diff --git a/.gitignore b/.gitignore index 0565d35a..db169364 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ local-dev* *.sw? .opencode/plans/* .hive +docs/personal/* # Build outputs build/ diff --git a/AGENTS.md b/AGENTS.md index c601cacb..dc72473e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,18 +107,56 @@ All scripts are in `package.json`. - Do not run git/GitHub commands unless explicitly asked. - Keep baseline green (run `bun run type-check`, `bun run lint`, `bun run build` before finalizing changes). +## Agent code of conduct +- Prefer the smallest correct change. +- Preserve working behavior before improving structure. +- Do not add cleverness where a direct implementation is enough. +- Do not infer critical state from weak signals when a stronger source exists. +- Do not encode policy only in UI; enforce it in core logic. +- Do not hide data loss, partial failure, or fallback behavior. Make it explicit in code. +- Finish work end-to-end: implementation, verification, and cleanup. + ## Development rules - Keep diffs tight; avoid drive-by refactors. -- Backend changes: keep web/desktop/vscode runtimes consistent (if relevant). -- Follow local precedent; search nearby code first. -- TypeScript: avoid `any`/blind casts; keep ESLint/TS green. -- React: prefer function components + hooks; class only when needed (e.g. error boundaries). -- Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`. -- Styling: Tailwind v4; typography via `packages/ui/src/lib/typography.ts`; theme vars via `packages/ui/src/lib/theme/`. -- Shared UI patterns: for "series of items + divider + series of items" layouts, use shared UI primitives instead of duplicating ad-hoc markup in feature components. -- Toasts: use custom toast wrapper from `@/components/ui` (backed by `packages/ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code. +- Follow local precedent; inspect nearby code before introducing new patterns. +- Backend changes: keep web, desktop, and VS Code behavior consistent when they share contracts. +- TypeScript: avoid `any`, blind casts, and shape guessing. +- React: prefer function components + hooks; use classes only when required. +- Control flow: prefer early returns and explicit branching over nested ternaries. +- Styling: Tailwind v4, typography via `packages/ui/src/lib/typography.ts`, theme vars via `packages/ui/src/lib/theme/`. +- Shared UI patterns: reuse shared primitives before introducing feature-local markup patterns. +- Toasts: use the wrapper from `@/components/ui`; do not import `sonner` directly in feature code. - No new deps unless asked. -- Never add secrets (`.env`, keys) or log sensitive data. +- Never add secrets or log sensitive data. + +## Architecture patterns + +### Thin entrypoints, focused modules +- Keep orchestration entrypoints thin: `index.js`, bridge files, bootstrap files, provider roots. +- Move route, domain, and runtime logic into focused modules with clear ownership. +- Prefer dependency injection over hidden module coupling. +- Add or update module documentation when ownership changes. + +### Strong source of truth +- Prefer deterministic state over heuristics. +- Use live server/session state for live activity. Do not let historical anomalies masquerade as current execution. +- If a fallback is necessary, scope it narrowly to the active entity and treat it as temporary. +- Restore derived UI state from authoritative records. Example: restore model or agent from the latest user message, not assistant-side guesses. + +### Live state vs historical state +- Derive live UI behavior from live state channels, not persisted history. +- Use historical records to restore context, not to infer that work is still in progress. +- If live state is delayed, use the narrowest possible transient fallback and clear it as soon as authoritative state arrives. + +### Cross-runtime parity +- If web defines a route or payload contract that shared UI depends on, keep VS Code and desktop parity where applicable. +- Shared behavior differences must be intentional and visible in code. +- Do not ship a web-only assumption into shared UI. + +### Partial-failure-safe flows +- Cross-directory and multi-entity operations must tolerate partial failure. +- Prefer per-item results, rollback paths, or resumable cleanup over all-or-nothing assumptions. +- Never leave optimistic state or local caches stranded after failure. ## CLI Parity and Safety Policy (MANDATORY) @@ -175,6 +213,102 @@ skill({ name: "theme-system" }) This skill contains all color tokens, semantic logic, decision tree, and usage patterns. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes. +## Performance rules (MANDATORY) + +These rules exist because violating them has caused measurable regressions (render cascades, memory bloat, UI jank). They apply to all UI and sync layer work. + +### Shared-store render discipline + +- **Treat common stores as render fanout boundaries.** An unnecessary reference change in shared state can re-render large parts of the app. +- **Do not put high-frequency state in broadly consumed stores.** Fast-changing state should live in narrow stores with narrow subscribers. +- **Update only the fields that changed.** Preserve references for untouched state branches. +- **Prefer leaf selectors over container selectors.** Subscribe to the smallest stable value that satisfies the component. +- **Isolate hot consumers.** If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child. + +### Zustand referential equality + +Zustand skips re-renders when a selector returns the same reference (`Object.is`). Every new object/array reference triggers a re-render in every subscriber. + +- **Never spread all state fields in an update.** Only create new references for fields that actually changed. A `message.part.delta` event should not clone `session`, `permission`, etc. +- **Select leaf values, not containers.** `useStore((s) => s.permission[sessionID])` is correct. `useStore((s) => s.permission)` subscribes to every permission change across all sessions. +- **Preserve references when merging.** If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added. + +### Store splitting + +A single store with N properties means every subscriber re-evaluates on every state change. Split stores by change frequency and subscriber set. + +- **Group state by how often it changes.** Streaming state (updated 60/sec) must not live with user preferences (updated on click). +- **Group state by who reads it.** If only 2 components need a value, it belongs in a store that only those 2 subscribe to. +- **Cross-store reads use `.getState()`.** Actions in one store that need another store call `useOtherStore.getState()` — imperative, no subscription. +- **Never add unrelated state to an existing store** just because it's convenient. Create a new store. + +### Event pipeline and SSE + +- **Gate expensive operations on the hot path.** During streaming, `message.part.delta` and `message.part.updated` fire ~60/sec. Any `findIndex`, `filter`, or iteration added to these handlers multiplies across every event. Gate behind a cheap boolean check first (e.g., check `next[0]` before scanning the array). +- **Skip no-op updates.** If an incoming event doesn't change the state (same role, same finish, same timestamps), return `false` from the reducer to avoid creating new references. +- **Coalesce by key.** Same-entity events (e.g., repeated `session.status` for the same session) should replace earlier ones in the queue, not accumulate. +- **Preserve event ordering semantics.** Reducers and queues must not let stale deltas or out-of-order events corrupt the latest state. +- **Do not widen live-activity fallbacks.** A fallback for delayed status should inspect only the current trailing entity, not arbitrary historical records. + +### Polling payload fidelity + +- **Do not let lightweight polling erase rich fields.** If light mode omits fields (e.g., `diffStats`), preserve previous rich data until a heavy follow-up fetch lands. +- **Use two-phase polling.** Run cheap change detection first; only run heavy status fetches for directories that actually changed. + +### Optimistic updates + +- **Use the shadow Map pattern.** Insert optimistic data into the store for instant UI, AND register it in a separate tracking Map. Cleanup happens deterministically via `mergeOptimisticPage` on the next data fetch — not via heuristics in the event reducer. +- **Pass client-generated IDs to the server.** Use the same ID format as the server (hex-encoded timestamps). Pass `messageID` to `promptAsync` so the server echoes back the same ID. This prevents duplicates and enables in-place replacement. +- **Rollback on error.** Remove the optimistic entry from both the store and the shadow Map. +- **Stabilize bridge callbacks.** When wiring hook callbacks into module-level refs, use stable ref wrappers so effects do not loop on changing function identities. + +### Session/input consistency + +- **Capture send config at queue time.** Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time. +- **Keep server-selected attachments sendable.** Preserve server-backed file selections in queue/submit flows and convert them to proper `file://` URLs before sending. + +### Bootstrap resilience + +- **Treat startup 502/503 as transient.** Retry bootstrap/session-list flows with bounded retries/intervals, especially in VS Code where API readiness can lag bridge startup. +- **Use polling recovery when failures are swallowed.** If an async loader resolves without throwing on failure, recover with interval retries gated by loaded-state checks. + +### Scroll and DOM + +- **Never use `await waitForFrames()` for scroll preservation.** Frames of visible scroll jump are unacceptable. Use `useLayoutEffect` to adjust scroll synchronously after React commits DOM — before the browser paints. +- **Capture scroll state before the state change, restore in layout effect.** The pattern: save `scrollHeight`/`scrollTop` into a ref before triggering the update, consume it in `useLayoutEffect` on the rendered output. + +### Component isolation + +- **Extract high-frequency hook consumers into separate components.** If a hook re-evaluates 60/sec (e.g., streaming status), wrap its consumer in a `React.memo` child component so the parent doesn't re-render. +- **Use custom `React.memo` comparators for message rows.** Compare render-relevant fields (role, finish, parts count, part IDs) — not object references. + +### Caching and memory + +- **Cap in-memory caches with both count and byte limits.** Entry count alone doesn't prevent memory bloat from large files. Use dual-constraint LRU (e.g., 40 entries OR 20MB). +- **Set store session limits to match loaded data.** If bootstrap loads N sessions, set `limit >= N`. Otherwise the next SSE event triggers trimming that silently removes sessions. +- **Invalidate caches on mutations.** File content cache must clear entries on write, delete, rename. Prefetch cache must clear on session eviction. +- **Use TTLs to prevent redundant fetches.** If a session was fetched <15s ago, skip re-fetching — SSE events keep it current. + +### Directory context + +- **Never cache directory strings in closures.** Directory can change at any time (worktree switch). Read it dynamically from `opencodeClient.getDirectory()` at call time. +- **Pass directory hints when the source of truth isn't available yet.** Newly created sessions aren't in the sync store until SSE delivers them. Pass the known directory as a parameter instead of relying on lookup. + +## Regression-prevention checklist +- When adding fallback logic, ask: can stale persisted data keep this path active forever? +- When deriving UI state, ask: is this live state, historical state, or inferred state? +- When adding store fields, ask: who reads this, how often does it change, and should it live elsewhere? +- When touching polling or bootstrap, ask: can a lighter payload erase richer existing data? +- When handling optimistic updates, ask: where is rollback, reconciliation, and duplicate prevention? +- When changing shared routes or state contracts, ask: what breaks in web, desktop, and VS Code? +- When fixing a bug with a heuristic, prefer narrowing the heuristic over widening it. + +## Validation expectations +- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing. +- For hot-path changes, verify behavior under streaming or repeated events, not just static render. +- For sync or startup changes, verify fresh load, retry/failure, and restart behavior. +- For session changes, verify create, stream, abort, permission, archive/delete, and revisit flows when relevant. + ## Recent changes - Releases + high-level changes: `CHANGELOG.md` - Recent commits: `git log --oneline` (latest tags: `v1.4.6`, `v1.4.5`) diff --git a/bun.lock b/bun.lock index daaa0f7d..57b26507 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -133,7 +133,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -179,7 +179,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", - "@tauri-apps/api": "^2.9.0", + "@tauri-apps/api": "^2.10.1", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", @@ -208,7 +208,7 @@ "version": "1.9.1", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -240,7 +240,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -817,7 +817,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.0", "", {}, "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.7", "", {}, "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg=="], "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="], diff --git a/package.json b/package.json index 24e9b710..aebff413 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 97ff0927..044061a7 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -14,7 +14,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "version_check", ] @@ -71,9 +71,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -90,27 +90,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "ashpd" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "tokio", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -137,9 +116,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -169,9 +148,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", @@ -204,7 +183,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -239,7 +218,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -289,6 +268,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -297,9 +291,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -325,22 +319,13 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2", ] [[package]] @@ -358,25 +343,26 @@ dependencies = [ [[package]] name = "borsh" -version = "1.5.7" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.5.7" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -402,17 +388,18 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byte-unit" -version = "5.1.6" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cd29c3c585209b0cbc7309bfe3ed7efd8c84c21b7af29c8bfae908f8777174" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" dependencies = [ "rust_decimal", + "schemars 1.2.1", "serde", "utf8-width", ] @@ -441,9 +428,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -453,9 +440,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -466,7 +453,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cairo-sys-rs", "glib", "libc", @@ -487,9 +474,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] @@ -514,7 +501,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -524,14 +511,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", ] [[package]] name = "cc" -version = "1.2.46" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -578,9 +565,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -641,11 +628,11 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "core-graphics-types", "foreign-types", @@ -658,7 +645,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "libc", ] @@ -723,6 +710,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -730,7 +730,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -740,14 +740,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -755,34 +755,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -796,7 +795,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -809,7 +808,28 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.110", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", ] [[package]] @@ -843,22 +863,16 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", + "bitflags 2.11.0", + "block2", "libc", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -869,23 +883,14 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", -] - -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading 0.8.9", + "syn 2.0.117", ] [[package]] name = "dlopen2" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ "dlopen2_derive", "libc", @@ -895,20 +900,29 @@ dependencies = [ [[package]] name = "dlopen2_derive" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "dom_query" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] [[package]] name = "dpi" @@ -921,9 +935,9 @@ dependencies = [ [[package]] name = "dtoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dtoa-short" @@ -948,14 +962,14 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "embed-resource" -version = "3.0.6" +version = "3.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "vswhom", "winreg", ] @@ -977,9 +991,9 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enumflags2" @@ -999,7 +1013,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1020,9 +1034,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", "serde_core", @@ -1096,27 +1110,26 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1128,6 +1141,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1146,7 +1171,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1182,9 +1207,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1192,15 +1217,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1209,9 +1234,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1228,32 +1253,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -1262,7 +1287,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1397,9 +1421,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1417,11 +1441,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gio" version = "0.18.4" @@ -1460,7 +1497,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -1488,7 +1525,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1567,7 +1604,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -1581,9 +1618,18 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -1617,18 +1663,27 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ "log", "mac", - "markup5ever", + "markup5ever 0.14.1", "match_token", ] [[package]] -name = "http" -version = "1.3.1" +name = "html5ever" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1701,14 +1756,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1725,9 +1779,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1749,9 +1803,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png", @@ -1805,9 +1859,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1819,9 +1873,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1838,6 +1892,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1878,12 +1938,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -1899,15 +1959,15 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1934,9 +1994,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "javascriptcore-rs" @@ -1970,7 +2030,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -1979,16 +2039,40 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2021,7 +2105,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "serde", "unicode-segmentation", ] @@ -2032,17 +2116,17 @@ version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ - "cssparser", - "html5ever", - "indexmap 2.12.0", - "selectors", + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.0", + "selectors 0.24.0", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libappindicator" @@ -2064,15 +2148,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading 0.7.4", + "libloading", "once_cell", ] [[package]] name = "libc" -version = "0.2.177" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -2084,32 +2168,23 @@ dependencies = [ "winapi", ] -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall", + "plain", + "redox_syscall 0.7.3", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -2128,9 +2203,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ "value-bag", ] @@ -2149,13 +2224,13 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", "time", ] @@ -2168,9 +2243,20 @@ dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", ] [[package]] @@ -2181,7 +2267,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2192,9 +2278,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -2213,9 +2299,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minisign-verify" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" [[package]] name = "miniz_oxide" @@ -2229,9 +2315,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2248,14 +2334,14 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "png", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -2265,8 +2351,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", - "jni-sys", + "bitflags 2.11.0", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -2286,7 +2372,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -2295,19 +2381,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nodrop" version = "0.1.14" @@ -2316,9 +2389,9 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "notify-rust" -version = "4.11.7" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" dependencies = [ "futures-lite", "log", @@ -2330,9 +2403,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -2345,9 +2418,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2355,14 +2428,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -2374,27 +2447,11 @@ dependencies = [ "libc", ] -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - [[package]] name = "objc2" -version = "0.5.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -2406,41 +2463,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", - "libc", - "objc2 0.6.3", - "objc2-cloud-kit", - "objc2-core-data", + "bitflags 2.11.0", + "block2", + "objc2", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2-foundation", ] [[package]] @@ -2449,9 +2476,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -2460,48 +2487,13 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-io-surface", ] -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2 0.6.3", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -2517,28 +2509,16 @@ dependencies = [ "cc", ] -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "libc", - "objc2 0.5.2", -] - [[package]] name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", + "bitflags 2.11.0", + "block2", "libc", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] @@ -2548,56 +2528,21 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-core-foundation", ] -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2 0.6.3", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-osa-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.10.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-foundation", ] [[package]] @@ -2606,20 +2551,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -2628,10 +2563,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.11.0", + "objc2", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", ] [[package]] @@ -2640,21 +2575,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", - "objc2 0.6.3", + "bitflags 2.11.0", + "block2", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-javascript-core", - "objc2-security", + "objc2-foundation", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" @@ -2675,7 +2608,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "log", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -2690,6 +2623,12 @@ dependencies = [ "window-vibrancy 0.7.1", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2722,12 +2661,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2779,7 +2718,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2826,6 +2765,17 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.8.0" @@ -2846,6 +2796,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.8.0" @@ -2876,6 +2836,16 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.10.0" @@ -2900,7 +2870,20 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2927,14 +2910,23 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.1", + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2944,9 +2936,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2959,6 +2951,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -2966,7 +2964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.12.0", + "indexmap 2.13.0", "quick-xml 0.38.4", "serde", "time", @@ -3029,6 +3027,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3051,11 +3059,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] @@ -3090,9 +3098,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3149,7 +3157,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3157,9 +3165,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -3170,7 +3178,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3192,9 +3200,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3205,6 +3213,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -3243,7 +3257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3273,7 +3287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -3291,14 +3305,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -3333,7 +3347,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", ] [[package]] @@ -3342,9 +3365,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3364,14 +3387,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -3381,9 +3404,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3392,9 +3415,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rend" @@ -3407,9 +3430,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -3435,6 +3458,44 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3444,32 +3505,30 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] name = "rfd" -version = "0.15.4" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "ashpd", - "block2 0.6.2", + "block2", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", "log", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3480,7 +3539,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3488,9 +3547,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" dependencies = [ "bitvec", "bytecheck", @@ -3506,9 +3565,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" dependencies = [ "proc-macro2", "quote", @@ -3517,9 +3576,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.39.0" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" dependencies = [ "arrayvec", "borsh", @@ -3529,13 +3588,14 @@ dependencies = [ "rkyv", "serde", "serde_json", + "wasm-bindgen", ] [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3548,11 +3608,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3561,9 +3621,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", "ring", @@ -3574,20 +3634,59 @@ dependencies = [ ] [[package]] -name = "rustls-pki-types" -version = "1.13.0" +name = "rustls-native-certs" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.103.8" +name = "rustls-platform-verifier" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -3602,9 +3701,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3615,6 +3714,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3644,9 +3752,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -3663,15 +3771,9 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.110", + "syn 2.0.117", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -3684,6 +3786,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3691,14 +3816,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", - "cssparser", - "derive_more", + "cssparser 0.29.6", + "derive_more 0.99.20", "fxhash", "log", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", - "servo_arc", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", "smallvec", ] @@ -3751,7 +3895,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3762,20 +3906,20 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3786,7 +3930,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3800,9 +3944,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -3821,17 +3965,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10574371d41b0d9b2cff89418eda27da52bcaff2cc8741db26382a77c29131f1" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.1.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -3840,14 +3984,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3869,7 +4013,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -3882,6 +4026,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3933,18 +4086,19 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -3960,15 +4114,15 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -3978,34 +4132,34 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "softbuffer" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", - "cfg_aliases", - "core-graphics", - "foreign-types", "js-sys", - "log", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", + "tracing", "wasm-bindgen", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4040,12 +4194,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.8.9" @@ -4059,6 +4207,18 @@ dependencies = [ "serde", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + [[package]] name = "string_cache_codegen" version = "0.5.4" @@ -4071,6 +4231,18 @@ dependencies = [ "quote", ] +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4107,9 +4279,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4133,7 +4305,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4151,35 +4323,33 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.5" +version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", + "bitflags 2.11.0", + "block2", "core-foundation", "core-graphics", "crossbeam-channel", - "dispatch", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni", - "lazy_static", "libc", "log", "ndk", "ndk-context", "ndk-sys", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "parking_lot", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -4197,7 +4367,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -4208,9 +4378,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -4225,9 +4395,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.9.4" +version = "2.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15524fc7959bfcaa051ba6d0b3fb1ef18e978de2176c7c6acb977f7fd14d35c7" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" dependencies = [ "anyhow", "bytes", @@ -4245,15 +4415,15 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4264,7 +4434,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tray-icon", "url", @@ -4276,9 +4446,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.3" +version = "2.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", @@ -4292,15 +4462,15 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.2" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" dependencies = [ "base64 0.22.1", "brotli", @@ -4314,9 +4484,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.110", + "syn 2.0.117", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "url", "uuid", @@ -4325,23 +4495,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.2" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.5.1" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" dependencies = [ "anyhow", "glob", @@ -4350,15 +4520,15 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-plugin-dialog" -version = "2.4.2" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" dependencies = [ "log", "raw-window-handle", @@ -4368,15 +4538,15 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.4.4" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" dependencies = [ "anyhow", "dunce", @@ -4389,30 +4559,30 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.17", - "toml 0.9.8", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-log" -version = "2.7.1" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5709c792b8630290b5d9811a1f8fe983dd925fc87c7fc7f4923616458cd00b6" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" dependencies = [ "android_logger", "byte-unit", "fern", "log", - "objc2 0.6.3", - "objc2-foundation 0.3.2", + "objc2", + "objc2-foundation", "serde", "serde_json", "serde_repr", "swift-rs", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -4430,16 +4600,16 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "url", ] [[package]] name = "tauri-plugin-shell" -version = "2.3.3" +version = "2.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c374b6db45f2a8a304f0273a15080d98c70cde86178855fc24653ba657a1144c" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" dependencies = [ "encoding_rs", "log", @@ -4452,15 +4622,15 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", ] [[package]] name = "tauri-plugin-updater" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", "dirs", @@ -4472,7 +4642,8 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", + "rustls", "semver", "serde", "serde_json", @@ -4480,7 +4651,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "url", @@ -4490,23 +4661,23 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.9.2" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" dependencies = [ "cookie", "dpi", "gtk", "http", "jni", - "objc2 0.6.3", + "objc2", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", "serde", "serde_json", "tauri-utils", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webview2-com", @@ -4515,17 +4686,16 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.9.2" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7950f3bde6bcca6655bc5e76d3d6ec587ceb81032851ab4ddbe1f508bdea2729" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http", "jni", "log", - "objc2 0.6.3", + "objc2", "objc2-app-kit", - "objc2-foundation 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -4542,9 +4712,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" dependencies = [ "anyhow", "brotli", @@ -4552,7 +4722,7 @@ dependencies = [ "ctor", "dunce", "glob", - "html5ever", + "html5ever 0.29.1", "http", "infer", "json-patch", @@ -4570,8 +4740,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.17", - "toml 0.9.8", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4586,7 +4756,7 @@ checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" dependencies = [ "dunce", "embed-resource", - "toml 0.9.8", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -4596,19 +4766,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows", "windows-version", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4625,6 +4795,16 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4636,11 +4816,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4651,25 +4831,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -4677,22 +4857,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4710,9 +4890,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -4725,17 +4905,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", - "tracing", "windows-sys 0.61.2", ] @@ -4751,9 +4929,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -4776,17 +4954,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.3", - "toml_datetime 0.7.3", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.13", + "winnow 0.7.15", ] [[package]] @@ -4800,9 +4978,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -4813,7 +5000,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -4824,7 +5011,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -4833,36 +5020,36 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ - "indexmap 2.12.0", - "toml_datetime 0.7.3", + "indexmap 2.13.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow 0.7.13", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow 0.7.13", + "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -4875,11 +5062,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -4905,9 +5092,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -4916,43 +5103,43 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "tray-icon" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", "dirs", "libappindicator", "muda", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.2", + "objc2-foundation", "once_cell", "png", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -4976,13 +5163,13 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -5028,15 +5215,21 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -5046,14 +5239,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -5076,9 +5270,9 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8-width" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" [[package]] name = "utf8_iter" @@ -5088,21 +5282,21 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] [[package]] name = "value-bag" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "version-compare" @@ -5169,18 +5363,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -5191,22 +5394,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5214,31 +5414,53 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-streams" -version = "0.4.2" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5248,70 +5470,22 @@ dependencies = [ ] [[package]] -name = "wayland-backend" -version = "0.3.11" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" -dependencies = [ - "bitflags 2.10.0", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" -dependencies = [ - "proc-macro2", - "quick-xml 0.37.5", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" -dependencies = [ - "dlib", - "log", - "pkg-config", + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", @@ -5328,10 +5502,22 @@ dependencies = [ ] [[package]] -name = "webkit2gtk" -version = "2.0.1" +name = "web_atoms" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -5353,9 +5539,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -5372,19 +5558,28 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.4" +name = "webpki-root-certs" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] [[package]] name = "webview2-com" -version = "0.38.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", @@ -5396,22 +5591,22 @@ dependencies = [ [[package]] name = "webview2-com-macros" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] name = "webview2-com-sys" -version = "0.38.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "windows", "windows-core 0.61.2", ] @@ -5453,10 +5648,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -5468,10 +5663,10 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" dependencies = [ - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "raw-window-handle", "windows-sys 0.60.2", "windows-version", @@ -5544,7 +5739,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -5555,7 +5750,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -5876,9 +6071,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -5895,9 +6099,91 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -5907,30 +6193,29 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.5" +version = "0.54.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" dependencies = [ "base64 0.22.1", - "block2 0.6.2", + "block2", "cookie", "crossbeam-channel", "dirs", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever", "http", "javascriptcore-rs", "jni", - "kuchikiki", "libc", "ndk", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", - "objc2-foundation 0.3.2", + "objc2-foundation", "objc2-ui-kit", "objc2-web-kit", "once_cell", @@ -5939,7 +6224,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", "webkit2gtk", "webkit2gtk-sys", @@ -6009,15 +6294,15 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] [[package]] name = "zbus" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -6033,16 +6318,16 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.13", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -6050,14 +6335,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -6065,34 +6350,33 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", - "winnow 0.7.13", + "winnow 0.7.15", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -6112,7 +6396,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "synstructure", ] @@ -6152,7 +6436,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", ] [[package]] @@ -6163,47 +6447,52 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.12.0", + "indexmap 2.13.0", "memchr", ] [[package]] -name = "zvariant" -version = "5.8.0" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", "serde", - "url", - "winnow 0.7.13", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.117", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.110", - "winnow 0.7.13", + "syn 2.0.117", + "winnow 0.7.15", ] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 3afbd71b..502f5d3b 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -19,17 +19,17 @@ log = "0.4.28" reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "blocking"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.143" -tauri = { version = "2.9.4", features = ["macos-private-api"] } -tauri-plugin-dialog = "2.4.2" -tauri-plugin-log = "2.7.1" -tauri-plugin-shell = "2.3.3" +tauri = { version = "2.10.3", features = ["macos-private-api"] } +tauri-plugin-dialog = "2.6.0" +tauri-plugin-log = "2.8.0" +tauri-plugin-shell = "2.3.5" tauri-plugin-notification = "2.3.3" -tauri-plugin-updater = "2" +tauri-plugin-updater = "2.10.0" tokio = { version = "1.38", features = ["rt-multi-thread", "time"] } url = "2.5" [build-dependencies] -tauri-build = { version = "2.5.3", features = [] } +tauri-build = { version = "2.5.6", features = [] } [target.'cfg(target_os = "macos")'.dependencies] window-vibrancy = "0.7.1" diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index a5335deb..c7964ad3 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -22,7 +22,6 @@ use std::{ }, time::Duration, }; -use tauri::utils::config::BackgroundThrottlingPolicy; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; #[cfg(target_os = "macos")] use window_vibrancy::{ @@ -277,7 +276,7 @@ fn build_macos_menu( MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID, "Toggle Memory Debug", true, - Some("Cmd+Shift+D"), + Some("CmdOrCtrl+Shift+D"), )?; let help_dialog = MenuItem::with_id( @@ -2630,8 +2629,7 @@ fn create_window( .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) .decorations(true) .visible(false) - .initialization_script(&init_script) - .background_throttling(BackgroundThrottlingPolicy::Disabled); + .initialization_script(&init_script); let apply_restored_state = restored_state .as_ref() @@ -2693,8 +2691,7 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) .decorations(true) .visible(true) - .initialization_script(&splash_script) - .background_throttling(BackgroundThrottlingPolicy::Disabled); + .initialization_script(&splash_script); let apply_restored_state = restored_state .as_ref() diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 853ba53b..0979b05e 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -28,8 +28,7 @@ "y": 26 }, "dragDropEnabled": false, - "visible": false, - "backgroundThrottling": "disabled" + "visible": false } ], "security": { diff --git a/packages/ui/package.json b/packages/ui/package.json index c55e71c7..bcc7e67e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -85,7 +85,7 @@ "devDependencies": { "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", - "@tauri-apps/api": "^2.9.0", + "@tauri-apps/api": "^2.10.1", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", "@types/qrcode": "^1.5.5", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 13875df0..b5de0414 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,12 +6,12 @@ import { ChatView } from '@/components/views'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; +import { setStreamPerfEnabled } from '@/stores/utils/streamDebug'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; -import { useEventStream } from '@/hooks/useEventStream'; +// useEventStream removed — replaced by SyncProvider + SyncBridge import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useMenuActions } from '@/hooks/useMenuActions'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; -import { useServerSessionStatus } from '@/hooks/useServerSessionStatus'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend'; import { useRouter } from '@/hooks/useRouter'; @@ -25,9 +25,12 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { hasModifier } from '@/lib/utils'; import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop'; import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { opencodeClient } from '@/lib/opencode/client'; +import { SyncProvider, useSessions } from '@/sync/sync-context'; +import { useSync } from '@/sync/use-sync'; +import { setOptimisticRefs } from '@/sync/session-actions'; import { useFontPreferences } from '@/hooks/useFontPreferences'; import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; @@ -45,7 +48,8 @@ const CLI_MISSING_ERROR_REGEX = const CLI_ONBOARDING_HEALTH_POLL_MS = 1500; const AboutDialogWrapper: React.FC = () => { - const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore(); + const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen); + const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); return ( { }; }; +const EmbeddedSessionSelectionGate: React.FC<{ + embeddedSessionChat: EmbeddedSessionChatConfig | null; + isVSCodeRuntime: boolean; +}> = ({ embeddedSessionChat, isVSCodeRuntime }) => { + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + + React.useEffect(() => { + if (!embeddedSessionChat || isVSCodeRuntime) { + return; + } + + if (currentSessionId === embeddedSessionChat.sessionId) { + return; + } + + if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) { + return; + } + + void setCurrentSession(embeddedSessionChat.sessionId); + }, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]); + + return null; +}; + +const SyncOptimisticBridge: React.FC = () => { + const sync = useSync(); + const addRef = React.useRef(sync.optimistic.add); + const removeRef = React.useRef(sync.optimistic.remove); + addRef.current = sync.optimistic.add; + removeRef.current = sync.optimistic.remove; + + React.useEffect(() => { + setOptimisticRefs( + (input) => addRef.current(input), + (input) => removeRef.current(input), + ); + }, []); + + return null; +}; + +function SyncAppEffects({ apis, embeddedBackgroundWorkEnabled }: { + apis: RuntimeAPIs; + embeddedBackgroundWorkEnabled: boolean; +}) { + const githubApi = embeddedBackgroundWorkEnabled ? apis.github : undefined; + useGitHubPrBackgroundTracking(githubApi, apis.git); + usePwaManifestSync(); + useSessionAutoCleanup(embeddedBackgroundWorkEnabled); + useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled); + useKeyboardShortcuts(); + + return ; +} + function App({ apis }: AppProps) { - const { initializeApp, isInitialized, isConnected } = useConfigStore(); + const initializeApp = useConfigStore((s) => s.initializeApp); + const isInitialized = useConfigStore((s) => s.isInitialized); + const isConnected = useConfigStore((s) => s.isConnected); const providersCount = useConfigStore((state) => state.providers.length); const agentsCount = useConfigStore((state) => state.agents.length); const loadProviders = useConfigStore((state) => state.loadProviders); const loadAgents = useConfigStore((state) => state.loadAgents); - const { error, clearError, loadSessions } = useSessionStore(); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const sessions = useSessionStore((state) => state.sessions); + const error = useSessionUIStore((s) => s.error); + const clearError = useSessionUIStore((s) => s.clearError); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const setDirectory = useDirectoryStore((state) => state.setDirectory); const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory); @@ -118,6 +180,13 @@ function App({ apis }: AppProps) { const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []); const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; + React.useEffect(() => { + setStreamPerfEnabled(showMemoryDebug); + return () => { + setStreamPerfEnabled(false); + }; + }, [showMemoryDebug]); + React.useEffect(() => { setIsVSCodeRuntime(apis.runtime.isVSCode); }, [apis.runtime.isVSCode]); @@ -135,8 +204,6 @@ function App({ apis }: AppProps) { void refreshGitHubAuthStatus(apis.github, { force: true }); }, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]); - useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git); - React.useEffect(() => { if (typeof document === 'undefined') { return; @@ -202,70 +269,45 @@ function App({ apis }: AppProps) { init(); }, [initializeApp, isVSCodeRuntime]); - const startupRecoveryInProgressRef = React.useRef(false); - const startupRecoveryLastAttemptRef = React.useRef(0); - + // Startup recovery: poll until providers AND agents are loaded. + // loadProviders/loadAgents resolve normally even on failure (errors swallowed), + // so a reactive effect can't detect failure — we need an interval. React.useEffect(() => { - if (isVSCodeRuntime) { - return; - } - if (!isConnected) { - return; - } - if (providersCount > 0 && agentsCount > 0) { - return; - } - if (startupRecoveryInProgressRef.current) { - return; - } + if (isVSCodeRuntime || !isConnected) return; + if (providersCount > 0 && agentsCount > 0) return; - const now = Date.now(); - if (now - startupRecoveryLastAttemptRef.current < 750) { - return; - } - - startupRecoveryLastAttemptRef.current = now; - startupRecoveryInProgressRef.current = true; - - const repair = async () => { + let active = true; + const attempt = async () => { + const state = useConfigStore.getState(); + if (state.providers.length > 0 && state.agents.length > 0) return; try { - if (providersCount === 0) { - await loadProviders(); - } - if (agentsCount === 0) { - await loadAgents(); - } - } catch { - // Keep UI responsive; we'll retry on next cycle. - } finally { - startupRecoveryInProgressRef.current = false; - } + if (state.providers.length === 0) await loadProviders(); + if (useConfigStore.getState().agents.length === 0) await loadAgents(); + } catch { /* retry next interval */ } }; - void repair(); - }, [agentsCount, isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount]); + void attempt(); + const id = setInterval(() => { if (active) void attempt(); }, 2000); + return () => { active = false; clearInterval(id); }; + }, [isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount, agentsCount]); React.useEffect(() => { if (isSwitchingDirectory) { return; } - const syncDirectoryAndSessions = async () => { - // VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races. - if (isVSCodeRuntime) { - return; - } + // VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races. + if (isVSCodeRuntime) { + return; + } - if (!isConnected) { - return; - } - opencodeClient.setDirectory(currentDirectory); + if (!isConnected) { + return; + } + opencodeClient.setDirectory(currentDirectory); - await loadSessions(); - }; - - syncDirectoryAndSessions(); - }, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]); + // Session loading is handled by the sync system's bootstrap — no manual loadSessions needed. + }, [currentDirectory, isSwitchingDirectory, isConnected, isVSCodeRuntime]); React.useEffect(() => { if (!embeddedSessionChat || typeof window === 'undefined') { @@ -317,22 +359,6 @@ function App({ apis }: AppProps) { setDirectory(embeddedSessionChat.directory, { showOverlay: false }); }, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]); - React.useEffect(() => { - if (!embeddedSessionChat || isVSCodeRuntime) { - return; - } - - if (currentSessionId === embeddedSessionChat.sessionId) { - return; - } - - if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) { - return; - } - - void setCurrentSession(embeddedSessionChat.sessionId); - }, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]); - React.useEffect(() => { if (!embeddedSessionChat || typeof window === 'undefined') { return; @@ -365,22 +391,17 @@ function App({ apis }: AppProps) { window.dispatchEvent(new Event('openchamber:app-ready')); }, [isInitialized, isSwitchingDirectory]); - useEventStream({ enabled: embeddedBackgroundWorkEnabled }); + // useEventStream replaced by SyncProvider + SyncBridge - // Server-authoritative session status polling - // Replaces SSE-dependent status updates with reliable HTTP polling - useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled }); + // Session attention now handled by notification-store via SSE events (session.idle/session.error) usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); - usePwaManifestSync(); usePwaInstallPrompt(); useWindowTitle(); useRouter(); - useKeyboardShortcuts(); - const handleToggleMemoryDebug = React.useCallback(() => { setShowMemoryDebug(prev => !prev); }, []); @@ -388,8 +409,6 @@ function App({ apis }: AppProps) { useMenuActions(handleToggleMemoryDebug); useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); - useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled }); - useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled }); React.useEffect(() => { if (embeddedSessionChat) { @@ -397,14 +416,19 @@ function App({ apis }: AppProps) { } const handleKeyDown = (e: KeyboardEvent) => { - if (hasModifier(e) && e.shiftKey && e.key === 'D') { + const isDebugShortcut = hasModifier(e) + && e.shiftKey + && !e.altKey + && (e.code === 'KeyD' || e.key.toLowerCase() === 'd'); + + if (isDebugShortcut) { e.preventDefault(); setShowMemoryDebug(prev => !prev); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); }, [embeddedSessionChat]); React.useEffect(() => { @@ -478,14 +502,18 @@ function App({ apis }: AppProps) { if (embeddedSessionChat) { return ( - - -
- - -
-
-
+ + + +
+ + + + +
+
+
+
); } @@ -493,62 +521,71 @@ function App({ apis }: AppProps) { // VS Code runtime - simplified layout without git/terminal views if (isVSCodeRuntime) { // Check if this is the Agent Manager panel - const panelType = typeof window !== 'undefined' - ? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__ + const panelType = typeof window !== 'undefined' + ? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__ : 'chat'; - + if (panelType === 'agentManager') { return ( - - -
- - -
-
-
-
- ); - } - - return ( - - - + +
- + +
-
-
+ + +
+ ); + } + + return ( + + + + + +
+ + + +
+
+
+
+
); } return ( - - - - - -
- - - - - {showMemoryDebug && ( - setShowMemoryDebug(false)} /> - )} -
-
-
-
-
-
+ + + + + + +
+ + + + + + {showMemoryDebug && ( + setShowMemoryDebug(false)} /> + )} +
+
+
+
+
+
+
); } diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index d0724458..b36c5168 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { RiArrowLeftLine } from '@remixicon/react'; -import { useShallow } from 'zustand/react/shallow'; import type { Message, Part } from '@opencode-ai/sdk/v2'; import { ChatInput } from './ChatInput'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; @@ -14,10 +12,10 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager } from '@/hooks/useChatScrollManager'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; +import { useTimelineStaging } from '@/hooks/useTimelineStaging'; import { useDeviceInfo } from '@/lib/device'; import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; -import { TimelineDialog } from './TimelineDialog'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import { cn } from '@/lib/utils'; @@ -26,6 +24,19 @@ import { flattenBlockingRequests, } from './lib/blockingRequests'; +// New sync system imports +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useStreamingStore } from '@/sync/streaming'; +import { + useSessionMessageRecords, + useSessions, + useDirectorySync, + useSessionStatus, +} from '@/sync/sync-context'; +import { useSync } from '@/sync/use-sync'; +import { getAllSyncSessions } from '@/sync/sync-refs'; + const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; @@ -71,101 +82,97 @@ const HYDRATING_SKELETON_ITEMS: Array<{ ]; export const ChatContainer: React.FC = () => { - const { - currentSessionId, - loadMessages, - loadMoreMessages, - updateViewportAnchor, - openNewSessionDraft, - setCurrentSession, - newSessionDraft, - } = useSessionStore( - useShallow((state) => ({ - currentSessionId: state.currentSessionId, - loadMessages: state.loadMessages, - loadMoreMessages: state.loadMoreMessages, - updateViewportAnchor: state.updateViewportAnchor, - openNewSessionDraft: state.openNewSessionDraft, - setCurrentSession: state.setCurrentSession, - newSessionDraft: state.newSessionDraft, - })) + // Session UI state + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor); + const isSyncing = useViewportStore((s) => s.isSyncing); + const sessionMemoryStateMap = useViewportStore((s) => s.sessionMemoryState); + + // Sync actions + const sync = useSync(); + const loadMessages = React.useCallback( + (sessionId: string) => sync.syncSession(sessionId), + [sync], + ); + const loadMoreMessages = React.useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId), + [sync], ); - const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore( - useShallow((state) => ({ - isSyncing: state.isSyncing, - messageStreamStates: state.messageStreamStates, - sessionMemoryStateMap: state.sessionMemoryState, - })) - ); + // UI store + const { isExpandedInput, stickyUserHeader, chatRenderMode } = useUIStore(); - const { - isTimelineDialogOpen, - setTimelineDialogOpen, - isExpandedInput, - stickyUserHeader, - chatRenderMode, - } = useUIStore(); - - const sessionMessages = useSessionStore( + // Streaming state + const streamingMessageId = useStreamingStore( React.useCallback( - (state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES), - [currentSessionId] - ) + (s) => (currentSessionId ? s.streamingMessageIds.get(currentSessionId) ?? null : null), + [currentSessionId], + ), + ); + // Messages from sync system + const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); + const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; + + // Sessions from sync system + const sessions = useSessions(); + + // Session status from sync system + const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS; + + // Permissions & questions from sync system + const allPermissions = useDirectorySync( + React.useCallback((s) => s.permission ?? {}, []), + ); + const allQuestions = useDirectorySync( + React.useCallback((s) => s.question ?? {}, []), ); - const sessions = useSessionStore((state) => state.sessions); + // Convert Record → Map for blockingRequests helpers + const permissionsMap = React.useMemo(() => { + const m = new Map(); + for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]); + return m; + }, [allPermissions]); - const blockingRequestState = useSessionStore( - useShallow((state) => ({ - sessions: state.sessions, - permissions: state.permissions, - questions: state.questions, - })) - ); + const questionsMap = React.useMemo(() => { + const m = new Map(); + for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]); + return m; + }, [allQuestions]); const scopedSessionIds = React.useMemo( () => collectVisibleSessionIdsForBlockingRequests( - blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })), + sessions.map((session) => ({ id: session.id, parentID: session.parentID })), currentSessionId, ), - [blockingRequestState.sessions, currentSessionId] + [sessions, currentSessionId], ); const sessionPermissions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS; - return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds); - }, [blockingRequestState.permissions, scopedSessionIds]); + return flattenBlockingRequests(permissionsMap, scopedSessionIds); + }, [permissionsMap, scopedSessionIds]); const sessionQuestions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS; - return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds); - }, [blockingRequestState.questions, scopedSessionIds]); + return flattenBlockingRequests(questionsMap, scopedSessionIds); + }, [questionsMap, scopedSessionIds]); - const historyMeta = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null), - [currentSessionId] - ) - ); + // History metadata — use sync's hasMore/isLoading + const historyMeta = React.useMemo(() => { + if (!currentSessionId) return null; + return { + limit: sessionMessages.length, + complete: !sync.hasMore(currentSessionId), + loading: sync.isLoading(currentSessionId), + }; + }, [currentSessionId, sessionMessages.length, sync]); - const streamingMessageId = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null), - [currentSessionId] - ) - ); - - const sessionStatusForCurrent = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS), - [currentSessionId] - ) - ); - - const hasSessionMessagesEntry = useSessionStore( - React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId]) - ); + const hasSessionMessagesEntry = sessionMessages.length > 0 || (currentSessionId ? sync.hasMore(currentSessionId) : false); const { isMobile } = useDeviceInfo(); const draftOpen = Boolean(newSessionDraft?.open); @@ -173,24 +180,18 @@ export const ChatContainer: React.FC = () => { const messageListRef = React.useRef(null); const parentSession = React.useMemo(() => { - if (!currentSessionId) { - return null; - } - + if (!currentSessionId) return null; const current = sessions.find((session) => session.id === currentSessionId); const parentID = current?.parentID; - if (!parentID) { - return null; - } - - return sessions.find((session) => session.id === parentID) ?? null; + if (!parentID) return null; + return sessions.find((session) => session.id === parentID) + ?? getAllSyncSessions().find((session) => session.id === parentID) + ?? null; }, [currentSessionId, sessions]); const handleReturnToParentSession = React.useCallback(() => { - if (!parentSession) { - return; - } - void setCurrentSession(parentSession.id); + if (!parentSession) return; + setCurrentSession(parentSession.id); }, [parentSession, setCurrentSession]); const returnToParentButton = parentSession ? ( @@ -219,13 +220,15 @@ export const ChatContainer: React.FC = () => { }, [sessionPermissions, sessionQuestions]); const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {}); + const handleActiveTurnChange = React.useCallback((turnId: string | null) => { + activeTurnChangeRef.current(turnId); + }, []); const { scrollRef, handleMessageContentChange, getAnimationHandlers, scrollToBottom, - releasePinnedScroll, isPinned, isOverflowing, isProgrammaticFollowActive, @@ -238,16 +241,20 @@ export const ChatContainer: React.FC = () => { isSyncing, isMobile, chatRenderMode, - messageStreamStates, sessionPermissions: sessionBlockingCards, - onActiveTurnChange: (turnId) => { - activeTurnChangeRef.current(turnId); - }, + onActiveTurnChange: handleActiveTurnChange, + }); + + // Deferred timeline staging — renders 1 message on first paint, + // adds 3 per rAF frame to avoid blocking. + const { stagedMessages } = useTimelineStaging({ + sessionKey: currentSessionId ?? '', + messages: sessionMessages, }); const timelineController = useChatTimelineController({ sessionId: currentSessionId, - messages: sessionMessages, + messages: stagedMessages, historyMeta, scrollRef, messageListRef, @@ -272,16 +279,11 @@ export const ChatContainer: React.FC = () => { }); React.useEffect(() => { - if (typeof window === 'undefined' || !currentSessionId) { - return; - } + if (typeof window === 'undefined' || !currentSessionId) return; const handleSessionReselected = (event: Event) => { const customEvent = event as CustomEvent; - if (customEvent.detail !== currentSessionId) { - return; - } - + if (customEvent.detail !== currentSessionId) return; resumeToBottomInstant(); }; @@ -293,9 +295,7 @@ export const ChatContainer: React.FC = () => { React.useLayoutEffect(() => { const container = scrollRef.current; - if (!container) { - return; - } + if (!container) return; const updateChatScrollHeight = () => { container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`); @@ -329,23 +329,15 @@ export const ChatContainer: React.FC = () => { }; }, [currentSessionId, isDesktopExpandedInput, scrollRef]); - const hasHistoryMetadata = React.useMemo(() => { - return Boolean(historyMeta); - }, [historyMeta]); + const hasHistoryMetadata = Boolean(historyMeta); const isSessionHydrating = Boolean(currentSessionId) && (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true); React.useEffect(() => { - if (!currentSessionId) { - return; - } - - const hasSessionMessages = hasSessionMessagesEntry; - if (hasSessionMessages && hasHistoryMetadata) { - return; - } + if (!currentSessionId) return; + if (hasSessionMessagesEntry && hasHistoryMetadata) return; const load = async () => { await loadMessages(currentSessionId).finally(() => { @@ -523,6 +515,9 @@ export const ChatContainer: React.FC = () => { { )} - - { - releasePinnedScroll(); - return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false }); - }} - onScrollByTurnOffset={(offset) => { - releasePinnedScroll(); - void navigation.scrollByTurnOffset(offset); - }} - onResumeToLatest={navigation.resumeToLatest} - /> ); }; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index dd1526d7..b61102e1 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -16,12 +16,16 @@ import { RiSendPlane2Line, } from '@remixicon/react'; import { BrowserVoiceButton } from '@/components/voice'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useSessionStore as useSessionManagementStore } from '@/stores/sessionStore'; +// sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import { useInputStore } from '@/sync/input-store'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import * as sessionActions from '@/sync/session-actions'; +import { useSessionMessageRecords } from '@/sync/sync-context'; import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; import { appendInlineComments } from '@/lib/messages/inlineComments'; import { AttachedFilesList } from './FileAttachment'; @@ -40,8 +44,7 @@ import { MobileSessionStatusBar } from './MobileSessionStatusBar'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; -import { useFileStore } from '@/stores/fileStore'; -import { useMessageStore } from '@/stores/messageStore'; +// useMessageStore removed — messages now come from sync system import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; @@ -81,6 +84,28 @@ const VS_CODE_DROP_DATA_TYPES = [ const FILE_URI_PREFIX = 'file://'; +const encodeFilePath = (filepath: string): string => { + let normalized = filepath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(normalized)) { + normalized = `/${normalized}`; + } + return normalized + .split('/') + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment; + return encodeURIComponent(segment); + }) + .join('/'); +}; + +const toServerFileUrl = (filepath: string): string => { + const normalized = filepath.replace(/\\/g, '/').trim(); + if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) { + return normalized; + } + return `file://${encodeFilePath(normalized)}`; +}; + const isLikelyAbsolutePath = (value: string): boolean => ( value.startsWith('/') || value.startsWith('\\\\') @@ -273,7 +298,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const initialSessionIdRef = React.useRef(null); const [message, setMessage] = React.useState(() => { // Read per-session draft at mount time using the current session from the store - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; initialSessionIdRef.current = sessionId; const draft = getStoredDraft(sessionId); if (draft) { @@ -313,25 +338,32 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const lastPersistedDraftRef = React.useRef>(new Map()); const currentSessionIdForDraftRef = React.useRef(null); - const sendMessage = useSessionStore((state) => state.sendMessage); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraft = useSessionStore((state) => state.newSessionDraft); + // TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sendMessage = React.useRef((...args: any[]) => + Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)), + ).current; + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraftOpen = Boolean(newSessionDraft?.open); - const setNewSessionDraftTarget = useSessionStore((state) => state.setNewSessionDraftTarget); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation); - const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort); - const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId); - const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt); - const attachedFiles = useSessionStore((state) => state.attachedFiles); - const addAttachedFile = useSessionStore((state) => state.addAttachedFile); - const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles); - const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection); - const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); - const pendingInputText = useSessionStore((state) => state.pendingInputText); - const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts); - const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId); + const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); + const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject); + const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); + const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); + const attachedFiles = useInputStore((s) => s.attachedFiles); + const addAttachedFile = useInputStore((s) => s.addAttachedFile); + const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles); + const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection); + const consumePendingInputText = useInputStore((s) => s.consumePendingInputText); + const setPendingInputText = useInputStore((s) => s.setPendingInputText); + const pendingInputText = useInputStore((s) => s.pendingInputText); + const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts); + const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort); + const abortCurrentOperation = React.useCallback( + (sessionIdOverride?: string) => sessionActions.abortCurrentOperation(sessionIdOverride ?? currentSessionId ?? ''), + [currentSessionId], + ); + const currentManagementSessionId = currentSessionId; const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); @@ -339,7 +371,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); - const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore(); + const { isMobile, inputBarOffset, isKeyboardOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore(); const { working } = useAssistantStatus(); const { git: runtimeGit } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); @@ -351,10 +383,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const isDesktopExpanded = isExpandedInput && !isMobile; const chatInputRadius = 'var(--radius-lg)'; - const sendableAttachedFiles = React.useMemo( - () => attachedFiles.filter((file) => file.source !== 'server'), - [attachedFiles], - ); + const sendableAttachedFiles = attachedFiles; const hasInlineMentionForHighlight = React.useMemo(() => { if (!message || !message.includes('@') || inputMode === 'shell') { @@ -426,8 +455,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const sanitizeAttachmentsForSend = React.useCallback( (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? []) - .filter((file) => file.source !== 'server') - .map((file) => ({ ...file })), + .map((file) => ({ + ...file, + dataUrl: file.source === 'server' && file.serverPath + ? toServerFileUrl(file.serverPath) + : file.dataUrl, + })), [], ); @@ -498,7 +531,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo filename, mimeType: 'text/plain', size: 0, - dataUrl: normalizedServerPath, + dataUrl: toServerFileUrl(normalizedServerPath), source: 'server', serverPath: normalizedServerPath, }); @@ -565,15 +598,10 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // User message history for up/down arrow navigation // Get raw messages from store (stable reference) - const sessionMessages = useMessageStore( - React.useCallback( - (state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined), - [currentSessionId] - ) - ); + const sessionMessages = useSessionMessageRecords(currentSessionId ?? ""); // Derive user message history with useMemo to avoid infinite re-renders const userMessageHistory = React.useMemo(() => { - if (!sessionMessages) return []; + if (!sessionMessages || !currentSessionId) return []; return sessionMessages .filter((m) => m.info.role === 'user') .map((m) => { @@ -585,7 +613,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }) .filter((text) => text.length > 0) .reverse(); // Most recent first - }, [sessionMessages]); + }, [sessionMessages, currentSessionId]); // Keep messageRef in sync with message state React.useEffect(() => { @@ -866,6 +894,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo addToQueue(currentSessionId, { content: messageToQueue, attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, + sendConfig: currentProviderId && currentModelId ? { + providerID: currentProviderId, + modelID: currentModelId, + agent: currentAgentName ?? undefined, + variant: currentVariant ?? undefined, + } : undefined, }); // Clear input and attachments @@ -877,7 +911,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!isMobile) { textareaRef.current?.focus(); } - }, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]); + }, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; @@ -1037,25 +1071,26 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo .split(/\s+/)[0] ?.toLowerCase(); - // NEW: /undo - revert to last message (populates input with reverted message text) if (commandName === 'undo' && currentSessionId) { - await useSessionStore.getState().handleSlashUndo(currentSessionId); - // Don't clear message - pendingInputText will populate it with reverted message + await useSessionUIStore.getState().handleSlashUndo(currentSessionId); scrollToBottom?.({ instant: true, force: true }); - return; // Don't send to assistant + return; } - // NEW: /redo - unrevert or partial redo (populates input with message text) else if (commandName === 'redo' && currentSessionId) { - await useSessionStore.getState().handleSlashRedo(currentSessionId); - // Don't clear message - pendingInputText will populate it + await useSessionUIStore.getState().handleSlashRedo(currentSessionId); scrollToBottom?.({ instant: true, force: true }); - return; // Don't send to assistant + return; } - // NEW: /timeline - open timeline dialog - else if (commandName === 'timeline' && currentSessionId) { - setTimelineDialogOpen(true); - setMessage(''); - return; // Don't send to assistant + else if (commandName === 'compact' && currentSessionId) { + const { opencodeClient } = await import('@/lib/opencode/client'); + const sdk = opencodeClient.getSdkClient(); + const configState = useConfigStore.getState(); + await sdk.session.summarize({ + sessionID: currentSessionId, + modelID: configState.currentModelId || '', + providerID: configState.currentProviderId || '', + }); + return; } } @@ -1108,21 +1143,21 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) { toast.error('Attachments are too large to send. Please try reducing the number or size of images.'); if (allAttachments.length > 0) { - useFileStore.setState({ attachedFiles: allAttachments }); + useInputStore.setState({ attachedFiles: allAttachments }); } return; } if (isSoftNetworkError) { if (allAttachments.length > 0) { - useFileStore.setState({ attachedFiles: allAttachments }); + useInputStore.setState({ attachedFiles: allAttachments }); toast.error('Failed to send attachments. Try fewer files or smaller images.'); } return; } if (allAttachments.length > 0) { - useFileStore.setState({ attachedFiles: allAttachments }); + useInputStore.setState({ attachedFiles: allAttachments }); } toast.error(rawMessage || 'Message failed to send. Attachments restored.'); }); @@ -2560,7 +2595,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) { return; } - useSessionStore.getState().setDraftPreserveDirectoryOverride(false); + useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false); }, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]); const shouldShowDraftBranchSelector = React.useMemo(() => { @@ -2574,7 +2609,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]); const handleDraftProjectChange = React.useCallback((projectId: string) => { - const draft = useSessionStore.getState().newSessionDraft; + const draft = useSessionUIStore.getState().newSessionDraft; if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { return; } @@ -2592,7 +2627,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]); const handleDraftDirectoryChange = React.useCallback((directory: string) => { - const draft = useSessionStore.getState().newSessionDraft; + const draft = useSessionUIStore.getState().newSessionDraft; if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { return; } diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index ca85ba6b..32d48551 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -4,10 +4,13 @@ import { useShallow } from 'zustand/react/shallow'; import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme'; import { MessageFreshnessDetector } from '@/lib/messageFreshness'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useContextStore } from '@/stores/contextStore'; +import { useStreamingStore } from '@/sync/streaming'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useDeviceInfo } from '@/lib/device'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; @@ -26,6 +29,8 @@ import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/li import type { TurnGroupingContext } from './lib/turns/types'; import { copyTextToClipboard } from '@/lib/clipboard'; import { FadeInOnReveal } from './message/FadeInOnReveal'; +import { streamPerfCount } from '@/stores/utils/streamDebug'; +import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessageInfoEqual, areRenderRelevantPartsEqual } from './message/renderCompare'; const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); @@ -123,6 +128,8 @@ interface ChatMessageProps { animationHandlers?: AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; turnGroupingContext?: TurnGroupingContext; + assistantHeaderMessageId?: string; + isInActiveTurn?: boolean; animateUserOnMount?: boolean; onUserAnimationConsumed?: (messageId: string) => void; } @@ -134,6 +141,8 @@ const ChatMessage: React.FC = ({ onContentChange, animationHandlers, turnGroupingContext, + assistantHeaderMessageId, + isInActiveTurn = false, animateUserOnMount = false, onUserAnimationConsumed, }) => { @@ -141,36 +150,31 @@ const ChatMessage: React.FC = ({ const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); - const sessionState = useSessionStore( - useShallow((state) => ({ - lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null, - isStreamingMessage: (() => { - const sessionId = - (message.info as { sessionID?: string }).sessionID ?? - state.currentSessionId ?? - null; - if (!sessionId) return false; - return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id; - })(), - currentSessionId: state.currentSessionId, - getAgentModelForSession: state.getAgentModelForSession, - getSessionModelSelection: state.getSessionModelSelection, - revertToMessage: state.revertToMessage, - forkFromMessage: state.forkFromMessage, - })) - ); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const streamState = useStreamingStore((s) => s.messageStreamStates.get(message.info.id)); + const lifecyclePhase = isInActiveTurn ? (streamState?.phase ?? null) : null; - const { - lifecyclePhase, - isStreamingMessage, - currentSessionId, - getAgentModelForSession, - getSessionModelSelection, - revertToMessage, - forkFromMessage, - } = sessionState; + const msgSessionId = (message.info as { sessionID?: string }).sessionID ?? currentSessionId ?? null; + const streamingMsgForSession = useStreamingStore((s) => msgSessionId ? s.streamingMessageIds.get(msgSessionId) ?? null : null); + const isStreamingMessage = isInActiveTurn ? streamingMsgForSession === message.info.id : false; + const hasActiveStreamInSession = typeof streamingMsgForSession === 'string' && streamingMsgForSession.length > 0; - const providers = useConfigStore((state) => state.providers); + const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession); + const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection); + const revertToMessage = sessionActions.revertToMessage; + const forkFromMessage = sessionActions.forkFromMessage; + + streamPerfCount('ui.chat_message.render'); + if (isStreamingMessage) { + streamPerfCount('ui.chat_message.render.streaming'); + } else if (hasActiveStreamInSession) { + streamPerfCount('ui.chat_message.render.static_during_stream'); + if (!isInActiveTurn) { + streamPerfCount('ui.chat_message.render.static_outside_active_turn_during_stream'); + } + } + + const providers = useConfigStore.getState().providers; const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore( useShallow((state) => ({ showReasoningTraces: state.showReasoningTraces, @@ -211,11 +215,11 @@ const ChatMessage: React.FC = ({ const sessionId = message.info.sessionID; - // Subscribe to context changes so badges update immediately on mode switches. + // Keep non-active-turn rows detached from context-store churn. const { currentContextAgent, savedSessionAgentSelection } = useContextStore( useShallow((state) => ({ - currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined, - savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined, + currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined, + savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined, })) ); @@ -607,7 +611,7 @@ const ChatMessage: React.FC = ({ }, [message.info.id]); React.useEffect(() => { - const headerMessageId = turnGroupingContext?.headerMessageId; + const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId; if (isUser || !headerMessageId || headerMessageId !== message.info.id) { return; } @@ -616,13 +620,13 @@ const ChatMessage: React.FC = ({ if (isCurrentlyStreaming) { setHasStartedStreamingHeader(true); } - }, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]); + }, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]); const shouldShowHeader = React.useMemo(() => { if (isUser) return true; // Use turn grouping context if available for more precise control - const headerMessageId = turnGroupingContext?.headerMessageId; + const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId; if (headerMessageId) { // For turn grouping: only show header for the first assistant message in the turn const isFirstAssistantInTurn = message.info.id === headerMessageId; @@ -644,7 +648,7 @@ const ChatMessage: React.FC = ({ // Ungrouped fallback path: always show assistant header. return true; - }, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]); + }, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]); const handleCopyCode = React.useCallback((code: string) => { void copyTextToClipboard(code).then((result) => { @@ -1086,6 +1090,7 @@ const ChatMessage: React.FC = ({ )} = ({ ); }; -export default React.memo(ChatMessage); +export default React.memo(ChatMessage, (prev, next) => { + return areRenderRelevantMessageInfoEqual(prev.message.info, next.message.info) + && areRenderRelevantPartsEqual(prev.message.parts, next.message.parts) + && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) + && prev.onContentChange === next.onContentChange + && prev.turnGroupingContext === next.turnGroupingContext + && prev.assistantHeaderMessageId === next.assistantHeaderMessageId + && prev.isInActiveTurn === next.isInActiveTurn + && prev.animateUserOnMount === next.animateUserOnMount + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed; +}); diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 97565ec3..35a290b5 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react'; +import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine } from '@remixicon/react'; import { cn, fuzzyMatch } from '@/lib/utils'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionMessages } from '@/sync/sync-context'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; -import { useShallow } from 'zustand/react/shallow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface CommandInfo { @@ -42,16 +42,9 @@ export const CommandAutocomplete = React.forwardRef { - const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore( - useShallow((state) => { - const sessionId = state.currentSessionId; - const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0; - return { - hasMessagesInCurrentSession: messageCount > 0, - currentSessionId: sessionId, - }; - }) - ); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionMessages = useSessionMessages(currentSessionId ?? ''); + const hasMessagesInCurrentSession = sessionMessages.length > 0; const hasSession = Boolean(currentSessionId); const [commands, setCommands] = React.useState([]); @@ -114,7 +107,6 @@ export const CommandAutocomplete = React.forwardRef; case 'redo': return ; - case 'timeline': - return ; case 'compact': return ; case 'test': diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 8930dab8..7a19c132 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -1,6 +1,7 @@ import React, { useRef, memo } from 'react'; import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react'; -import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore'; +import { useInputStore } from '@/sync/input-store'; +import type { AttachedFile } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; @@ -13,7 +14,7 @@ import type { ToolPopupContent } from './message/types'; export const FileAttachmentButton = memo(() => { const fileInputRef = useRef(null); - const { addAttachedFile } = useSessionStore(); + const { addAttachedFile } = useInputStore(); const { isMobile } = useUIStore(); const isVSCodeRuntime = useIsVSCodeRuntime(); const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7'; @@ -255,7 +256,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => { FileChip.displayName = 'FileChip'; export const AttachedFilesList = memo(() => { - const { attachedFiles, removeAttachedFile } = useSessionStore(); + const { attachedFiles, removeAttachedFile } = useInputStore(); const localFiles = attachedFiles.filter((file) => file.source !== 'server'); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 5a9eaa0b..a79ab3fb 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1,11 +1,8 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; -import { flushSync } from 'react-dom'; -import { elementScroll, observeElementOffset, observeElementRect, Virtualizer } from '@tanstack/react-virtual'; -import { useShallow } from 'zustand/react/shallow'; -import type { ReactVirtualizerOptions, VirtualItem } from '@tanstack/react-virtual'; import ChatMessage from './ChatMessage'; +import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; import TurnItem from './components/TurnItem'; @@ -17,23 +14,16 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import { useTurnRecords } from './hooks/useTurnRecords'; -import { useStageTurns } from './lib/turns/stageTurns'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; +import { useStreamingStore } from '@/sync/streaming'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionStatus } from '@/sync/sync-context'; import { useDeviceInfo } from '@/lib/device'; import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; -import { useAssistantStatus } from '@/hooks/useAssistantStatus'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { StatusRow } from './StatusRow'; - -const MESSAGE_VIRTUALIZE_THRESHOLD = 40; -const MESSAGE_VIRTUAL_OVERSCAN_MOBILE = 2; -const MESSAGE_VIRTUAL_OVERSCAN_DESKTOP = 4; -const TURN_ESTIMATE_BASE_PX = 120; -const TURN_ESTIMATE_PER_ASSISTANT_PX = 120; -const TURN_ESTIMATE_MAX_PX = 1400; +import { StatusRowContainer } from './StatusRowContainer'; +import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; const useStableEvent = (handler: (...args: TArgs) => TResult) => { const handlerRef = React.useRef(handler); @@ -44,49 +34,6 @@ const useStableEvent = (handler: (...args: TAr return React.useCallback((...args: TArgs) => handlerRef.current(...args), []); }; -type MessageListVirtualizerOptions = Omit< - ReactVirtualizerOptions, - 'scrollToFn' | 'observeElementRect' | 'observeElementOffset' -> - -const useMessageListVirtualizer = ( - options: MessageListVirtualizerOptions, -): Virtualizer => { - const [, forceRender] = React.useReducer(() => ({}), {}); - const { useFlushSync = true, onChange, ...baseOptions } = options; - - const handleChange = React.useCallback((instance: Virtualizer, sync: boolean) => { - if (useFlushSync && sync) { - flushSync(forceRender); - } else { - forceRender(); - } - - onChange?.(instance, sync); - }, [onChange, useFlushSync]); - - const [virtualizer] = React.useState(() => new Virtualizer({ - ...baseOptions, - onChange: handleChange, - observeElementRect, - observeElementOffset, - scrollToFn: elementScroll, - })); - - virtualizer.setOptions({ - ...baseOptions, - onChange: handleChange, - observeElementRect, - observeElementOffset, - scrollToFn: elementScroll, - }); - - React.useLayoutEffect(() => virtualizer._didMount(), [virtualizer]); - React.useLayoutEffect(() => virtualizer._willUpdate(), [virtualizer]); - - return virtualizer; -}; - const USER_SHELL_MARKER = 'The following tool was executed by the user'; const resolveMessageRole = (message: ChatMessageEntry): string | null => { @@ -387,6 +334,8 @@ interface MessageRowProps { previousMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry; turnGroupingContext?: TurnGroupingContext; + assistantHeaderMessageId?: string; + isInActiveTurn?: boolean; animateUserOnMount?: boolean; onUserAnimationConsumed?: (messageId: string) => void; onContentChange: (reason?: ContentChangeReason) => void; @@ -394,11 +343,13 @@ interface MessageRowProps { scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } -const MessageRow = React.memo(({ +const MessageRow = React.memo(({ message, previousMessage, nextMessage, turnGroupingContext, + assistantHeaderMessageId, + isInActiveTurn, animateUserOnMount, onUserAnimationConsumed, onContentChange, @@ -416,8 +367,39 @@ const MessageRow = React.memo(({ animationHandlers={animationHandlers} scrollToBottom={scrollToBottom} turnGroupingContext={turnGroupingContext} + assistantHeaderMessageId={assistantHeaderMessageId} + isInActiveTurn={isInActiveTurn} /> ); +}, (prev, next) => { + const prevTurn = prev.turnGroupingContext; + const nextTurn = next.turnGroupingContext; + + return areRenderRelevantMessagesEqual(prev.message, next.message) + && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) + && prev.animateUserOnMount === next.animateUserOnMount + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed + && prev.onContentChange === next.onContentChange + && prev.scrollToBottom === next.scrollToBottom + && prevTurn?.turnId === nextTurn?.turnId + && prevTurn?.isFirstAssistantInTurn === nextTurn?.isFirstAssistantInTurn + && prevTurn?.isLastAssistantInTurn === nextTurn?.isLastAssistantInTurn + && prevTurn?.activityOwnerMessageId === nextTurn?.activityOwnerMessageId + && prevTurn?.isWorking === nextTurn?.isWorking + && prevTurn?.isGroupExpanded === nextTurn?.isGroupExpanded + && prevTurn?.toggleGroup === nextTurn?.toggleGroup + && prevTurn?.activityGroupSegments === nextTurn?.activityGroupSegments + && prevTurn?.activityParts === nextTurn?.activityParts + && prev.assistantHeaderMessageId === next.assistantHeaderMessageId + && prev.isInActiveTurn === next.isInActiveTurn + && prev.animationHandlers?.onChunk === next.animationHandlers?.onChunk + && prev.animationHandlers?.onComplete === next.animationHandlers?.onComplete + && prev.animationHandlers?.onStreamingCandidate === next.animationHandlers?.onStreamingCandidate + && prev.animationHandlers?.onAnimationStart === next.animationHandlers?.onAnimationStart + && prev.animationHandlers?.onReservationCancelled === next.animationHandlers?.onReservationCancelled + && prev.animationHandlers?.onReasoningBlock === next.animationHandlers?.onReasoningBlock + && prev.animationHandlers?.onAnimatedHeightChange === next.animationHandlers?.onAnimatedHeightChange; }); MessageRow.displayName = 'MessageRow'; @@ -436,6 +418,7 @@ interface TurnBlockProps { stickyUserHeader?: boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; } const TurnBlock: React.FC = ({ @@ -452,8 +435,12 @@ const TurnBlock: React.FC = ({ stickyUserHeader = true, shouldAnimateUserMessage, onUserAnimationConsumed, + activeStreamingMessageId, }) => { const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded }; + const handleToggleTurnGroup = React.useCallback(() => { + onToggleTurnGroup(turn.turnId); + }, [onToggleTurnGroup, turn.turnId]); const messageOrder = React.useMemo(() => { const ordered = [turn.userMessage, ...turn.assistantMessages]; @@ -464,20 +451,45 @@ const TurnBlock: React.FC = ({ return { ordered, lookup }; }, [turn.assistantMessages, turn.userMessage]); + const streamingAssistantMessageId = React.useMemo(() => { + if (activeStreamingMessageId && turn.assistantMessages.some((assistant) => assistant.info.id === activeStreamingMessageId)) { + return activeStreamingMessageId; + } + + for (let index = turn.assistantMessages.length - 1; index >= 0; index -= 1) { + const assistant = turn.assistantMessages[index]; + if (!isAssistantMessageCompleted(assistant)) { + return assistant.info.id; + } + } + + return null; + }, [activeStreamingMessageId, turn.assistantMessages]); + const visibleAssistantMessages = React.useMemo(() => { if (chatRenderMode === 'live') { return turn.assistantMessages; } + const completed = turn.assistantMessages.filter(isAssistantMessageCompleted); if (completed.length === turn.assistantMessages.length) { return turn.assistantMessages; } + + if (streamingAssistantMessageId) { + const completedIds = new Set(completed.map((assistant) => assistant.info.id)); + return turn.assistantMessages.filter((assistant) => ( + completedIds.has(assistant.info.id) + || assistant.info.id === streamingAssistantMessageId + )); + } + if (completed.length > 0) { return completed; } const firstAssistant = turn.assistantMessages[0]; return firstAssistant ? [firstAssistant] : []; - }, [chatRenderMode, turn.assistantMessages]); + }, [chatRenderMode, streamingAssistantMessageId, turn.assistantMessages]); const completedAssistantMessages = React.useMemo(() => { if (chatRenderMode !== 'sorted') { @@ -498,30 +510,49 @@ const TurnBlock: React.FC = ({ return new Set(completedAssistantMessages.map((assistant) => assistant.info.id)); }, [completedAssistantMessages]); + const visibleActivityMessageIdSet = React.useMemo(() => { + const ids = new Set(completedAssistantIdSet); + if (streamingAssistantMessageId) { + ids.add(streamingAssistantMessageId); + } + return ids; + }, [completedAssistantIdSet, streamingAssistantMessageId]); + + const turnIsInActiveStream = React.useMemo(() => { + return turnContainsMessageId(turn, streamingAssistantMessageId); + }, [turn, streamingAssistantMessageId]); + + const activityOwnerMessageId = React.useMemo(() => { + if (turnIsInActiveStream && streamingAssistantMessageId) { + return streamingAssistantMessageId; + } + return visibleAssistantMessages[0]?.info.id; + }, [streamingAssistantMessageId, turnIsInActiveStream, visibleAssistantMessages]); + const visibleActivityParts = React.useMemo(() => { if (chatRenderMode !== 'sorted') { return turn.activityParts; } - if (completedAssistantMessages.length === turn.assistantMessages.length) { + if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) { return turn.activityParts; } - return turn.activityParts.filter((activity) => completedAssistantIdSet.has(activity.messageId)); - }, [chatRenderMode, completedAssistantIdSet, completedAssistantMessages.length, turn.activityParts, turn.assistantMessages.length]); + return turn.activityParts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId)); + }, [chatRenderMode, visibleActivityMessageIdSet, turn.activityParts, turn.assistantMessages.length]); const visibleActivitySegments = React.useMemo(() => { if (chatRenderMode !== 'sorted') { return turn.activitySegments; } - if (completedAssistantMessages.length === turn.assistantMessages.length) { + if (visibleActivityMessageIdSet.size === turn.assistantMessages.length) { return turn.activitySegments; } return turn.activitySegments .map((segment) => { - const parts = segment.parts.filter((activity) => completedAssistantIdSet.has(activity.messageId)); + const parts = segment.parts.filter((activity) => visibleActivityMessageIdSet.has(activity.messageId)); if (parts.length === 0) { return null; } - const anchorMessageId = completedAssistantIdSet.has(segment.anchorMessageId) + const anchorMessageId = visibleActivityMessageIdSet.has(segment.anchorMessageId) ? segment.anchorMessageId : parts[0]?.messageId; if (!anchorMessageId) { @@ -534,7 +565,7 @@ const TurnBlock: React.FC = ({ }; }) .filter((segment): segment is NonNullable => segment !== null); - }, [chatRenderMode, completedAssistantIdSet, completedAssistantMessages.length, turn.activitySegments, turn.assistantMessages.length]); + }, [chatRenderMode, visibleActivityMessageIdSet, turn.activitySegments, turn.assistantMessages.length]); const turnGroupingContextBase = React.useMemo(() => { const userCreatedAt = (turn.userMessage.info.time as { created?: number } | undefined)?.created; @@ -558,24 +589,50 @@ const TurnBlock: React.FC = ({ const renderMessage = React.useCallback( (message: ChatMessageEntry) => { + const messageRole = resolveMessageRole(message); + const isUserMessage = messageRole === 'user'; const messageIndex = messageOrder.lookup.get(message.info.id); - const previousMessage = typeof messageIndex === 'number' && messageIndex > 0 - ? messageOrder.ordered[messageIndex - 1] - : undefined; - const nextMessage = typeof messageIndex === 'number' && messageIndex < messageOrder.ordered.length - 1 - ? messageOrder.ordered[messageIndex + 1] - : undefined; - const assistantIndex = visibleAssistantIds.get(message.info.id) ?? -1; + const isAssistantMessage = assistantIndex >= 0; + const isFirstAssistant = assistantIndex === 0; + const isLastAssistant = assistantIndex === visibleAssistantMessages.length - 1; + const isActivityOwner = Boolean(activityOwnerMessageId) && message.info.id === activityOwnerMessageId; + const shouldAttachFullTurnContext = chatRenderMode === 'sorted' + ? isAssistantMessage + : (isActivityOwner || isFirstAssistant || isLastAssistant); + const assistantHeaderMessageId = visibleAssistantMessages[0]?.info.id ?? turn.headerMessageId; - const turnGroupingContext = assistantIndex >= 0 + const previousMessage = isUserMessage + ? undefined + : (isAssistantMessage + ? (isFirstAssistant + ? turn.userMessage + : undefined) + : (typeof messageIndex === 'number' && messageIndex > 0 + ? messageOrder.ordered[messageIndex - 1] + : undefined)); + const nextMessage = undefined; + + const turnGroupingContext = isAssistantMessage ? { - ...turnGroupingContextBase, - isFirstAssistantInTurn: assistantIndex === 0, - isLastAssistantInTurn: assistantIndex === visibleAssistantMessages.length - 1, - isWorking: isLastTurn && sessionIsWorking, - isGroupExpanded: turnUiState.isExpanded, - toggleGroup: () => onToggleTurnGroup(turn.turnId), + turnId: turn.turnId, + activityOwnerMessageId, + isFirstAssistantInTurn: isFirstAssistant, + isLastAssistantInTurn: isLastAssistant, + isWorking: isLastTurn && sessionIsWorking && message.info.id === streamingAssistantMessageId, + hasTools: turn.hasTools, + hasReasoning: turn.hasReasoning, + ...(shouldAttachFullTurnContext ? { + summaryBody: turnGroupingContextBase.summaryBody, + activityParts: turnGroupingContextBase.activityParts, + activityGroupSegments: turnGroupingContextBase.activityGroupSegments, + headerMessageId: turnGroupingContextBase.headerMessageId, + diffStats: turnGroupingContextBase.diffStats, + userMessageCreatedAt: turnGroupingContextBase.userMessageCreatedAt, + userMessageVariant: turnGroupingContextBase.userMessageVariant, + isGroupExpanded: turnUiState.isExpanded, + toggleGroup: handleToggleTurnGroup, + } : {}), } satisfies TurnGroupingContext : undefined; @@ -586,6 +643,8 @@ const TurnBlock: React.FC = ({ previousMessage={previousMessage} nextMessage={nextMessage} turnGroupingContext={turnGroupingContext} + assistantHeaderMessageId={assistantHeaderMessageId} + isInActiveTurn={Boolean(streamingAssistantMessageId) && message.info.id === streamingAssistantMessageId} animateUserOnMount={shouldAnimateUserMessage(message)} onUserAnimationConsumed={onUserAnimationConsumed} onContentChange={onMessageContentChange} @@ -602,14 +661,21 @@ const TurnBlock: React.FC = ({ onMessageContentChange, scrollToBottom, sessionIsWorking, + chatRenderMode, + turn.headerMessageId, + turn.hasReasoning, + turn.hasTools, turn.turnId, + turn.userMessage, turnUiState.isExpanded, turnGroupingContextBase, + streamingAssistantMessageId, visibleAssistantMessages, visibleAssistantIds, + activityOwnerMessageId, shouldAnimateUserMessage, onUserAnimationConsumed, - onToggleTurnGroup, + handleToggleTurnGroup, ] ); @@ -639,6 +705,7 @@ interface UngroupedMessageRowProps { scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; } const UngroupedMessageRow: React.FC = React.memo(({ @@ -650,6 +717,7 @@ const UngroupedMessageRow: React.FC = React.memo(({ scrollToBottom, shouldAnimateUserMessage, onUserAnimationConsumed, + activeStreamingMessageId, }) => { return ( = React.memo(({ onContentChange={onMessageContentChange} animationHandlers={getAnimationHandlers(message.info.id)} scrollToBottom={scrollToBottom} + isInActiveTurn={Boolean(activeStreamingMessageId) && message.info.id === activeStreamingMessageId} /> ); +}, (prev, next) => { + return areRenderRelevantMessagesEqual(prev.message, next.message) + && areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage) + && prev.onMessageContentChange === next.onMessageContentChange + && prev.getAnimationHandlers === next.getAnimationHandlers + && prev.scrollToBottom === next.scrollToBottom + && prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed + && prev.activeStreamingMessageId === next.activeStreamingMessageId; }); UngroupedMessageRow.displayName = 'UngroupedMessageRow'; @@ -680,8 +759,21 @@ interface MessageListEntryProps { chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; } +const turnContainsMessageId = (turn: TurnRecord, messageId: string | null | undefined): boolean => { + if (!messageId) { + return false; + } + + if (turn.userMessage.info.id === messageId) { + return true; + } + + return turn.assistantMessages.some((assistant) => assistant.info.id === messageId); +}; + const MessageListEntry: React.FC = React.memo(({ entry, onMessageContentChange, @@ -695,6 +787,7 @@ const MessageListEntry: React.FC = React.memo(({ chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, + activeStreamingMessageId, }) => { if (entry.kind === 'ungrouped') { return ( @@ -707,6 +800,7 @@ const MessageListEntry: React.FC = React.memo(({ scrollToBottom={scrollToBottom} shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} + activeStreamingMessageId={activeStreamingMessageId} /> ); } @@ -722,6 +816,7 @@ const MessageListEntry: React.FC = React.memo(({ chatRenderMode={chatRenderMode} shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} + activeStreamingMessageId={activeStreamingMessageId} onMessageContentChange={onMessageContentChange} getAnimationHandlers={getAnimationHandlers} scrollToBottom={scrollToBottom} @@ -757,14 +852,31 @@ function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextPro return false; } + if (prevProps.activeStreamingMessageId !== nextProps.activeStreamingMessageId) { + const prevAffected = turnContainsMessageId(prevEntry.turn, prevProps.activeStreamingMessageId); + const nextAffected = turnContainsMessageId(nextEntry.turn, nextProps.activeStreamingMessageId); + if (prevAffected || nextAffected) { + return false; + } + } + return true; } if (prevEntry.kind === 'ungrouped' && nextEntry.kind === 'ungrouped') { + if (prevProps.activeStreamingMessageId !== nextProps.activeStreamingMessageId) { + const messageId = prevEntry.message.info.id; + const prevActive = prevProps.activeStreamingMessageId === messageId; + const nextActive = nextProps.activeStreamingMessageId === messageId; + if (prevActive !== nextActive) { + return false; + } + } + return ( - prevEntry.message === nextEntry.message - && prevEntry.previousMessage === nextEntry.previousMessage - && prevEntry.nextMessage === nextEntry.nextMessage + areRenderRelevantMessagesEqual(prevEntry.message, nextEntry.message) + && areOptionalRenderRelevantMessagesEqual(prevEntry.previousMessage, nextEntry.previousMessage) + && areOptionalRenderRelevantMessagesEqual(prevEntry.nextMessage, nextEntry.nextMessage) ); } @@ -785,7 +897,8 @@ const MessageListContent: React.FC<{ chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; -}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => { + activeStreamingMessageId?: string | null; +}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId }) => { const renderEntry = React.useCallback((entry: RenderEntry) => { return ( ); - }, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]); + }, [activeStreamingMessageId, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]); return ( ); }; +const StreamingTailContent: React.FC<{ + entry: RenderEntry; + onMessageContentChange: (reason?: ContentChangeReason) => void; + getAnimationHandlers: (messageId: string) => AnimationHandlers; + scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; + stickyUserHeader: boolean; + sessionIsWorking: boolean; + defaultActivityExpanded: boolean; + turnUiStates: Map; + onToggleTurnGroup: (turnId: string) => void; + chatRenderMode: 'sorted' | 'live'; + shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; + onUserAnimationConsumed: (messageId: string) => void; + activeStreamingMessageId?: string | null; +}> = React.memo(({ + entry, + onMessageContentChange, + getAnimationHandlers, + scrollToBottom, + stickyUserHeader, + sessionIsWorking, + defaultActivityExpanded, + turnUiStates, + onToggleTurnGroup, + chatRenderMode, + shouldAnimateUserMessage, + onUserAnimationConsumed, + activeStreamingMessageId, +}) => { + return ( + + ); +}, (prev, next) => { + return prev.entry === next.entry + && prev.onMessageContentChange === next.onMessageContentChange + && prev.getAnimationHandlers === next.getAnimationHandlers + && prev.scrollToBottom === next.scrollToBottom + && prev.stickyUserHeader === next.stickyUserHeader + && prev.sessionIsWorking === next.sessionIsWorking + && prev.defaultActivityExpanded === next.defaultActivityExpanded + && prev.turnUiStates === next.turnUiStates + && prev.onToggleTurnGroup === next.onToggleTurnGroup + && prev.chatRenderMode === next.chatRenderMode + && prev.shouldAnimateUserMessage === next.shouldAnimateUserMessage + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed + && prev.activeStreamingMessageId === next.activeStreamingMessageId; +}); + +StreamingTailContent.displayName = 'StreamingTailContent'; + const MessageList = React.forwardRef(({ sessionKey, turnStart, - disableStaging, + disableStaging: _disableStaging, messages, permissions, questions, @@ -826,10 +1004,11 @@ const MessageList = React.forwardRef(({ scrollToBottom, scrollRef, }, ref) => { + streamPerfCount('ui.message_list.render'); + void _disableStaging; const { isMobile } = useDeviceInfo(); const { isWorking: sessionIsWorking } = useCurrentSessionActivity(); - const { working } = useAssistantStatus(); - const currentAgentName = useConfigStore((state) => state.currentAgentName); + const activeStreamingMessageId = useStreamingStore((state) => state.streamingMessageIds.get(sessionKey) ?? null); const stickyUserHeader = useUIStore(state => state.stickyUserHeader); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const activityRenderMode = useUIStore((state) => state.activityRenderMode); @@ -845,6 +1024,13 @@ const MessageList = React.forwardRef(({ output: ChatMessageEntry[]; outputIndexById: Map; } | null>(null); + const staticRenderEntriesCacheRef = React.useRef<{ + input: ChatMessageEntry[]; + output: RenderEntry[]; + staticTurns: TurnRecord[]; + lastTurnId: string | null; + ungroupedMessageIds: Set; + } | null>(null); const stableOnMessageContentChange = useStableEvent(onMessageContentChange); const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers); @@ -874,7 +1060,7 @@ const MessageList = React.forwardRef(({ }, [defaultActivityExpanded]); - const baseDisplayMessages = React.useMemo(() => { + const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => { const cached = baseDisplayCacheRef.current; const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined; const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage)); @@ -972,22 +1158,21 @@ const MessageList = React.forwardRef(({ }; return output; - }, [messages]); + }), [messages]); - const activeRetryStatus = useSessionStore( - useShallow((state) => { - const sessionId = state.currentSessionId; - if (!sessionId) return null; - const status = state.sessionStatus?.get(sessionId); - if (!status || status.type !== 'retry') return null; - const rawMessage = typeof status.message === 'string' ? status.message.trim() : ''; - return { - sessionId, - message: rawMessage || 'Quota limit reached. Retrying automatically.', - confirmedAt: status.confirmedAt, - }; - }) - ); + const currentSessionIdForRetry = useSessionUIStore((s) => s.currentSessionId); + const retryStatusRaw = useSessionStatus(currentSessionIdForRetry ?? ''); + const activeRetryStatus = React.useMemo(() => { + if (!currentSessionIdForRetry) return null; + const status = retryStatusRaw; + if (!status || status.type !== 'retry') return null; + const rawMessage = typeof (status as { message?: string }).message === 'string' ? ((status as { message?: string }).message ?? '').trim() : ''; + return { + sessionId: currentSessionIdForRetry, + message: rawMessage || 'Quota limit reached. Retrying automatically.', + confirmedAt: (status as { confirmedAt?: number }).confirmedAt, + }; + }, [currentSessionIdForRetry, retryStatusRaw]); const activeRetrySessionId = activeRetryStatus?.sessionId ?? null; const activeRetryMessage = activeRetryStatus?.message @@ -1019,27 +1204,63 @@ const MessageList = React.forwardRef(({ } }, [activeRetryStatus, activeRetryStatus?.sessionId, activeRetryStatus?.confirmedAt]); - const displayMessages = React.useMemo(() => { + const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => { return applyRetryOverlay(baseDisplayMessages, { sessionId: activeRetrySessionId, message: activeRetryMessage, confirmedAt: activeRetryConfirmedAt, fallbackTimestamp: fallbackRetryTimestamp, }); - }, [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]); + }), [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]); const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, { + sessionKey, showTextJustificationActivity: chatRenderMode === 'sorted', }); - const turns = React.useMemo(() => { - if (!streamingTurn) { - return staticTurns; - } - return [...staticTurns, streamingTurn]; - }, [staticTurns, streamingTurn]); + const staticRenderEntries = React.useMemo(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => { + const cached = staticRenderEntriesCacheRef.current; + const lastMessage = displayMessages.length > 0 ? displayMessages[displayMessages.length - 1] : undefined; + const hasTrailingCandidate = Boolean(lastMessage) && ( + (streamingTurn + ? (streamingTurn.userMessage.info.id === lastMessage?.info.id + || streamingTurn.assistantMessages.some((assistant) => assistant.info.id === lastMessage?.info.id)) + : false) + || (lastMessage ? projection.ungroupedMessageIds.has(lastMessage.info.id) : false) + ); - const renderEntries = React.useMemo(() => { - const turnEntries = turns.map((turn) => ({ + if ( + cached + && hasTrailingCandidate + && cached.input.length === displayMessages.length + && cached.staticTurns === staticTurns + && cached.lastTurnId === projection.lastTurnId + && cached.ungroupedMessageIds === projection.ungroupedMessageIds + && displayMessages.length > 0 + ) { + let changedCount = 0; + let changedIndex = -1; + let idsStable = true; + + for (let index = 0; index < displayMessages.length; index += 1) { + if (displayMessages[index]?.info?.id !== cached.input[index]?.info?.id) { + idsStable = false; + break; + } + if (displayMessages[index] !== cached.input[index]) { + changedCount += 1; + changedIndex = index; + if (changedCount > 1) { + break; + } + } + } + + if (idsStable && changedCount === 1 && changedIndex === displayMessages.length - 1) { + return cached.output; + } + } + + const turnEntries = staticTurns.map((turn) => ({ kind: 'turn' as const, key: `turn:${turn.turnId}`, turn, @@ -1076,22 +1297,53 @@ const MessageList = React.forwardRef(({ }); }); + staticRenderEntriesCacheRef.current = { + input: displayMessages, + output: orderedEntries, + staticTurns, + lastTurnId: projection.lastTurnId, + ungroupedMessageIds: projection.ungroupedMessageIds, + }; + return orderedEntries; - }, [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, turns]); + }), [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, staticTurns, streamingTurn]); - const staging = useStageTurns({ - sessionKey, - turnStart, - totalTurns: renderEntries.length, - disabled: disableStaging, - }); - - const stagedEntries = React.useMemo(() => { - if (staging.stageStartIndex <= 0) { - return renderEntries; + const trailingStreamingEntry = React.useMemo(() => { + if (streamingTurn) { + return { + kind: 'turn', + key: `turn:${streamingTurn.turnId}`, + turn: streamingTurn, + isLastTurn: streamingTurn.turnId === projection.lastTurnId, + } satisfies RenderEntry; } - return renderEntries.slice(staging.stageStartIndex); - }, [renderEntries, staging.stageStartIndex]); + + if (projection.ungroupedMessageIds.size === 0) { + return undefined; + } + + const lastMessage = displayMessages[displayMessages.length - 1]; + if (!lastMessage || !projection.ungroupedMessageIds.has(lastMessage.info.id)) { + return undefined; + } + + return { + kind: 'ungrouped', + key: `msg:${lastMessage.info.id}`, + message: lastMessage, + previousMessage: displayMessages.length > 1 ? displayMessages[displayMessages.length - 2] : undefined, + nextMessage: undefined, + } satisfies RenderEntry; + }, [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, streamingTurn]); + + if (trailingStreamingEntry) { + streamPerfCount('ui.message_list.render.streaming'); + } + + const historyEntries = staticRenderEntries; + const allEntries = React.useMemo(() => { + return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries; + }, [historyEntries, trailingStreamingEntry]); const currentUserOrder = React.useMemo(() => { return messages @@ -1136,77 +1388,14 @@ const MessageList = React.forwardRef(({ return userAnimationRef.current.animatedIds.has(message.info.id); }, []); - const onUserAnimationConsumed = React.useCallback(() => { - // Animation plays once via ToolRevealOnMount; no cleanup needed. - // The ref-based animatedIds set is reset on session switch. + const onUserAnimationConsumed = React.useCallback((messageId: string) => { + userAnimationRef.current.animatedIds.delete(messageId); }, []); - const shouldVirtualize = Boolean(resolveScrollContainer()) && stagedEntries.length >= MESSAGE_VIRTUALIZE_THRESHOLD; - - const estimateEntrySize = React.useCallback( - (index: number): number => { - const entry = stagedEntries[index]; - if (!entry) { - return 220; - } - if (entry.kind === 'turn') { - const assistantCount = entry.turn.assistantMessages.length; - return Math.min( - TURN_ESTIMATE_MAX_PX, - TURN_ESTIMATE_BASE_PX + assistantCount * TURN_ESTIMATE_PER_ASSISTANT_PX, - ); - } - const role = resolveMessageRole(entry.message); - return role === 'user' ? 100 : 220; - }, - [stagedEntries] - ); - - const virtualizer = useMessageListVirtualizer({ - count: stagedEntries.length, - getScrollElement: resolveScrollContainer, - estimateSize: estimateEntrySize, - overscan: isMobile ? MESSAGE_VIRTUAL_OVERSCAN_MOBILE : MESSAGE_VIRTUAL_OVERSCAN_DESKTOP, - getItemKey: (index: number) => stagedEntries[index]?.key ?? index, - enabled: shouldVirtualize, - useFlushSync: false, - }); - - const isVirtualRowInRange = React.useCallback( - (row: VirtualItem) => row.index >= 0 && row.index < stagedEntries.length, - [stagedEntries.length], - ); - - const virtualRows = shouldVirtualize ? virtualizer.getVirtualItems().filter(isVirtualRowInRange) : []; - const lastNonEmptyVirtualRowsRef = React.useRef([]); - if (shouldVirtualize && virtualRows.length > 0) { - lastNonEmptyVirtualRowsRef.current = virtualRows; - } else if (!shouldVirtualize && lastNonEmptyVirtualRowsRef.current.length > 0) { - lastNonEmptyVirtualRowsRef.current = []; - } - - const fallbackVirtualRows = shouldVirtualize - ? lastNonEmptyVirtualRowsRef.current.filter(isVirtualRowInRange) - : []; - - const effectiveVirtualRows = shouldVirtualize - ? (virtualRows.length > 0 ? virtualRows : fallbackVirtualRows) - : []; - - const renderVirtualized = shouldVirtualize && effectiveVirtualRows.length > 0; - - const scrollVirtualizerToIndex = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => { - if (!virtualizer) { - return; - } - const normalizedBehavior: 'auto' | 'smooth' = behavior === 'instant' ? 'auto' : behavior; - virtualizer.scrollToIndex(index, { align: 'start', behavior: normalizedBehavior }); - }, [virtualizer]); - const messageIndexMap = React.useMemo(() => { const indexMap = new Map(); - stagedEntries.forEach((entry, index) => { + allEntries.forEach((entry, index) => { if (entry.kind === 'ungrouped') { indexMap.set(entry.message.info.id, index); return; @@ -1218,17 +1407,17 @@ const MessageList = React.forwardRef(({ }); return indexMap; - }, [stagedEntries]); + }, [allEntries]); const turnIndexMap = React.useMemo(() => { const indexMap = new Map(); - stagedEntries.forEach((entry, index) => { + allEntries.forEach((entry, index) => { if (entry.kind === 'turn') { indexMap.set(entry.turn.turnId, index); } }); return indexMap; - }, [stagedEntries]); + }, [allEntries]); const findMessageElement = React.useCallback((messageId: string): HTMLElement | null => { const container = resolveScrollContainer(); @@ -1256,7 +1445,7 @@ const MessageList = React.forwardRef(({ return true; }, [findMessageElement, resolveScrollContainer]); - React.useLayoutEffect(() => { + React.useEffect(() => { if (!ref) { return; } @@ -1269,21 +1458,9 @@ const MessageList = React.forwardRef(({ return false; } - if (shouldVirtualize) { - scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior); - if (typeof window !== 'undefined') { - window.requestAnimationFrame(() => { - const container = resolveScrollContainer(); - if (!container) { - return; - } - const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); - if (turnElement) { - turnElement.scrollIntoView({ behavior, block: 'start' }); - } - }); - } - return true; + const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length; + if (targetIsTail) { + return false; } const container = resolveScrollContainer(); @@ -1305,25 +1482,9 @@ const MessageList = React.forwardRef(({ return false; } - if (shouldVirtualize) { - scrollVirtualizerToIndex(index, behavior === 'instant' ? 'auto' : behavior); - if (scrollMessageElementIntoView(messageId, behavior)) { - return true; - } - if (typeof window !== 'undefined') { - let attempts = 0; - const retry = () => { - attempts += 1; - if (scrollMessageElementIntoView(messageId, behavior)) { - return; - } - if (attempts < 3) { - window.requestAnimationFrame(retry); - } - }; - window.requestAnimationFrame(retry); - } - return true; + const targetIsTail = trailingStreamingEntry !== undefined && index >= historyEntries.length; + if (targetIsTail) { + return false; } return scrollMessageElementIntoView(messageId, behavior); @@ -1337,7 +1498,20 @@ const MessageList = React.forwardRef(({ const containerRect = container.getBoundingClientRect(); const nodes: HTMLElement[] = Array.from(container.querySelectorAll('[data-message-id]')); - const firstVisible = nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); + const firstVisible = nodes.find((node) => { + const rect = node.getBoundingClientRect(); + if (rect.bottom <= containerRect.top + 1) { + return false; + } + + if (typeof window === 'undefined') { + return true; + } + + const computed = window.getComputedStyle(node); + const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1; + return !isStuckSticky; + }) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); if (!firstVisible) { return null; } @@ -1359,15 +1533,10 @@ const MessageList = React.forwardRef(({ return false; } - const index = messageIndexMap.get(anchor.messageId); - if (index === undefined) { + if (!messageIndexMap.has(anchor.messageId)) { return false; } - if (shouldVirtualize) { - scrollVirtualizerToIndex(index, 'auto'); - } - const applyAnchor = (): boolean => { const element = findMessageElement(anchor.messageId); if (!element) { @@ -1382,25 +1551,7 @@ const MessageList = React.forwardRef(({ return true; }; - if (applyAnchor()) { - return true; - } - - if (typeof window !== 'undefined') { - let attempts = 0; - const retry = () => { - attempts += 1; - if (applyAnchor()) { - return; - } - if (attempts < 3) { - window.requestAnimationFrame(retry); - } - }; - window.requestAnimationFrame(retry); - } - - return true; + return applyAnchor(); }, }; @@ -1416,9 +1567,9 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, scrollVirtualizerToIndex, shouldVirtualize, turnIndexMap, ref]); + }, [findMessageElement, historyEntries.length, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, trailingStreamingEntry, turnIndexMap, ref]); - const disableFadeIn = isLoadingOlder || (renderVirtualized && virtualizer.isScrolling); + const disableFadeIn = false; return (
@@ -1440,56 +1591,10 @@ const MessageList = React.forwardRef(({
)} - {staging.isStaging ? ( -
- - Revealing history… - -
- ) : null} - - {renderVirtualized ? ( -
- {effectiveVirtualRows.map((virtualRow: VirtualItem) => { - const entry = stagedEntries[virtualRow.index]; - if (!entry) { - return null; - } - - return ( -
- -
- ); - })} -
- ) : ( -
+
(({ chatRenderMode={chatRenderMode} shouldAnimateUserMessage={shouldAnimateUserMessage} onUserAnimationConsumed={onUserAnimationConsumed} + activeStreamingMessageId={activeStreamingMessageId} /> -
- )} + {trailingStreamingEntry ? ( + + ) : null} +
{(questions.length > 0 || permissions.length > 0) && ( @@ -1518,18 +1640,7 @@ const MessageList = React.forwardRef(({ )}
- +
{/* Bottom spacer */} diff --git a/packages/ui/src/components/chat/MobileAgentButton.tsx b/packages/ui/src/components/chat/MobileAgentButton.tsx index 944a6f30..f1687889 100644 --- a/packages/ui/src/components/chat/MobileAgentButton.tsx +++ b/packages/ui/src/components/chat/MobileAgentButton.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { getAgentDisplayName } from './mobileControlsUtils'; import { getAgentColor } from '@/lib/agentColors'; @@ -16,8 +17,8 @@ const LONG_PRESS_MS = 500; // NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile export const MobileAgentButton: React.FC = ({ onCycleAgent, onOpenAgentPanel, className }) => { const { currentAgentName, getVisibleAgents } = useConfigStore(); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessionAgentName = useSessionStore((state) => + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionAgentName = useSelectionStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index b3b3360f..e29b27b4 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import { useSessions, useAllSessionStatuses } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -52,6 +54,7 @@ import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { useDrawerSwipe } from '@/hooks/useDrawerSwipe'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useNotificationStore } from '@/sync/notification-store'; interface MobileSessionStatusBarProps { onSessionSwitch?: (sessionId: string) => void; @@ -74,9 +77,10 @@ const normalize = (value: string): string => { function useSessionGrouping( sessions: Session[], - sessionStatus: Map | undefined, - sessionAttentionStates: Map | undefined + sessionStatus: Record | undefined ) { + const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); + const parentChildMap = React.useMemo(() => { const map = new Map(); const allIds = new Set(sessions.map((s) => s.id)); @@ -91,7 +95,7 @@ function useSessionGrouping( }, [sessions]); const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => { - const status = sessionStatus?.get(sessionId); + const status = sessionStatus?.[sessionId]; if (status?.type === 'busy' || status?.type === 'retry') return status.type; return 'idle'; }, [sessionStatus]); @@ -126,7 +130,7 @@ function useSessionGrouping( topLevel.forEach((session) => { const statusType = getStatusType(session.id); const hasRunning = hasRunningChildren(session.id); - const attention = sessionAttentionStates?.get(session.id)?.needsAttention ?? false; + const attention = (unseenCounts[session.id] ?? 0) > 0; const enriched: SessionWithStatus = { ...session, @@ -155,28 +159,27 @@ function useSessionGrouping( viewed.sort(sortByUpdated); return [...running, ...viewed]; - }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]); + }, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]); const totalRunning = processedSessions.reduce((sum, s) => { const selfRunning = s._statusType !== 'idle' ? 1 : 0; return sum + selfRunning + (s._runningChildrenCount ?? 0); }, 0); - const totalUnread = processedSessions.filter((s) => sessionAttentionStates?.get(s.id)?.needsAttention ?? false).length; + const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length; return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length }; } function useSessionHelpers( agents: Array<{ name: string }>, - sessionStatus: Map | undefined, - sessionAttentionStates: Map | undefined + sessionStatus: Record | undefined ) { const getSessionAgentName = React.useCallback((session: Session): string => { const agent = (session as { agent?: string }).agent; if (agent) return agent; - const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id); + const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id); if (sessionAgentSelection) return sessionAgentSelection; return agents[0]?.name ?? 'agent'; @@ -189,14 +192,14 @@ function useSessionHelpers( }, []); const isRunning = React.useCallback((sessionId: string): boolean => { - const status = sessionStatus?.get(sessionId); + const status = sessionStatus?.[sessionId]; return status?.type === 'busy' || status?.type === 'retry'; }, [sessionStatus]); - // Use server-authoritative attention state instead of local activity state + const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const needsAttention = React.useCallback((sessionId: string): boolean => { - return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false; - }, [sessionAttentionStates]); + return (unseenCounts[sessionId] ?? 0) > 0; + }, [unseenCounts]); return { getSessionAgentName, getSessionTitle, isRunning, needsAttention }; } @@ -204,17 +207,16 @@ function useSessionHelpers( // Hook to calculate project status indicators function useProjectStatus( sessions: Session[], - sessionStatus: Map | undefined, - sessionAttentionStates: Map | undefined, + sessionStatus: Record | undefined, currentSessionId: string | null ) { - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); - const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); + const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount); const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => { const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => { - const status = sessionStatus?.get(sessionId); + const status = sessionStatus?.[sessionId]; if (status?.type === 'busy' || status?.type === 'retry') return status.type; return 'idle'; }; @@ -241,7 +243,7 @@ function useProjectStatus( let hasUnread = false; for (const dir of dirs) { - const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir); + const list = getSessionsByDirectory(dir); for (const session of list) { if (!session?.id || seen.has(session.id)) { continue; @@ -253,7 +255,7 @@ function useProjectStatus( hasRunning = true; } - if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) { + if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) { hasUnread = true; } @@ -267,7 +269,7 @@ function useProjectStatus( } return { hasRunning, hasUnread }; - }, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]); + }, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]); return projectStatusMap; } @@ -1290,7 +1292,7 @@ function ExpandedView({ const [collapsedHeight, setCollapsedHeight] = React.useState(null); const [hasMeasured, setHasMeasured] = React.useState(false); const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); React.useEffect(() => { if (containerRef.current && !hasMeasured && !isExpanded) { @@ -1429,13 +1431,12 @@ export const MobileSessionStatusBar: React.FC = ({ cornerRadius, }) => { const { currentTheme } = useThemeSystem(); - const sessions = useSessionStore((state) => state.sessions); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessionStatus = useSessionStore((state) => state.sessionStatus); - const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const getContextUsage = useSessionStore((state) => state.getContextUsage); + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionStatus = useAllSessionStatuses(); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const agents = useConfigStore((state) => state.agents); const { getCurrentModel } = useConfigStore(); const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore(); @@ -1452,9 +1453,9 @@ export const MobileSessionStatusBar: React.FC = ({ // Directory store const homeDirectory = useDirectoryStore((state) => state.homeDirectory); - const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates); - const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates); - const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId); + const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus); + const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus); + const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId); const currentSession = sessions.find((s) => s.id === currentSessionId); const currentSessionTitle = currentSession diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index dfbdade3..97422066 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -47,7 +47,10 @@ import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import { useDirectorySync, useSessionMessages } from '@/sync/sync-context'; +import { useSync } from '@/sync/use-sync'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; @@ -314,20 +317,23 @@ export const ModelControls: React.FC = ({ const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession); + const sync = useSync(); + const { - currentSessionId, - messages, + getSessionModelSelection, + saveSessionModelSelection, saveSessionAgentSelection, saveAgentModelForSession, getAgentModelForSession, saveAgentModelVariantForSession, getAgentModelVariantForSession, - analyzeAndSaveExternalSessionChoices, - } = useSessionStore(); + } = useSelectionStore(); const contextHydrated = useContextStore((state) => state.hasHydrated); - const sessionSavedAgentName = useContextStore((state) => + const sessionSavedAgentName = useSelectionStore((state) => currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null ); @@ -563,31 +569,45 @@ export const ModelControls: React.FC = ({ ]; const prevAgentNameRef = React.useRef(undefined); + const latestLoadedUserChoiceRestoreRef = React.useRef(null); - const currentSessionMessageCount = currentSessionId ? (messages.get(currentSessionId)?.length ?? -1) : -1; + const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined; + const hasCurrentSessionMessagesEntry = useDirectorySync( + React.useCallback( + (state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false), + [currentSessionId], + ), + currentSessionDirectory ?? undefined, + ); + const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); + const latestLoadedUserChoice = React.useMemo(() => { + for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) { + const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & { + model?: { providerID?: string; modelID?: string }; + variant?: string; + mode?: string; + }; + if (message.role !== 'user') { + continue; + } - const sessionInitializationRef = React.useRef<{ - sessionId: string; - resolved: boolean; - inFlight: boolean; - } | null>(null); + const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 + ? message.model.providerID + : undefined; + const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 + ? message.model.modelID + : undefined; + const agent = typeof message.agent === 'string' && message.agent.trim().length > 0 + ? message.agent + : (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined); + const variant = typeof message.variant === 'string' && message.variant.trim().length > 0 + ? message.variant + : undefined; - // If we have an explicit per-session agent selection (eg. server-injected mode switch), - // treat the session as resolved and don't run inference/fallback that could cause flicker. - React.useEffect(() => { - if (!currentSessionId) { - return; + return { id: message.id, agent, providerID, modelID, variant }; } - const refState = sessionInitializationRef.current; - if (!refState || refState.sessionId !== currentSessionId) { - return; - } - - if (sessionSavedAgentName && agents.some((agent) => agent.name === sessionSavedAgentName)) { - refState.resolved = true; - refState.inFlight = false; - } - }, [agents, currentSessionId, sessionSavedAgentName]); + return null; + }, [currentSessionMessagesFromSync]); const tryApplyModelSelection = React.useCallback( (providerId: string, modelId: string, agentName?: string): ModelApplyResult => { @@ -606,21 +626,93 @@ export const ModelControls: React.FC = ({ return 'model-missing'; } + const providerMatches = currentProviderId === providerId; + const modelMatches = currentModelId === modelId; + if (providerMatches && modelMatches) { + return 'applied'; + } + setProvider(providerId); setModel(modelId); - if (currentSessionId && agentName) { - saveAgentModelForSession(currentSessionId, agentName, providerId, modelId); + if (currentSessionId) { + saveSessionModelSelection(currentSessionId, providerId, modelId); + if (agentName) { + saveAgentModelForSession(currentSessionId, agentName, providerId, modelId); + } } return 'applied'; }, - [providers, setProvider, setModel, currentSessionId, saveAgentModelForSession], + [providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection], ); React.useEffect(() => { if (!currentSessionId) { - sessionInitializationRef.current = null; + latestLoadedUserChoiceRestoreRef.current = null; + return; + } + + if (!contextHydrated || providers.length === 0 || !hasCurrentSessionMessagesEntry || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) { + return; + } + + const restoreKey = [ + currentSessionId, + latestLoadedUserChoice.id, + latestLoadedUserChoice.agent ?? '', + latestLoadedUserChoice.providerID, + latestLoadedUserChoice.modelID, + latestLoadedUserChoice.variant ?? '', + ].join('|'); + + if (latestLoadedUserChoiceRestoreRef.current === restoreKey) { + return; + } + + if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) { + setAgent(latestLoadedUserChoice.agent); + } + + const applyResult = tryApplyModelSelection( + latestLoadedUserChoice.providerID, + latestLoadedUserChoice.modelID, + latestLoadedUserChoice.agent || currentAgentName || undefined, + ); + if (applyResult !== 'applied') { + return; + } + + if (latestLoadedUserChoice.agent) { + saveSessionAgentSelection(currentSessionId, latestLoadedUserChoice.agent); + saveAgentModelVariantForSession( + currentSessionId, + latestLoadedUserChoice.agent, + latestLoadedUserChoice.providerID, + latestLoadedUserChoice.modelID, + latestLoadedUserChoice.variant, + ); + } + saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID); + latestLoadedUserChoiceRestoreRef.current = restoreKey; + + }, [ + currentSessionId, + currentAgentName, + contextHydrated, + providers, + hasCurrentSessionMessagesEntry, + latestLoadedUserChoice, + setAgent, + tryApplyModelSelection, + saveSessionAgentSelection, + saveAgentModelVariantForSession, + saveSessionModelSelection, + ]); + + React.useEffect(() => { + if (!currentSessionId) { + latestLoadedUserChoiceRestoreRef.current = null; return; } @@ -628,31 +720,10 @@ export const ModelControls: React.FC = ({ return; } - if (!sessionInitializationRef.current || sessionInitializationRef.current.sessionId !== currentSessionId) { - sessionInitializationRef.current = { sessionId: currentSessionId, resolved: false, inFlight: false }; - } - - const state = sessionInitializationRef.current; - if (!state || state.resolved || state.inFlight) { - return; - } - - let isCancelled = false; - - const finalize = () => { - if (isCancelled) { - return; - } - const refState = sessionInitializationRef.current; - if (refState && refState.sessionId === currentSessionId) { - refState.resolved = true; - refState.inFlight = false; - } - }; - const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => { + const savedSessionModel = getSessionModelSelection(currentSessionId); const savedAgentName = currentSessionId - ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) + ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) : null; if (savedAgentName) { if (currentAgentName !== savedAgentName) { @@ -668,9 +739,17 @@ export const ModelControls: React.FC = ({ if (result === 'provider-missing') { return 'waiting'; } - } else { + } + } + + if (savedSessionModel) { + const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined); + if (result === 'applied') { return 'resolved'; } + if (result === 'provider-missing') { + return 'waiting'; + } } for (const agent of agents) { @@ -683,7 +762,7 @@ export const ModelControls: React.FC = ({ setAgent(agent.name); } - const existingSelection = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; + const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; if (!existingSelection) { saveSessionAgentSelection(currentSessionId, agent.name); } @@ -705,14 +784,14 @@ export const ModelControls: React.FC = ({ } const existingSelection = currentSessionId - ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) + ? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) : null; // If we already have a valid agent selected (often from server-injected mode switch), // don't override it with a fallback. const preferred = (currentSessionId - ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) + ? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current) : null) || currentAgentName; if (preferred && agents.some((agent) => agent.name === preferred)) { @@ -740,174 +819,38 @@ export const ModelControls: React.FC = ({ } }; - const resolveSessionPreferences = async () => { - try { - const savedOutcome = applySavedSelections(); - if (savedOutcome === 'resolved') { - finalize(); - return; - } - if (savedOutcome === 'waiting') { - return; - } + const savedOutcome = applySavedSelections(); + if (savedOutcome === 'resolved' || savedOutcome === 'waiting') { + return; + } - if (currentSessionMessageCount === -1) { - return; - } - - if (currentSessionMessageCount > 0) { - state.inFlight = true; - try { - const discoveredChoices = await analyzeAndSaveExternalSessionChoices(currentSessionId, agents); - if (isCancelled) { - return; - } - - if (discoveredChoices.size > 0) { - let latestAgent: string | null = null; - let latestTimestamp = -Infinity; - - for (const [agentName, choice] of discoveredChoices) { - if (choice.timestamp > latestTimestamp) { - latestTimestamp = choice.timestamp; - latestAgent = agentName; - } - } - - if (latestAgent) { - // If server/user already selected an agent for this session, don't override - // with heuristic inference mid-stream. - const latestSaved = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; - if (latestSaved && latestSaved !== latestAgent) { - finalize(); - return; - } - - if (!latestSaved) { - saveSessionAgentSelection(currentSessionId, latestAgent); - } - if (currentAgentName !== latestAgent) { - setAgent(latestAgent); - } - - const latestChoice = discoveredChoices.get(latestAgent); - if (latestChoice) { - const applyResult = tryApplyModelSelection( - latestChoice.providerId, - latestChoice.modelId, - latestAgent, - ); - - if (applyResult === 'applied') { - finalize(); - return; - } - - if (applyResult === 'provider-missing') { - return; - } - } else { - finalize(); - return; - } - } - } - } catch (error) { - if (!isCancelled) { - console.error('[ModelControls] Error resolving session from messages:', error); - } - } finally { - const refState = sessionInitializationRef.current; - if (!isCancelled && refState && refState.sessionId === currentSessionId) { - refState.inFlight = false; - } - } - } - - if (isCancelled) { - return; - } - - applyFallbackAgent(); - finalize(); - } catch (error) { - if (!isCancelled) { - console.error('[ModelControls] Error in session switch:', error); - } + if (!hasCurrentSessionMessagesEntry) { + if (!sync.isLoading(currentSessionId)) { + void sync.syncSession(currentSessionId); } - }; + return; + } - resolveSessionPreferences(); + if (latestLoadedUserChoice) { + return; + } - return () => { - isCancelled = true; - }; + applyFallbackAgent(); }, [ currentSessionId, - currentSessionMessageCount, + hasCurrentSessionMessagesEntry, + latestLoadedUserChoice, agents, primaryAgents, currentAgentName, + getSessionModelSelection, getAgentModelForSession, setAgent, tryApplyModelSelection, - analyzeAndSaveExternalSessionChoices, saveSessionAgentSelection, contextHydrated, providers, - sessionSavedAgentName, - ]); - - React.useEffect(() => { - if (!contextHydrated || !currentSessionId || providers.length === 0 || agents.length === 0) { - return; - } - - const preferredAgent = sessionSavedAgentName || currentAgentName; - if (!preferredAgent) { - return; - } - - const preferredSelection = getAgentModelForSession(currentSessionId, preferredAgent); - if (!preferredSelection) { - return; - } - - const provider = providers.find(p => p.id === preferredSelection.providerId); - if (!provider) { - return; - } - - const modelExists = Array.isArray(provider.models) - ? provider.models.some((m: ProviderModel) => m.id === preferredSelection.modelId) - : false; - if (!modelExists) { - return; - } - - const providerMatches = currentProviderId === preferredSelection.providerId; - const modelMatches = currentModelId === preferredSelection.modelId; - if (providerMatches && modelMatches) { - return; - } - - if (preferredAgent !== currentAgentName) { - setAgent(preferredAgent); - } - - tryApplyModelSelection(preferredSelection.providerId, preferredSelection.modelId, preferredAgent); - }, [ - contextHydrated, - currentSessionId, - currentAgentName, - currentProviderId, - currentModelId, - providers, - agents, - getAgentModelForSession, - tryApplyModelSelection, - setAgent, - sessionSavedAgentName, + sync, ]); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index 2fee9c52..0bebd5c5 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { PermissionRequest, PermissionResponse } from '@/types/permission'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; +import * as sessionActions from '@/sync/session-actions'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; @@ -62,15 +64,14 @@ export const PermissionCard: React.FC = ({ }) => { const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); - const { respondToPermission } = useSessionStore(); - const isFromSubagent = useSessionStore( - React.useCallback((state) => { - const currentSessionId = state.currentSessionId; - if (!currentSessionId || permission.sessionID === currentSessionId) return false; - const sourceSession = state.sessions.find((session) => session.id === permission.sessionID); - return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); - }, [permission.sessionID]) - ); + const respondToPermission = sessionActions.respondToPermission;; + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const isFromSubagent = React.useMemo(() => { + if (!currentSessionId || permission.sessionID === currentSessionId) return false; + const sourceSession = sessions.find((session) => session.id === permission.sessionID); + return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); + }, [permission.sessionID, currentSessionId, sessions]); const { currentTheme } = useThemeSystem(); const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); diff --git a/packages/ui/src/components/chat/PermissionRequest.tsx b/packages/ui/src/components/chat/PermissionRequest.tsx index ab4fad33..402fb9cb 100644 --- a/packages/ui/src/components/chat/PermissionRequest.tsx +++ b/packages/ui/src/components/chat/PermissionRequest.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission'; -import { useSessionStore } from '@/stores/useSessionStore'; +import * as sessionActions from '@/sync/session-actions'; interface PermissionRequestProps { permission: PermissionRequestPayload; @@ -15,7 +15,7 @@ export const PermissionRequest: React.FC = ({ }) => { const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); - const { respondToPermission } = useSessionStore(); + const respondToPermission = sessionActions.respondToPermission;; const handleResponse = async (response: PermissionResponse) => { setIsResponding(true); diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index fa6a670d..d6feb365 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -4,7 +4,9 @@ import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; import type { QuestionRequest } from '@/types/question'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; +import * as sessionActions from '@/sync/session-actions'; interface QuestionCardProps { question: QuestionRequest; @@ -14,15 +16,15 @@ type TabKey = string; const SUMMARY_TAB = 'summary'; export const QuestionCard: React.FC = ({ question }) => { - const { respondToQuestion, rejectQuestion } = useSessionStore(); - const isFromSubagent = useSessionStore( - React.useCallback((state) => { - const currentSessionId = state.currentSessionId; - if (!currentSessionId || question.sessionID === currentSessionId) return false; - const sourceSession = state.sessions.find((session) => session.id === question.sessionID); - return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); - }, [question.sessionID]) - ); + const respondToQuestion = sessionActions.respondToQuestion; + const rejectQuestion = sessionActions.rejectQuestion;; + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const isFromSubagent = React.useMemo(() => { + if (!currentSessionId || question.sessionID === currentSessionId) return false; + const sourceSession = sessions.find((session) => session.id === question.sessionID); + return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); + }, [question.sessionID, currentSessionId, sessions]); const [activeTab, setActiveTab] = React.useState('0'); const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index b36ca389..31fd5cce 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -1,8 +1,8 @@ import React, { memo } from 'react'; import { RiCloseLine, RiMessage2Line } from '@remixicon/react'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useFileStore } from '@/stores/fileStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; interface QueuedMessageChipProps { message: QueuedMessage; @@ -67,7 +67,7 @@ interface QueuedMessageChipsProps { const EMPTY_QUEUE: QueuedMessage[] = []; export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { @@ -84,10 +84,9 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro const popped = popToInput(currentSessionId, message.id); if (popped) { - // Restore attachments to file store if any if (popped.attachments && popped.attachments.length > 0) { - const currentAttachments = useFileStore.getState().attachedFiles; - useFileStore.setState({ + const currentAttachments = useInputStore.getState().attachedFiles; + useInputStore.setState({ attachedFiles: [...currentAttachments, ...popped.attachments] }); } diff --git a/packages/ui/src/components/chat/StatusChip.tsx b/packages/ui/src/components/chat/StatusChip.tsx index e1d26f21..6283ba71 100644 --- a/packages/ui/src/components/chat/StatusChip.tsx +++ b/packages/ui/src/components/chat/StatusChip.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useContextStore } from '@/stores/contextStore'; import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils'; @@ -19,7 +19,7 @@ export const StatusChip: React.FC = ({ onClick, className }) => getCurrentModelVariants, getVisibleAgents, } = useConfigStore(); - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionAgentName = useContextStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index bf673941..2a8a2eb6 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { RiArrowDownSLine, RiArrowUpDoubleLine, @@ -9,8 +10,13 @@ import { RiTimeLine, } from "@remixicon/react"; import { cn } from "@/lib/utils"; -import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore"; -import { useSessionStore } from "@/stores/useSessionStore"; +import { useDirectorySync } from "@/sync/sync-context"; +import type { Todo } from "@opencode-ai/sdk/v2/client"; + +// Compat aliases for old TodoItem shape +type TodoItem = Todo & { id?: string }; +type TodoStatus = string; +type TodoPriority = string; import { useUIStore } from "@/stores/useUIStore"; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; import { isVSCodeRuntime } from "@/lib/desktop"; @@ -146,21 +152,15 @@ export const StatusRow: React.FC = ({ agentName, }) => { const [isExpanded, setIsExpanded] = React.useState(false); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const todos = useTodoStore((state) => - currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const todosRecord = useDirectorySync((state) => state.todo); + const todos: TodoItem[] = React.useMemo( + () => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS), + [todosRecord, currentSessionId], ); - const loadTodos = useTodoStore((state) => state.loadTodos); const { isMobile } = useUIStore(); const isCompact = isMobile || isVSCodeRuntime(); - // Load todos when session changes - React.useEffect(() => { - if (currentSessionId) { - void loadTodos(currentSessionId); - } - }, [currentSessionId, loadTodos]); - // Filter out cancelled todos for display and keep original order. // This prevents items from jumping around when status changes. const visibleTodos = React.useMemo(() => { @@ -313,8 +313,8 @@ export const StatusRow: React.FC = ({ {/* Todo list */}
- {visibleTodos.map((todo) => ( - + {visibleTodos.map((todo, index) => ( + ))}
diff --git a/packages/ui/src/components/chat/StatusRowContainer.tsx b/packages/ui/src/components/chat/StatusRowContainer.tsx new file mode 100644 index 00000000..9c7da1f5 --- /dev/null +++ b/packages/ui/src/components/chat/StatusRowContainer.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useAssistantStatus } from '@/hooks/useAssistantStatus'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { StatusRow } from './StatusRow'; + +/** + * Self-contained wrapper — subscribes to assistant status internally + * so MessageList doesn't re-render on every streaming part delta. + */ +export const StatusRowContainer: React.FC = React.memo(() => { + const { working } = useAssistantStatus(); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + + return ( + + ); +}); + +StatusRowContainer.displayName = 'StatusRowContainer'; diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index bbb1eabe..5db0f35a 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -7,8 +7,8 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useMessageStore } from '@/stores/messageStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionMessageRecords } from '@/sync/sync-context'; import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { Part } from '@opencode-ai/sdk/v2'; @@ -44,13 +44,10 @@ export const TimelineDialog: React.FC = ({ onScrollByTurnOffset, onResumeToLatest, }) => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const messages = useMessageStore((state) => - currentSessionId ? state.messages.get(currentSessionId) || [] : [] - ); - const revertToMessage = useSessionStore((state) => state.revertToMessage); - const forkFromMessage = useSessionStore((state) => state.forkFromMessage); - const loadSessions = useSessionStore((state) => state.loadSessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const messages = useSessionMessageRecords(currentSessionId ?? ''); + const revertToMessage = useSessionUIStore((state) => state.revertToMessage); + const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage); const [forkingMessageId, setForkingMessageId] = React.useState(null); const [searchQuery, setSearchQuery] = React.useState(''); @@ -78,7 +75,6 @@ export const TimelineDialog: React.FC = ({ setForkingMessageId(messageId); try { await forkFromMessage(currentSessionId, messageId); - await loadSessions(); onOpenChange(false); } finally { setForkingMessageId(null); diff --git a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx index 1de252c1..e3d64d5a 100644 --- a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx +++ b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx @@ -3,7 +3,8 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; @@ -55,11 +56,8 @@ export const UnifiedControlsDrawer: React.FC = ({ } = useConfigStore(); const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore(); const { recentModelsList } = useModelLists(); - const { - currentSessionId, - saveAgentModelForSession, - saveAgentModelVariantForSession, - } = useSessionStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const { saveAgentModelForSession, saveAgentModelVariantForSession } = useSelectionStore(); const sessionAgentName = useContextStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); diff --git a/packages/ui/src/components/chat/components/TurnList.tsx b/packages/ui/src/components/chat/components/TurnList.tsx index afaf4671..be2fd0dd 100644 --- a/packages/ui/src/components/chat/components/TurnList.tsx +++ b/packages/ui/src/components/chat/components/TurnList.tsx @@ -10,7 +10,18 @@ interface TurnListProps { } const TurnList = ({ entries, renderEntry }: TurnListProps): React.ReactElement => { - return <>{entries.map((entry) => renderEntry(entry))}; + return ( + <> + {entries.map((entry) => ( +
+ {renderEntry(entry)} +
+ ))} + + ); }; export default React.memo(TurnList) as typeof TurnList; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 5899be54..57ec4420 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -141,7 +141,7 @@ export const useChatTimelineController = ({ historyMetaRef.current = historyMeta; }, [historyMeta]); - React.useEffect(() => { + React.useLayoutEffect(() => { if (initializedSessionRef.current === sessionId) { return; } @@ -153,11 +153,11 @@ export const useChatTimelineController = ({ previousTurnCountRef.current = turnWindowModel.turnCount; }, [sessionId, turnWindowModel.turnCount]); - React.useEffect(() => { + React.useLayoutEffect(() => { setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount)); }, [turnWindowModel.turnCount]); - React.useEffect(() => { + React.useLayoutEffect(() => { const previousTurnCount = previousTurnCountRef.current; const nextTurnCount = turnWindowModel.turnCount; if (previousTurnCount === nextTurnCount) { @@ -180,6 +180,42 @@ export const useChatTimelineController = ({ return windowMessagesByTurn(messages, turnWindowModel, turnStart); }, [messages, turnStart, turnWindowModel]); + // --- Synchronous scroll compensation for load-more / reveal --- + // fetchOlderHistory and revealBufferedTurns store a snapshot here + // before triggering the state change. useLayoutEffect consumes it + // after React commits new DOM — before the browser paints. + const prePrependScrollRef = React.useRef<{ + height: number; + top: number; + anchor: ViewportAnchor | null; + } | null>(null); + + React.useLayoutEffect(() => { + const snap = prePrependScrollRef.current; + const container = scrollRef.current; + if (!snap || !container) return; + prePrependScrollRef.current = null; + + // Try anchor-based restoration first (pixel-perfect) + if (snap.anchor) { + const anchorEl = container.querySelector( + `[data-message-id="${snap.anchor.messageId}"]`, + ); + if (anchorEl) { + const containerRect = container.getBoundingClientRect(); + const anchorTop = anchorEl.getBoundingClientRect().top - containerRect.top; + container.scrollTop += anchorTop - snap.anchor.offsetTop; + return; + } + } + + // Fallback: height-delta compensation + const delta = container.scrollHeight - snap.height; + if (delta > 0) { + container.scrollTop = snap.top + delta; + } + }, [renderedMessages, scrollRef]); + const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => { return messageListRef.current?.captureViewportAnchor() ?? null; }, [messageListRef]); @@ -188,35 +224,19 @@ export const useChatTimelineController = ({ return messageListRef.current?.restoreViewportAnchor(anchor) ?? false; }, [messageListRef]); - const restoreViewportWithFallback = React.useCallback((input: { - anchor: ViewportAnchor | null; - previousHeight: number | null; - previousTop: number | null; - }) => { - const container = scrollRef.current; - if (input.anchor && restoreViewportAnchor(input.anchor)) { - return; - } - - if (!container || input.previousHeight === null || input.previousTop === null) { - return; - } - - const heightDelta = container.scrollHeight - input.previousHeight; - if (heightDelta !== 0) { - container.scrollTop = input.previousTop + heightDelta; - } - }, [restoreViewportAnchor, scrollRef]); - const revealBufferedTurns = React.useCallback(async (): Promise => { if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) { return false; } - const anchor = captureViewportAnchor(); const container = scrollRef.current; - const previousHeight = container?.scrollHeight ?? null; - const previousTop = container?.scrollTop ?? null; + if (container) { + prePrependScrollRef.current = { + height: container.scrollHeight, + top: container.scrollTop, + anchor: captureViewportAnchor(), + }; + } setPendingRevealWork(true); setTurnStart((current) => { @@ -225,14 +245,9 @@ export const useChatTimelineController = ({ }); await waitForFrames(1); - restoreViewportWithFallback({ - anchor, - previousHeight, - previousTop, - }); setPendingRevealWork(false); return true; - }, [captureViewportAnchor, restoreViewportWithFallback, scrollRef]); + }, [captureViewportAnchor, scrollRef]); const fetchOlderHistory = React.useCallback(async (input: { preserveViewport: boolean; @@ -244,16 +259,22 @@ export const useChatTimelineController = ({ return false; } - const anchor = input.preserveViewport ? captureViewportAnchor() : null; const container = scrollRef.current; - const previousHeight = input.preserveViewport ? (container?.scrollHeight ?? null) : null; - const previousTop = input.preserveViewport ? (container?.scrollTop ?? null) : null; const beforeMessages = messagesRef.current; const beforeMessageCount = beforeMessages.length; const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null; const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES; - setPendingRevealWork(true); + // Store scroll snapshot BEFORE the fetch so useLayoutEffect can + // compensate synchronously when React commits the new messages. + if (input.preserveViewport && container) { + prePrependScrollRef.current = { + height: container.scrollHeight, + top: container.scrollTop, + anchor: captureViewportAnchor(), + }; + } + setIsLoadingOlder(true); try { @@ -274,20 +295,11 @@ export const useChatTimelineController = ({ && typeof afterOldestMessageId === 'string' && beforeOldestMessageId !== afterOldestMessageId); - if (input.preserveViewport) { - restoreViewportWithFallback({ - anchor, - previousHeight, - previousTop, - }); - } - return historyGrew || afterLimit > beforeLimit; } finally { setIsLoadingOlder(false); - setPendingRevealWork(false); } - }, [captureViewportAnchor, loadMoreMessages, restoreViewportWithFallback, scrollRef]); + }, [captureViewportAnchor, loadMoreMessages, scrollRef]); const loadEarlier = React.useCallback(async () => { if (await revealBufferedTurns()) { diff --git a/packages/ui/src/components/chat/hooks/useTurnRecords.ts b/packages/ui/src/components/chat/hooks/useTurnRecords.ts index ddbbd17f..b1c1da9d 100644 --- a/packages/ui/src/components/chat/hooks/useTurnRecords.ts +++ b/packages/ui/src/components/chat/hooks/useTurnRecords.ts @@ -1,9 +1,11 @@ import React from 'react'; import { projectTurnRecords } from '../lib/turns/projectTurnRecords'; import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection'; -import type { ChatMessageEntry, TurnProjectionResult } from '../lib/turns/types'; +import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types'; +import { streamPerfMeasure } from '@/stores/utils/streamDebug'; interface UseTurnRecordsOptions { + sessionKey?: string; showTextJustificationActivity: boolean; } @@ -18,33 +20,59 @@ export const useTurnRecords = ( options: UseTurnRecordsOptions, ): TurnRecordsResult => { const previousProjectionRef = React.useRef(null); + const staticTurnsRef = React.useRef([]); + const streamingTurnRef = React.useRef(undefined); React.useEffect(() => { previousProjectionRef.current = null; - }, [options.showTextJustificationActivity]); + staticTurnsRef.current = []; + streamingTurnRef.current = undefined; + }, [options.sessionKey, options.showTextJustificationActivity]); const projection = React.useMemo(() => { - const rawProjection = projectTurnRecords(messages, { - previousProjection: previousProjectionRef.current, - showTextJustificationActivity: options.showTextJustificationActivity, + return streamPerfMeasure('ui.turns.projection_ms', () => { + const rawProjection = projectTurnRecords(messages, { + previousProjection: previousProjectionRef.current, + showTextJustificationActivity: options.showTextJustificationActivity, + }); + const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current); + previousProjectionRef.current = stabilizedProjection; + return stabilizedProjection; }); - const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current); - previousProjectionRef.current = stabilizedProjection; - return stabilizedProjection; }, [messages, options.showTextJustificationActivity]); const staticTurns = React.useMemo(() => { - if (projection.turns.length <= 1) { - return []; + const nextStatic = projection.turns.length <= 1 + ? [] + : projection.turns.slice(0, -1); + const previousStatic = staticTurnsRef.current; + + if (previousStatic.length === nextStatic.length) { + let isSame = true; + for (let index = 0; index < nextStatic.length; index += 1) { + if (previousStatic[index] !== nextStatic[index]) { + isSame = false; + break; + } + } + if (isSame) { + return previousStatic; + } } - return projection.turns.slice(0, -1); + + staticTurnsRef.current = nextStatic; + return nextStatic; }, [projection.turns]); const streamingTurn = React.useMemo(() => { - if (projection.turns.length === 0) { - return undefined; + const nextStreamingTurn = projection.turns.length === 0 + ? undefined + : projection.turns[projection.turns.length - 1]; + if (streamingTurnRef.current === nextStreamingTurn) { + return streamingTurnRef.current; } - return projection.turns[projection.turns.length - 1]; + streamingTurnRef.current = nextStreamingTurn; + return nextStreamingTurn; }, [projection.turns]); return { diff --git a/packages/ui/src/components/chat/lib/turns/historySignals.ts b/packages/ui/src/components/chat/lib/turns/historySignals.ts index f3f70520..d9365ca1 100644 --- a/packages/ui/src/components/chat/lib/turns/historySignals.ts +++ b/packages/ui/src/components/chat/lib/turns/historySignals.ts @@ -1,4 +1,4 @@ -import type { SessionMemoryState } from '@/stores/types/sessionTypes'; +import type { SessionMemoryState } from '@/sync/viewport-store'; export interface TurnHistorySignalsInput { memoryState: SessionMemoryState | null; diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts index f83a2677..585d621c 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts @@ -171,16 +171,11 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit }); let firstWithAny: string | undefined; - let cumulative = 0; for (const message of input.assistantMessages) { const count = countByMessage.get(message.info.id) ?? 0; if (count > 0 && !firstWithAny) { firstWithAny = message.info.id; } - cumulative += count; - if (cumulative >= 2) { - return message.info.id; - } } return firstWithAny; diff --git a/packages/ui/src/components/chat/lib/turns/stageTurns.ts b/packages/ui/src/components/chat/lib/turns/stageTurns.ts index 2f88a8ac..c5e7807c 100644 --- a/packages/ui/src/components/chat/lib/turns/stageTurns.ts +++ b/packages/ui/src/components/chat/lib/turns/stageTurns.ts @@ -20,8 +20,8 @@ export interface StageTurnsResult { } const DEFAULT_STAGE_CONFIG: TurnStageConfig = { - init: 1, - batch: 3, + init: 10, + batch: 8, }; export const getInitialStageCount = (total: number, config: TurnStageConfig): number => { diff --git a/packages/ui/src/components/chat/lib/turns/types.ts b/packages/ui/src/components/chat/lib/turns/types.ts index 419f980a..e2024098 100644 --- a/packages/ui/src/components/chat/lib/turns/types.ts +++ b/packages/ui/src/components/chat/lib/turns/types.ts @@ -105,6 +105,7 @@ export type Turn = Pick = ({ part }) => { const [expanded, setExpanded] = React.useState(false); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const description = typeof part.description === 'string' ? part.description.trim() : ''; const command = typeof part.command === 'string' ? part.command.trim() : ''; @@ -254,6 +255,7 @@ const formatTurnDuration = (durationMs: number): string => { interface MessageBodyProps { + sessionId?: string; messageId: string; parts: Part[]; isUser: boolean; @@ -562,6 +564,7 @@ const UserMessageBody: React.FC<{ }; const AssistantMessageBody: React.FC> = ({ + sessionId, messageId, parts, isMessageCompleted, @@ -696,7 +699,7 @@ const AssistantMessageBody: React.FC> = ({ return visibleParts.filter((part) => part.type === 'text'); }, [visibleParts]); - const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage); + const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const isSortedRenderMode = chatRenderMode === 'sorted'; @@ -1065,6 +1068,8 @@ const AssistantMessageBody: React.FC> = ({ return all.filter((segment) => segment.anchorMessageId === messageId); }, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]); + const hasAnchoredActivitySegments = activityGroupSegmentsForMessage.length > 0; + const activityByPart = React.useMemo(() => { const byRef = new Map(); const byId = new Map(); @@ -1092,9 +1097,14 @@ const AssistantMessageBody: React.FC> = ({ }, [activityPartsForTurn]); const toggleActivityGroup = turnGroupingContext?.toggleGroup; + const isActivityOwnerMessage = !isSortedRenderMode + || !turnGroupingContext?.activityOwnerMessageId + || turnGroupingContext.activityOwnerMessageId === messageId + || hasAnchoredActivitySegments; const shouldRenderActivityGroup = isSortedRenderMode - && activityGroupSegmentsForMessage.length > 0 + && isActivityOwnerMessage + && hasAnchoredActivitySegments && Boolean(toggleActivityGroup); @@ -1155,6 +1165,7 @@ const AssistantMessageBody: React.FC> = ({ > = ({ > = ({ const toolPart = part as ToolPartType; const toolName = toolPart.tool?.toLowerCase() ?? ''; + if (isSortedRenderMode && !isActivityOwnerMessage) { + i += 1; + continue; + } + const activity = activityByPart.get(part); - if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) { + if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) { i += 1; continue; } @@ -1279,8 +1296,10 @@ const AssistantMessageBody: React.FC> = ({ expandedTools, hasStopFinish, isMobile, + isActivityOwnerMessage, isSortedRenderMode, messageId, + sessionId, onContentChange, onShowPopup, onToggleTool, @@ -1525,4 +1544,37 @@ const MessageBody: React.FC = ({ isUser, ...props }) => { return ; }; -export default React.memo(MessageBody); +export default React.memo(MessageBody, (prev, next) => { + return prev.sessionId === next.sessionId + && prev.messageId === next.messageId + && prev.isUser === next.isUser + && areRenderRelevantPartsEqual(prev.parts, next.parts) + && prev.isMessageCompleted === next.isMessageCompleted + && prev.messageFinish === next.messageFinish + && prev.messageCompletedAt === next.messageCompletedAt + && prev.messageCreatedAt === next.messageCreatedAt + && prev.syntaxTheme === next.syntaxTheme + && prev.isMobile === next.isMobile + && prev.hasTouchInput === next.hasTouchInput + && prev.copiedCode === next.copiedCode + && prev.expandedTools === next.expandedTools + && prev.streamPhase === next.streamPhase + && prev.allowAnimation === next.allowAnimation + && prev.shouldShowHeader === next.shouldShowHeader + && prev.hasTextContent === next.hasTextContent + && prev.copiedMessage === next.copiedMessage + && prev.showReasoningTraces === next.showReasoningTraces + && prev.agentMention === next.agentMention + && prev.turnGroupingContext === next.turnGroupingContext + && prev.errorMessage === next.errorMessage + && prev.userActionsMode === next.userActionsMode + && prev.stickyUserHeaderEnabled === next.stickyUserHeaderEnabled + && prev.onCopyCode === next.onCopyCode + && prev.onToggleTool === next.onToggleTool + && prev.onShowPopup === next.onShowPopup + && prev.onContentChange === next.onContentChange + && prev.onCopyMessage === next.onCopyMessage + && prev.onAuxiliaryContentComplete === next.onAuxiliaryContentComplete + && prev.onRevert === next.onRevert + && prev.onFork === next.onFork; +}); diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index 7c19ebb1..bb93cdf1 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; @@ -196,8 +197,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const pendingSelectionRef = React.useRef(null); const openRafRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); - const createSession = useSessionStore((state) => state.createSession); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); + const createSession = useSessionUIStore((state) => state.createSession); + const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index d10fd3a3..ee9c0393 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -5,11 +5,13 @@ import type { StreamPhase } from '../types'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility'; +import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug'; type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } }; interface AssistantTextPartProps { part: Part; + sessionId?: string; messageId: string; streamPhase: StreamPhase; chatRenderMode?: 'sorted' | 'live'; @@ -22,6 +24,8 @@ const AssistantTextPart: React.FC = ({ streamPhase, chatRenderMode = 'live', }) => { + // Use part directly from props — parent provides the latest version from the store. + // No store subscription here to avoid re-render cascade from unrelated delta events. const partWithText = part as PartWithText; const rawText = typeof partWithText.text === 'string' ? partWithText.text : ''; const contentText = typeof partWithText.content === 'string' ? partWithText.content : ''; @@ -33,6 +37,11 @@ const AssistantTextPart: React.FC = ({ const isCooldownPhase = streamPhase === 'cooldown'; const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase); + streamPerfCount('ui.assistant_text_part.render'); + if (isStreaming) { + streamPerfCount('ui.assistant_text_part.render.streaming'); + } + const throttledTextContent = useStreamingTextThrottle({ text: textContent, isStreaming, @@ -45,32 +54,7 @@ const AssistantTextPart: React.FC = ({ isStreaming, }); - const lastDisplayLengthRef = React.useRef(0); - React.useEffect(() => { - if (!isStreaming || typeof window === 'undefined') { - lastDisplayLengthRef.current = displayTextContent.length; - return; - } - const debugEnabled = window.localStorage.getItem('openchamber_stream_debug') === '1'; - if (!debugEnabled) { - lastDisplayLengthRef.current = displayTextContent.length; - return; - } - if (displayTextContent.length < lastDisplayLengthRef.current) { - console.info('[STREAM-TRACE] render_shrink', { - messageId, - partId: part.id, - rawTextLen: rawText.length, - contentLen: contentText.length, - valueLen: valueText.length, - chosenLen: textContent.length, - throttledLen: throttledTextContent.length, - displayLen: displayTextContent.length, - prevDisplayLen: lastDisplayLengthRef.current, - }); - } - lastDisplayLengthRef.current = displayTextContent.length; - }, [contentText.length, displayTextContent.length, isStreaming, messageId, part.id, rawText.length, textContent.length, throttledTextContent.length, valueText.length]); + streamPerfObserve('ui.assistant_text_part.display_len', displayTextContent.length); const time = partWithText.time; const isFinalized = Boolean(time && typeof time.end !== 'undefined'); @@ -105,4 +89,4 @@ const AssistantTextPart: React.FC = ({ ); }; -export default AssistantTextPart; +export default React.memo(AssistantTextPart); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index bfd40187..75f23934 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -8,6 +8,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useUIStore } from '@/stores/useUIStore'; import { useDurationTickerNow } from './useDurationTicker'; import { MarkdownRenderer } from '../../MarkdownRenderer'; +import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; @@ -201,16 +202,21 @@ const ReasoningPart: React.FC = ({ const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); const time = partWithText.time; const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number'; + const throttledText = useStreamingTextThrottle({ + text: textContent, + isStreaming, + identityKey: `${messageId}:${part.id ?? 'reasoning'}`, + }); // Show reasoning even if time.end isn't set yet (during streaming) // Only hide if there's no text content - if (!textContent || textContent.trim().length === 0) { + if (!throttledText || throttledText.trim().length === 0) { return null; } return ( { if (typeof value !== 'string') { return undefined; @@ -844,7 +844,7 @@ const TaskToolSummary: React.FC<{ animateTailText?: boolean; isActive?: boolean; }> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => { - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); const displayEntries = entries; @@ -1674,14 +1674,7 @@ const ToolPart: React.FC = ({ return readTaskSessionIdFromOutput(taskOutputString); }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]); - const childSessionMessages = useSessionStore( - React.useCallback((store) => { - if (!taskSessionId) { - return EMPTY_SESSION_MESSAGES; - } - return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES; - }, [taskSessionId]) - ); + const childSessionMessages = useSessionMessageRecords(taskSessionId ?? ''); const metadataTaskSummaryEntries = React.useMemo(() => { if (!isTaskTool) { @@ -1901,7 +1894,20 @@ const ToolPart: React.FC = ({ taskPollLastSignatureRef.current = nextSignature; taskPollNoChangeCountRef.current = 0; - useSessionStore.getState().syncMessages(taskSessionId, messages); + // Inject fetched subagent messages into sync child store + const childStores = getSyncChildStores(); + const dir = getSyncDirectory(); + childStores.update(dir, (prev) => { + const records = messages as SessionMessageWithParts[]; + const partPatch: Record = { ...prev.part }; + for (const rec of records) { + partPatch[rec.info.id] = rec.parts; + } + return { + message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] }, + part: partPatch, + }; + }); } catch { // Ignore transient subagent fetch errors. } finally { diff --git a/packages/ui/src/components/chat/message/renderCompare.ts b/packages/ui/src/components/chat/message/renderCompare.ts new file mode 100644 index 00000000..b198c98c --- /dev/null +++ b/packages/ui/src/components/chat/message/renderCompare.ts @@ -0,0 +1,115 @@ +import type { Message, Part } from '@opencode-ai/sdk/v2'; + +type MessageRecord = { + info: Message; + parts: Part[]; +}; + +const readPartId = (part: Part | undefined): string | null => { + if (!part) return null; + const candidate = (part as { id?: unknown }).id; + return typeof candidate === 'string' && candidate.length > 0 ? candidate : null; +}; + +const readToolStatus = (part: Part | undefined): string | null => { + const status = (part as { state?: { status?: unknown } } | undefined)?.state?.status; + return typeof status === 'string' ? status : null; +}; + +const readPartTime = (part: Part | undefined) => { + const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time; + return { + start: typeof time?.start === 'number' ? time.start : null, + end: typeof time?.end === 'number' ? time.end : null, + }; +}; + +const readPartText = (part: Part | undefined): string => { + const candidate = part as { text?: unknown; content?: unknown; value?: unknown } | undefined; + if (!candidate) return ''; + const text = typeof candidate.text === 'string' ? candidate.text : ''; + const content = typeof candidate.content === 'string' ? candidate.content : ''; + const value = typeof candidate.value === 'string' ? candidate.value : ''; + return [text, content, value].reduce((best, next) => (next.length > best.length ? next : best), ''); +}; + +export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolean => { + if (left === right) return true; + if (left.length !== right.length) return false; + + for (let index = 0; index < left.length; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + + if (leftPart.type !== rightPart.type) { + return false; + } + + const leftId = readPartId(leftPart); + const rightId = readPartId(rightPart); + if (leftId !== rightId) { + return false; + } + + if (leftPart.type === 'tool') { + if (readToolStatus(leftPart) !== readToolStatus(rightPart)) { + return false; + } + const leftTime = readPartTime(leftPart); + const rightTime = readPartTime(rightPart); + if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) { + return false; + } + const leftTool = (leftPart as { tool?: unknown }).tool; + const rightTool = (rightPart as { tool?: unknown }).tool; + if (leftTool !== rightTool) { + return false; + } + continue; + } + + const leftTime = readPartTime(leftPart); + const rightTime = readPartTime(rightPart); + if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) { + return false; + } + + if (leftPart.type === 'text' || leftPart.type === 'reasoning') { + if (readPartText(leftPart) !== readPartText(rightPart)) { + return false; + } + } + } + + return true; +}; + +export const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => { + if (left === right) return true; + + return left.id === right.id + && left.role === right.role + && left.sessionID === right.sessionID + && (left as { finish?: unknown }).finish === (right as { finish?: unknown }).finish + && (left as { status?: unknown }).status === (right as { status?: unknown }).status + && (left as { mode?: unknown }).mode === (right as { mode?: unknown }).mode + && (left as { agent?: unknown }).agent === (right as { agent?: unknown }).agent + && (left as { providerID?: unknown }).providerID === (right as { providerID?: unknown }).providerID + && (left as { modelID?: unknown }).modelID === (right as { modelID?: unknown }).modelID + && (left as { variant?: unknown }).variant === (right as { variant?: unknown }).variant + && (left as { clientRole?: unknown }).clientRole === (right as { clientRole?: unknown }).clientRole + && (left as { userMessageMarker?: unknown }).userMessageMarker === (right as { userMessageMarker?: unknown }).userMessageMarker + && ((left as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null) + && ((left as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null); +}; + +export const areRenderRelevantMessagesEqual = (left: MessageRecord, right: MessageRecord): boolean => { + return areRenderRelevantMessageInfoEqual(left.info, right.info) && areRenderRelevantPartsEqual(left.parts, right.parts); +}; + +export const areOptionalRenderRelevantMessagesEqual = (left?: MessageRecord, right?: MessageRecord): boolean => { + if (!left || !right) { + return left === right; + } + return areRenderRelevantMessagesEqual(left, right); +}; diff --git a/packages/ui/src/components/comments/useInlineCommentController.ts b/packages/ui/src/components/comments/useInlineCommentController.ts index e70cbd5f..db27e875 100644 --- a/packages/ui/src/components/comments/useInlineCommentController.ts +++ b/packages/ui/src/components/comments/useInlineCommentController.ts @@ -1,7 +1,7 @@ import React from 'react'; import { toast } from '@/components/ui'; import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; type LineRangeBase = { start: number; @@ -48,8 +48,8 @@ export function useInlineCommentController( ) { const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options; - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const addDraft = useInlineCommentDraftStore((state) => state.addDraft); const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft); diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index b8550424..235f2c26 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -7,13 +7,12 @@ import { deriveMessageRole } from '@/components/chat/message/messageRole'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions, useSessionMessageRecords } from '@/sync/sync-context'; import { copyTextToClipboard } from '@/lib/clipboard'; type SessionMessage = { info: Message; parts: Part[] }; -const EMPTY_SESSION_MESSAGES: SessionMessage[] = []; - type ProviderModelLike = { id?: string; name?: string; @@ -277,12 +276,9 @@ export const ContextPanelContent: React.FC = () => { const [expandedRawMessages, setExpandedRawMessages] = React.useState>({}); const [copiedRawMessageId, setCopiedRawMessageId] = React.useState(null); const copyResetTimeoutRef = React.useRef(null); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); - const sessionMessages = useSessionStore((state) => { - if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES; - return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES; - }); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); + const sessionMessages = useSessionMessageRecords(currentSessionId ?? ''); const providers = useConfigStore((state) => state.providers); React.useEffect(() => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 4fd67a54..6dce9625 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -20,7 +20,9 @@ import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions, useSessionMessageRecords } from '@/sync/sync-context'; +import { getAllSyncSessions } from '@/sync/sync-refs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -241,17 +243,13 @@ export const Header: React.FC = ({ const { getCurrentModel } = useConfigStore(); const runtimeApis = useRuntimeAPIs(); - const getContextUsage = useSessionStore((state) => state.getContextUsage); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const isNewSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const currentSessionMessages = useSessionStore((state) => { - if (!currentSessionId) { - return undefined; - } - return state.messages.get(currentSessionId); - }); - const sessions = useSessionStore((state) => state.sessions); + const getContextUsage = useSessionUIStore((state) => state.getContextUsage); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const currentSessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); + const currentSessionMessages = currentSessionId ? (currentSessionMessageRecords.length > 0 ? currentSessionMessageRecords : undefined) : undefined; + const sessions = useSessions(); const activeProject = useProjectsStore((state) => { if (!state.activeProjectId) { return null; @@ -565,14 +563,20 @@ export const Header: React.FC = ({ const currentSession = React.useMemo(() => { if (!currentSessionId) return null; - return sessions.find((s) => s.id === currentSessionId) ?? null; + // Try current directory's store first, then fall back to all child stores. + // The sidebar loads sessions globally via SDK, but the header uses + // useSessions() which only has the current directory. This fallback + // ensures the title/directory show when the session lives elsewhere. + return sessions.find((s) => s.id === currentSessionId) + ?? getAllSyncSessions().find((s) => s.id === currentSessionId) + ?? null; }, [currentSessionId, sessions]); - const worktreePath = useSessionStore((state) => { + const worktreePath = useSessionUIStore((state) => { if (!currentSessionId) return ''; return state.worktreeMetadata.get(currentSessionId)?.path ?? ''; }); - const currentSessionWorktreeBranch = useSessionStore((state) => { + const currentSessionWorktreeBranch = useSessionUIStore((state) => { if (!currentSessionId) return null; return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null; }); @@ -588,7 +592,7 @@ export const Header: React.FC = ({ return normalize(raw || ''); }, [currentSession?.directory]); - const draftDirectory = useSessionStore((state) => { + const draftDirectory = useSessionUIStore((state) => { if (!state.newSessionDraft?.open) { return ''; } diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index 1b7d7b82..9e195f9a 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -39,7 +39,8 @@ export const RightSidebarTabs: React.FC = () => {
- {rightSidebarTab === 'git' ? : } + {rightSidebarTab === 'git' && } + {rightSidebarTab === 'files' && }
); diff --git a/packages/ui/src/components/layout/SidebarContextSummary.tsx b/packages/ui/src/components/layout/SidebarContextSummary.tsx index 723d2d8d..944b1970 100644 --- a/packages/ui/src/components/layout/SidebarContextSummary.tsx +++ b/packages/ui/src/components/layout/SidebarContextSummary.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { cn } from '@/lib/utils'; @@ -23,8 +24,8 @@ const formatDirectoryPath = (path?: string) => { }; export const SidebarContextSummary: React.FC = ({ className }) => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); const { currentDirectory } = useDirectoryStore(); const activeSessionTitle = React.useMemo(() => { diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 29548903..254c21d0 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -453,13 +453,29 @@ export const SidebarFilesTree: React.FC = () => { React.useEffect(() => { if (!root || expandedPaths.length === 0) return; - for (const expandedPath of expandedPaths) { - const normalized = normalizePath(expandedPath); - if (!normalized || normalized === root) continue; - if (!normalized.startsWith(`${root}/`)) continue; - if (loadedDirsRef.current.has(normalized) || inFlightDirsRef.current.has(normalized)) continue; - void loadDirectory(normalized); - } + // Sort by depth so parent dirs load before children + const toLoad = expandedPaths + .map((p) => normalizePath(p)) + .filter((normalized): normalized is string => + !!normalized && + normalized !== root && + normalized.startsWith(`${root}/`) && + !loadedDirsRef.current.has(normalized) && + !inFlightDirsRef.current.has(normalized), + ) + .sort((a, b) => a.split('/').length - b.split('/').length); + + if (toLoad.length === 0) return; + + // Load with concurrency limit to avoid API stampede on startup + let cancelled = false; + void (async () => { + for (let i = 0; i < toLoad.length && !cancelled; i += 3) { + const batch = toLoad.slice(i, i + 3); + await Promise.all(batch.map((dir) => loadDirectory(dir))); + } + })(); + return () => { cancelled = true; }; }, [expandedPaths, loadDirectory, root]); // --- Fuzzy search scoring (matching FilesView) --- diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index ff1adccf..7675f29a 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { SessionSidebar } from '@/components/session/SessionSidebar'; import { ChatView, SettingsView } from '@/components/views'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useSessions, useDirectorySync } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { McpDropdown } from '@/components/mcp/McpDropdown'; @@ -74,7 +76,7 @@ export const VSCodeLayout: React.FC = () => { const bootDraftOpen = React.useMemo(() => { try { - return Boolean(useSessionStore.getState().newSessionDraft?.open); + return Boolean(useSessionUIStore.getState().newSessionDraft?.open); } catch { return false; } @@ -88,8 +90,8 @@ export const VSCodeLayout: React.FC = () => { const expandedSidebarResizeStartXRef = React.useRef(0); const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH); const expandedSidebarResizePointerIdRef = React.useRef(null); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); const activeSessionTitle = React.useMemo(() => { if (!currentSessionId) { @@ -97,21 +99,21 @@ export const VSCodeLayout: React.FC = () => { } return sessions.find((session) => session.id === currentSessionId)?.title || 'Session'; }, [currentSessionId, sessions]); - const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); - const isSyncingMessages = useSessionStore((state) => state.isSyncing); - const hasActiveSessionWork = useSessionStore((state) => { - const statuses = state.sessionStatus; - if (!statuses || statuses.size === 0) { + const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const isSyncingMessages = useViewportStore((state) => state.isSyncing); + const hasActiveSessionWork = useDirectorySync((state) => { + const statuses = state.session_status; + if (!statuses || Object.keys(statuses).length === 0) { return false; } - for (const status of statuses.values()) { + for (const status of Object.values(statuses)) { if (status?.type === 'busy' || status?.type === 'retry') { return true; } } return false; }); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>( () => (typeof window !== 'undefined' ? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as @@ -120,9 +122,6 @@ export const VSCodeLayout: React.FC = () => { ); const configInitialized = useConfigStore((state) => state.isInitialized); const initializeConfig = useConfigStore((state) => state.initializeApp); - const loadSessions = useSessionStore((state) => state.loadSessions); - const loadMessages = useSessionStore((state) => state.loadMessages); - const messages = useSessionStore((state) => state.messages); const [hasInitializedOnce, setHasInitializedOnce] = React.useState(() => configInitialized); const [isInitializing, setIsInitializing] = React.useState(false); const lastBootstrapAttemptAt = React.useRef(0); @@ -158,18 +157,11 @@ export const VSCodeLayout: React.FC = () => { } const timeoutId = window.setTimeout(() => { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const stillNoSession = !state.currentSessionId; const draftStillClosed = !state.newSessionDraft?.open; - const stillSyncing = state.isSyncing; - const stillActiveWork = (() => { - const statuses = state.sessionStatus; - if (!statuses || statuses.size === 0) return false; - for (const status of statuses.values()) { - if (status?.type === 'busy' || status?.type === 'retry') return true; - } - return false; - })(); + const stillSyncing = useViewportStore.getState().isSyncing; + const stillActiveWork = false; // sync bootstrap tracks session status if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) { setCurrentView('sessions'); @@ -270,17 +262,10 @@ export const VSCodeLayout: React.FC = () => { if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) { return; } - await loadSessions(); - const sessionsError = useSessionStore.getState().error; if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', { providers: configState.providers.length, agents: configState.agents.length, - sessions: useSessionStore.getState().sessions.length, - sessionsError, }); - if (typeof sessionsError === 'string' && sessionsError.length > 0) { - return; - } setHasInitializedOnce(true); } catch { // Ignore bootstrap failures @@ -289,7 +274,7 @@ export const VSCodeLayout: React.FC = () => { } }; void runBootstrap(); - }, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]); + }, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing]); React.useEffect(() => { if (viewMode !== 'editor') { @@ -314,35 +299,9 @@ export const VSCodeLayout: React.FC = () => { } hasAppliedInitialSession.current = true; - void useSessionStore.getState().setCurrentSession(initialSessionId); + void useSessionUIStore.getState().setCurrentSession(initialSessionId); }, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]); - // Hydrate messages when viewing chat - React.useEffect(() => { - const hydrateMessages = async () => { - if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) { - return; - } - - if (!currentSessionId) { - return; - } - - const hasMessagesEntry = messages.has(currentSessionId); - if (hasMessagesEntry) { - return; - } - - try { - await loadMessages(currentSessionId); - } catch { - /* ignored */ - } - }; - - void hydrateMessages(); - }, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]); - // Track container width for responsive settings layout React.useEffect(() => { const container = containerRef.current; @@ -532,8 +491,8 @@ interface VSCodeHeaderProps { } const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => { - const { getCurrentModel } = useConfigStore(); - const getContextUsage = useSessionStore((state) => state.getContextUsage); + const getCurrentModel = useConfigStore((s) => s.getCurrentModel); + const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const quotaResults = useQuotaStore((state) => state.results); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 2ec20c19..b96a9e00 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -11,7 +11,7 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { cn, formatDirectoryName } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/openchamberConfig'; @@ -440,7 +440,7 @@ export const MultiRunLauncher: React.FC = ({ const result = await createMultiRun(params); if (result) { if (result.firstSessionId) { - useSessionStore.getState().setCurrentSession(result.firstSessionId); + useSessionUIStore.getState().setCurrentSession(result.firstSessionId); } // Close launcher diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index b8312525..d3fc6f0f 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -5,8 +5,7 @@ import { NumberInput } from '@/components/ui/number-input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { usePermissionStore } from '@/stores/permissionStore'; +import { useDirectorySync } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDeviceInfo } from '@/lib/device'; import { opencodeClient } from '@/lib/opencode/client'; @@ -184,7 +183,6 @@ const buildPermissionConfigWithGlobal = ( export const AgentsPage: React.FC = () => { const { isMobile } = useDeviceInfo(); const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore(); - useConfigStore(); const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null; const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent); @@ -220,7 +218,7 @@ export const AgentsPage: React.FC = () => { const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const [toolIds, setToolIds] = React.useState([]); - const permissionsBySession = usePermissionStore((state) => state.permissions); + const permissionsBySession = useDirectorySync((state) => state.permission); React.useEffect(() => { let cancelled = false; @@ -264,7 +262,7 @@ export const AgentsPage: React.FC = () => { } } - for (const permissions of permissionsBySession.values()) { + for (const permissions of Object.values(permissionsBySession)) { for (const request of permissions) { const permissionName = request.permission?.trim(); if (permissionName && permissionName !== 'invalid') { diff --git a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx index 6c9471cf..9c36311a 100644 --- a/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/SessionRetentionSettings.tsx @@ -5,33 +5,44 @@ import { toast } from '@/components/ui'; import { NumberInput } from '@/components/ui/number-input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; const MIN_DAYS = 1; const MAX_DAYS = 365; const DEFAULT_RETENTION_DAYS = 30; +const RETENTION_ACTION_OPTIONS = [ + { value: 'archive', label: 'Archive' }, + { value: 'delete', label: 'Delete' }, +] as const; export const SessionRetentionSettings: React.FC = () => { const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); + const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction); const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled); const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays); + const setSessionRetentionAction = useUIStore((state) => state.setSessionRetentionAction); - const { candidates, isRunning, runCleanup } = useSessionAutoCleanup({ autoRun: false }); + const { candidates, isRunning, runCleanup, action } = useSessionAutoCleanup({ autoRun: false }); const pendingCount = candidates.length; const handleRunCleanup = React.useCallback(async () => { const result = await runCleanup({ force: true }); - if (result.deletedIds.length === 0 && result.failedIds.length === 0) { - toast.message('No sessions eligible for deletion'); + const verb = result.action === 'archive' ? 'archiving' : 'deletion'; + const pastTense = result.action === 'archive' ? 'Archived' : 'Deleted'; + const failureVerb = result.action === 'archive' ? 'archive' : 'delete'; + + if (result.completedIds.length === 0 && result.failedIds.length === 0) { + toast.message(`No sessions eligible for ${verb}`); return; } - if (result.deletedIds.length > 0) { - toast.success(`Deleted ${result.deletedIds.length} session${result.deletedIds.length === 1 ? '' : 's'}`); + if (result.completedIds.length > 0) { + toast.success(`${pastTense} ${result.completedIds.length} session${result.completedIds.length === 1 ? '' : 's'}`); } if (result.failedIds.length > 0) { - toast.error(`Failed to delete ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`); + toast.error(`Failed to ${failureVerb} ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`); } }, [runCleanup]); @@ -47,7 +58,7 @@ export const SessionRetentionSettings: React.FC = () => { - Automatically delete inactive sessions based on their last activity. Keeps recent 5 sessions. + Automatically archive or delete inactive sessions based on last activity. Keeps the 5 most recent sessions. @@ -103,6 +114,31 @@ export const SessionRetentionSettings: React.FC = () => { + +
+
+ When sessions expire +
+
+ {RETENTION_ACTION_OPTIONS.map((option) => ( + + ))} +
+
@@ -124,7 +160,7 @@ export const SessionRetentionSettings: React.FC = () => {

- Eligible for deletion right now: {pendingCount} + Eligible for {action === 'archive' ? 'archiving' : 'deletion'} right now: {pendingCount}

diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 881a47ad..4bd39e68 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -4,7 +4,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDeviceInfo } from '@/lib/device'; import { checkIsGitRepository } from '@/lib/gitApi'; @@ -24,7 +25,8 @@ export const WorktreeSectionContent: React.FC = ({ const projectPath = projectRefProp?.path ?? activeProject?.path ?? null; - const { sessions, getWorktreeMetadata } = useSessionStore(); + const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata); + const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const [setupCommands, setSetupCommands] = React.useState([]); diff --git a/packages/ui/src/components/session/BranchPickerDialog.tsx b/packages/ui/src/components/session/BranchPickerDialog.tsx index f88def66..b1a0b79b 100644 --- a/packages/ui/src/components/session/BranchPickerDialog.tsx +++ b/packages/ui/src/components/session/BranchPickerDialog.tsx @@ -27,7 +27,7 @@ import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { sessionEvents } from '@/lib/sessionEvents'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessions } from '@/sync/sync-context'; export interface BranchPickerProject { id: string; @@ -65,7 +65,7 @@ const normalizePath = (value: string | null | undefined): string => { }; export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) { - const sessions = useSessionStore((state) => state.sessions); + const sessions = useSessions(); const [searchQuery, setSearchQuery] = React.useState(''); const [branches, setBranches] = React.useState(null); const [worktrees, setWorktrees] = React.useState([]); diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index 883f5a44..a48afb05 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -21,9 +21,10 @@ import { import { cn } from '@/lib/utils'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useMessageStore } from '@/stores/messageStore'; import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; @@ -377,7 +378,7 @@ export function GitHubIssuePickerDialog({ return created.id; } - const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); + const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); if (!session?.id) { throw new Error('Failed to create session'); } @@ -385,10 +386,10 @@ export function GitHubIssuePickerDialog({ })(); // Ensure worktree-based sessions also get the issue title. - void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); try { - useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); } catch { // ignore } @@ -397,7 +398,7 @@ export function GitHubIssuePickerDialog({ onOpenChange(false); const configState = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index f36dd3fc..b8f3c5f0 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -10,15 +10,19 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SelectLabel, - SelectGroup, - SelectSeparator, -} from '@/components/ui/select'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; import { RiGitBranchLine, RiGitRepositoryLine, @@ -29,14 +33,16 @@ import { RiCheckLine, RiExternalLinkLine, RiCloseLine, + RiArrowDownSLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useUIStore } from '@/stores/useUIStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useMessageStore } from '@/stores/messageStore'; import { useContextStore } from '@/stores/contextStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; @@ -44,6 +50,7 @@ import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; import { opencodeClient } from '@/lib/opencode/client'; +import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; @@ -233,12 +240,6 @@ export function NewWorktreeDialog({ const isLoadingBranches = useGitStore((state) => state.isLoadingBranches); const fetchBranches = useGitStore((state) => state.fetchBranches); - React.useEffect(() => { - if (!open || !projectDirectory || !git) return; - if (branches?.all) return; - void fetchBranches(projectDirectory, git); - }, [open, projectDirectory, git, branches?.all, fetchBranches]); - // Compute local and remote branch lists (same pattern as GitView) const localBranches = React.useMemo(() => { if (!branches?.all) return []; @@ -256,8 +257,7 @@ export function NewWorktreeDialog({ }, [branches]); // Get existing worktrees for the current project to avoid conflicts - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const loadSessions = useSessionStore((state) => state.loadSessions); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const existingWorktreeNames = React.useMemo(() => { if (!projectDirectory) return new Set(); const worktrees = availableWorktreesByProject.get(projectDirectory) ?? []; @@ -278,10 +278,122 @@ export function NewWorktreeDialog({ const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); + // Desktop branch picker states + const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); + const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false); + // Mobile branch picker states const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false); const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false); - + + // Shared query state per picker (desktop + mobile) + const [existingBranchQuery, setExistingBranchQuery] = React.useState(''); + const [sourceBranchQuery, setSourceBranchQuery] = React.useState(''); + const existingBranchDropdownContentRef = React.useRef(null); + const sourceBranchDropdownContentRef = React.useRef(null); + const existingBranchMobileListWrapperRef = React.useRef(null); + const sourceBranchMobileListWrapperRef = React.useRef(null); + + const findScrollableContainer = React.useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let node: HTMLElement | null = startNode; + while (node && node !== document.body) { + const { overflowY } = window.getComputedStyle(node); + if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { + return node; + } + node = node.parentElement; + } + return null; + }, []); + + const resetScrollToTop = React.useCallback((container: HTMLElement | null) => { + if (!container) { + return; + } + container.scrollTop = 0; + }, []); + + const resetDesktopPickerScroll = React.useCallback((contentRef: React.RefObject) => { + const list = contentRef.current?.querySelector('[data-slot="command-list"]') ?? null; + resetScrollToTop(list); + }, [resetScrollToTop]); + + const resetMobilePickerScroll = React.useCallback((wrapperRef: React.RefObject) => { + const scrollContainer = findScrollableContainer(wrapperRef.current); + resetScrollToTop(scrollContainer); + }, [findScrollableContainer, resetScrollToTop]); + + const existingBranchRankedGroups = React.useMemo(() => { + return rankBranchesForQuery({ + localBranches, + remoteBranches, + query: existingBranchQuery, + }); + }, [localBranches, remoteBranches, existingBranchQuery]); + + const sourceBranchRankedGroups = React.useMemo(() => { + return rankBranchesForQuery({ + localBranches, + remoteBranches, + query: sourceBranchQuery, + }); + }, [localBranches, remoteBranches, sourceBranchQuery]); + + const hasExistingBranchQuery = existingBranchQuery.trim().length > 0; + const hasSourceBranchQuery = sourceBranchQuery.trim().length > 0; + const hasExistingBranchMatches = existingBranchRankedGroups.matching.length > 0; + const hasSourceBranchMatches = sourceBranchRankedGroups.matching.length > 0; + const canFetchBranches = Boolean(projectDirectory && git); + + const handleFetchBranches = React.useCallback(() => { + if (!projectDirectory || !git) { + return; + } + void fetchBranches(projectDirectory, git); + }, [projectDirectory, git, fetchBranches]); + + React.useEffect(() => { + if (!existingBranchDropdownOpen && !existingBranchPickerOpen) { + setExistingBranchQuery(''); + } + }, [existingBranchDropdownOpen, existingBranchPickerOpen]); + + React.useEffect(() => { + if (!sourceBranchDropdownOpen && !sourceBranchPickerOpen) { + setSourceBranchQuery(''); + } + }, [sourceBranchDropdownOpen, sourceBranchPickerOpen]); + + React.useEffect(() => { + if (existingBranchDropdownOpen) { + resetDesktopPickerScroll(existingBranchDropdownContentRef); + } + if (existingBranchPickerOpen) { + resetMobilePickerScroll(existingBranchMobileListWrapperRef); + } + }, [ + existingBranchDropdownOpen, + existingBranchPickerOpen, + existingBranchQuery, + resetDesktopPickerScroll, + resetMobilePickerScroll, + ]); + + React.useEffect(() => { + if (sourceBranchDropdownOpen) { + resetDesktopPickerScroll(sourceBranchDropdownContentRef); + } + if (sourceBranchPickerOpen) { + resetMobilePickerScroll(sourceBranchMobileListWrapperRef); + } + }, [ + sourceBranchDropdownOpen, + sourceBranchPickerOpen, + sourceBranchQuery, + resetDesktopPickerScroll, + resetMobilePickerScroll, + ]); + // Validation state const [validation, setValidation] = React.useState({ isValidating: false, @@ -399,7 +511,7 @@ export function NewWorktreeDialog({ } const configState = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; @@ -630,6 +742,12 @@ Nice-to-have: selectedBranch: '', worktreeName: '', }); + setExistingBranchDropdownOpen(false); + setSourceBranchDropdownOpen(false); + setExistingBranchPickerOpen(false); + setSourceBranchPickerOpen(false); + setExistingBranchQuery(''); + setSourceBranchQuery(''); setValidation({ isValidating: false, branchError: null, @@ -847,16 +965,16 @@ Nice-to-have: ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() : 'New session'; - const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null); + const session = await sessionActions.createSession(sessionTitle, metadata.path, null); if (!session?.id) { throw new Error('Failed to create session'); } createdSessionId = session.id; - void useSessionStore.getState().updateSessionTitle(session.id, sessionTitle).catch(() => undefined); + void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined); try { - useSessionStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents); + useSessionUIStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents); } catch { // ignore } @@ -871,8 +989,6 @@ Nice-to-have: description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''} - bootstrapping in background`, }); - void loadSessions().catch(() => undefined); - onOpenChange(false); if (createdSessionId) { @@ -1037,17 +1153,29 @@ Nice-to-have: - +
+ + +
{/* Mobile Branch Picker Overlay */} setExistingBranchPickerOpen(false)} > -
+
+ setExistingBranchQuery(e.target.value)} + placeholder="Search branches..." + className="h-8" + /> {isLoadingBranches ? (
Loading branches... @@ -1065,14 +1199,52 @@ Nice-to-have: No branches found
) : ( - <> - {localBranches.length > 0 && ( +
+ {hasExistingBranchQuery && hasExistingBranchMatches && (
- Local branches + Matching branches
- {localBranches.map(branch => ( + {existingBranchRankedGroups.matching.map((branch) => ( + + ))} +
+
+ )} + + {hasExistingBranchQuery && !hasExistingBranchMatches && ( +
+ No matching branches +
+ )} + + {existingBranchRankedGroups.otherLocal.length > 0 && ( +
+
+ {hasExistingBranchQuery ? 'Other local branches' : 'Local branches'} +
+
+ {existingBranchRankedGroups.otherLocal.map((branch) => (
)} - {remoteBranches.length > 0 && ( + + {existingBranchRankedGroups.otherRemote.length > 0 && (
- Remote branches + {hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}
- {remoteBranches.map(branch => ( + {existingBranchRankedGroups.otherRemote.map((branch) => (
)} - +
)}
@@ -1274,7 +1447,13 @@ Nice-to-have: title="Select Source Branch" onClose={() => setSourceBranchPickerOpen(false)} > -
+
+ setSourceBranchQuery(e.target.value)} + placeholder="Search branches..." + className="h-8" + /> {isLoadingBranches ? (
Loading branches... @@ -1284,14 +1463,47 @@ Nice-to-have: No branches found
) : ( - <> - {localBranches.length > 0 && ( +
+ {hasSourceBranchQuery && hasSourceBranchMatches && (
- Local branches + Matching branches
- {localBranches.map(branch => ( + {sourceBranchRankedGroups.matching.map((branch) => ( + + ))} +
+
+ )} + + {hasSourceBranchQuery && !hasSourceBranchMatches && ( +
+ No matching branches +
+ )} + + {sourceBranchRankedGroups.otherLocal.length > 0 && ( +
+
+ {hasSourceBranchQuery ? 'Other local branches' : 'Local branches'} +
+
+ {sourceBranchRankedGroups.otherLocal.map((branch) => (
)} - {remoteBranches.length > 0 && ( + + {sourceBranchRankedGroups.otherRemote.length > 0 && (
- Remote branches + {hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}
- {remoteBranches.map(branch => ( + {sourceBranchRankedGroups.otherRemote.map((branch) => (
)} - +
)}
@@ -1435,59 +1648,129 @@ Nice-to-have: - -
+
+ + + + + + + + + {isLoadingBranches ? ( +
+ Loading branches... +
+ ) : localBranches.length === 0 && remoteBranches.length === 0 ? ( + No branches found + ) : ( + <> + {hasExistingBranchQuery && hasExistingBranchMatches && ( + + {existingBranchRankedGroups.matching.map((branch) => ( + { + setExistingBranchState((prev) => ({ + ...prev, + selectedBranch: branch.value, + worktreeName: slugifyWorktreeName(branch.label), + })); + setValidation((prev) => ({ ...prev, touched: true })); + setExistingBranchDropdownOpen(false); + }} + > + {branch.label} + + ))} + + )} + + {hasExistingBranchQuery && !hasExistingBranchMatches && ( +
+ No matching branches +
+ )} + + {existingBranchRankedGroups.otherLocal.length > 0 && ( + <> + {hasExistingBranchQuery && } + + {existingBranchRankedGroups.otherLocal.map((branch) => ( + { + setExistingBranchState((prev) => ({ + ...prev, + selectedBranch: branch, + worktreeName: slugifyWorktreeName(branch), + })); + setValidation((prev) => ({ ...prev, touched: true })); + setExistingBranchDropdownOpen(false); + }} + > + {branch} + + ))} + + + )} + + {existingBranchRankedGroups.otherRemote.length > 0 && ( + <> + {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( + + )} + + {existingBranchRankedGroups.otherRemote.map((branch) => ( + { + setExistingBranchState((prev) => ({ + ...prev, + selectedBranch: `remotes/${branch}`, + worktreeName: slugifyWorktreeName(branch), + })); + setValidation((prev) => ({ ...prev, touched: true })); + setExistingBranchDropdownOpen(false); + }} + > + {branch} + + ))} + + + )} + + )} +
+
+
+
+ +
+
) : (
@@ -1606,51 +1889,101 @@ Nice-to-have: - + + + + {newBranchState.sourceBranch && (
New branch will be created from {newBranchState.sourceBranch} diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index 336c223b..9cae301a 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -19,7 +19,8 @@ import { type ProjectRef, } from '@/lib/openchamberConfig'; import { useUIStore } from '@/stores/useUIStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { cn } from '@/lib/utils'; @@ -52,9 +53,9 @@ export const ProjectNotesTodoPanel: React.FC = ({ const [sendingTodoId, setSendingTodoId] = React.useState(null); const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set()); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const setPendingInputText = useInputStore((state) => state.setPendingInputText); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index 165ff0f3..8cd22e5c 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -18,7 +18,8 @@ import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import * as sessionActions from '@/sync/session-actions'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -59,17 +60,14 @@ export const SessionDialogs: React.FC = () => { const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false); const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState>(new Set()); - const { - deleteSession, - deleteSessions, - archiveSession, - archiveSessions, - loadSessions, - getWorktreeMetadata, - newSessionDraft, - setNewSessionDraftTarget, - setDraftBootstrapPendingDirectory, - } = useSessionStore(); + const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); + const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); + const deleteSession = sessionActions.deleteSession; + const archiveSession = sessionActions.archiveSession; + const deleteSessions = useSessionUIStore((s) => s.deleteSessions); + const archiveSessions = useSessionUIStore((s) => s.archiveSessions); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore(); @@ -113,24 +111,7 @@ export const SessionDialogs: React.FC = () => { isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches; const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete; - React.useEffect(() => { - loadSessions(); - }, [loadSessions, currentDirectory]); - - const projectsKey = React.useMemo( - () => projects.map((project) => `${project.id}:${project.path}`).join('|'), - [projects], - ); - const lastProjectsKeyRef = React.useRef(projectsKey); - - React.useEffect(() => { - if (projectsKey === lastProjectsKeyRef.current) { - return; - } - - lastProjectsKeyRef.current = projectsKey; - loadSessions(); - }, [loadSessions, projectsKey]); + // Session loading is handled by sync bootstrap — no manual loadSessions needed. React.useEffect(() => { if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) { @@ -444,7 +425,6 @@ export const SessionDialogs: React.FC = () => { description: renderToastDescription(archiveNote), }); closeDeleteDialog(); - loadSessions(); return; } @@ -497,10 +477,8 @@ export const SessionDialogs: React.FC = () => { if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { // Remove selected worktree even if per-session metadata is missing. // Use same projectRef logic as the no-sessions path. - const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); - if (removed) { - await loadSessions(); - } + await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + // sync handles session refresh automatically } if (deletedIds.length > 0) { @@ -537,10 +515,8 @@ export const SessionDialogs: React.FC = () => { } if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { - const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); - if (removed) { - await loadSessions(); - } + await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch); + // sync bootstrap refreshes sessions automatically } closeDeleteDialog(); @@ -560,7 +536,6 @@ export const SessionDialogs: React.FC = () => { isWorktreeDelete, canRemoveRemoteBranches, removeSelectedWorktree, - loadSessions, ]); const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index f644c071..d38fd615 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -7,8 +7,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { sessionEvents } from '@/lib/sessionEvents'; import { formatDirectoryName, cn } from '@/lib/utils'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useSessions, useDirectorySync, useAllSessionStatuses } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useSync } from '@/sync/use-sync'; +import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import type { GitHubPullRequestStatus } from '@/lib/api/types'; @@ -26,7 +30,6 @@ import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSel import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering'; import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping'; import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects'; -import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe'; import { useSessionActions } from './sidebar/hooks/useSessionActions'; import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence'; @@ -44,6 +47,9 @@ import { SidebarFooter } from './sidebar/SidebarFooter'; import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; import { SessionNodeItem } from './sidebar/SessionNodeItem'; import { useUpdateStore } from '@/stores/useUpdateStore'; +import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; +import { checkIsGitRepository } from '@/lib/gitApi'; +import type { WorktreeMetadata } from '@/types/worktree'; import type { SortableDragHandleProps } from './sidebar/sortableItems'; import { FolderDeleteConfirmDialog, @@ -64,6 +70,7 @@ import { formatProjectLabel, normalizePath, } from './sidebar/utils'; +import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; @@ -299,27 +306,93 @@ export const SessionSidebar: React.FC = ({ const gitDirectories = useGitStore((state) => state.directories); - const sessions = useSessionStore((state) => state.sessions); - const archivedSessions = useSessionStore((state) => state.archivedSessions); - const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const loadMessages = useSessionStore((state) => state.loadMessages); - const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle); - const shareSession = useSessionStore((state) => state.shareSession); - const unshareSession = useSessionStore((state) => state.unshareSession); - const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState); - const sessionStatus = useSessionStore((state) => state.sessionStatus); - const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates); - const permissions = useSessionStore((state) => state.permissions); - const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory); - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); + const sync = useSync(); + const syncSessions = useSessions(); + const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); + const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); + const sessionsByDirectory = useGlobalSessionsStore((state) => state.sessionsByDirectory); + const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); + const shareSession = useSessionUIStore((state) => state.shareSession); + const unshareSession = useSessionUIStore((state) => state.unshareSession); + const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState); + const globalSessionStatuses = useAllSessionStatuses(); + // sessionAttentionStates removed — now using notification-store directly in SessionNodeItem + const permissionsRecord = useDirectorySync((state) => state.permission); + + const sessionStatus = React.useMemo( + () => new Map(Object.entries(globalSessionStatuses)), + [globalSessionStatuses], + ); + const permissions = React.useMemo( + () => new Map(Object.entries(permissionsRecord)), + [permissionsRecord], + ); + const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const prStatusEntries = useGitHubPrStatusStore((state) => state.entries); const updateStore = useUpdateStore(); + const sessions = React.useMemo( + () => (hasLoadedGlobalSessions ? globalActiveSessions : syncSessions), + [globalActiveSessions, hasLoadedGlobalSessions, syncSessions], + ); + + const syncSessionSignature = React.useMemo( + () => syncSessions + .map((session) => `${session.id}:${session.time?.updated ?? session.time?.created ?? 0}:${session.time?.archived ? 1 : 0}`) + .join('|'), + [syncSessions], + ); + + React.useEffect(() => { + let cancelled = false; + + const discoverWorktrees = async () => { + const projectEntries = useProjectsStore.getState().projects; + if (projectEntries.length === 0) return; + + const worktreesByProject = new Map(); + const allWorktrees: WorktreeMetadata[] = []; + + await Promise.all( + projectEntries.map(async (project) => { + const projectPath = normalizePath(project.path); + if (!projectPath) return; + try { + const isGitRepo = await checkIsGitRepository(projectPath); + if (!isGitRepo) return; + const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); + if (cancelled || worktrees.length === 0) return; + worktreesByProject.set(projectPath, worktrees); + allWorktrees.push(...worktrees); + } catch { + // ignore discovery errors + } + }), + ); + + if (cancelled) return; + + useSessionUIStore.setState({ + availableWorktrees: allWorktrees, + availableWorktreesByProject: worktreesByProject, + }); + }; + + void refreshGlobalSessions(syncSessions); + void discoverWorktrees(); + + return () => { + cancelled = true; + }; + }, [currentDirectory, syncSessionSignature, syncSessions]); + const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false); @@ -614,10 +687,10 @@ export const SessionSidebar: React.FC = ({ updateStore.available && (updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web'); - const deleteSession = useSessionStore((state) => state.deleteSession); - const deleteSessions = useSessionStore((state) => state.deleteSessions); - const archiveSession = useSessionStore((state) => state.archiveSession); - const archiveSessions = useSessionStore((state) => state.archiveSessions); + const deleteSession = useSessionUIStore((state) => state.deleteSession); + const deleteSessions = useSessionUIStore((state) => state.deleteSessions); + const archiveSession = useSessionUIStore((state) => state.archiveSession); + const archiveSessions = useSessionUIStore((state) => state.archiveSessions); const { copiedSessionId, @@ -820,7 +893,7 @@ export const SessionSidebar: React.FC = ({ setProjectRootBranches, }); - const isSessionsLoading = useSessionStore((state) => state.isLoading); + const isSessionsLoading = useSessionUIStore((state) => state.isLoading); useSessionFolderCleanup({ isSessionsLoading, sessions, @@ -970,6 +1043,7 @@ export const SessionSidebar: React.FC = ({ branchLabel?: string | null; } | null; }>(); + const projectPathLengthBySessionId = new Map(); projectSections.forEach((section) => { const projectLabel = formatProjectLabel( @@ -984,12 +1058,19 @@ export const SessionSidebar: React.FC = ({ const visit = (nodes: SessionNode[]) => { nodes.forEach((node) => { + const nextProjectPathLength = section.project.normalizedPath.length; + const currentProjectPathLength = projectPathLengthBySessionId.get(node.session.id) ?? -1; + if (nextProjectPathLength < currentProjectPathLength) { + return; + } + meta.set(node.session.id, { node, projectId: section.project.id, groupDirectory: group.directory, secondaryMeta, }); + projectPathLengthBySessionId.set(node.session.id, nextProjectPathLength); if (node.children.length > 0) { visit(node.children); } @@ -1008,12 +1089,7 @@ export const SessionSidebar: React.FC = ({ [activeNowEntries, sessions], ); - useSessionPrefetch({ - currentSessionId, - sortedSessions, - recentSessionIds: activeNowSessions.map((session) => session.id), - loadMessages, - }); + // Prefetch is wired below, after recentSessionIds is computed. const activitySections = React.useMemo(() => { const toItem = (session: Session) => { @@ -1036,6 +1112,15 @@ export const SessionSidebar: React.FC = ({ return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id))); }, [activitySections]); + const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]); + + useSessionPrefetch({ + currentSessionId, + sortedSessions, + recentSessionIds: recentSessionIdsList, + loadMessages: sync.syncSession, + }); + const sectionsForSidebarRender = React.useMemo(() => { if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) { return sectionsForRender; @@ -1105,7 +1190,6 @@ export const SessionSidebar: React.FC = ({ expandedParents={expandedParents} hasSessionSearchQuery={hasSessionSearchQuery} normalizedSessionSearchQuery={normalizedSessionSearchQuery} - sessionAttentionStates={sessionAttentionStates as Map} notifyOnSubtasks={notifyOnSubtasks} sessionStatus={sessionStatus as Map | undefined} permissions={permissions as Map} @@ -1147,7 +1231,6 @@ export const SessionSidebar: React.FC = ({ expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, - sessionAttentionStates, notifyOnSubtasks, sessionStatus, permissions, diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 23523e3b..1ec78232 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -38,6 +38,7 @@ import { DraggableSessionRow } from './sessionFolderDnd'; import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; +import { useSessionUnseenCount } from '@/sync/notification-store'; const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); @@ -65,7 +66,6 @@ type Props = { expandedParents: Set; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; - sessionAttentionStates: Map; notifyOnSubtasks: boolean; sessionStatus?: Map; permissions: Map; @@ -113,7 +113,6 @@ export function SessionNodeItem(props: Props): React.ReactNode { expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, - sessionAttentionStates, notifyOnSubtasks, sessionStatus, permissions, @@ -177,8 +176,8 @@ export function SessionNodeItem(props: Props): React.ReactNode { const isPinnedSession = pinnedSessionIds.has(session.id); const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id); const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); - const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true; - const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks); + const unseenCount = useSessionUnseenCount(session.id); + const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks); const sessionSummary = session.summary as SessionSummaryMeta | undefined; const sessionDiffStats = resolveSessionDiffStats(sessionSummary); const sessionTimestamp = session.time?.updated || session.time?.created || Date.now(); diff --git a/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts b/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts index 622091a2..9e6ebf27 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useDirectoryStatusProbe.ts @@ -1,25 +1,73 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { opencodeClient } from '@/lib/opencode/client'; +import { mapWithConcurrency } from '@/lib/concurrency'; import { normalizePath } from '../utils'; type ProjectLike = { path: string }; +type DirectoryStatusValue = 'unknown' | 'exists' | 'missing'; + type Args = { sortedSessions: Session[]; projects: ProjectLike[]; - directoryStatus: Map; - setDirectoryStatus: React.Dispatch>>; + directoryStatus: Map; + setDirectoryStatus: React.Dispatch>>; }; +const PROBE_CONCURRENCY = 3; +const MISSING_CACHE_KEY = 'oc.directoryProbe.missing'; +// Re-probe missing directories periodically in case they're recreated +const MISSING_REPROBE_MS = 10 * 60 * 1000; // 10 minutes + +type MissingCache = Record; // directory -> timestamp + +function loadMissingCache(): MissingCache { + try { + const raw = localStorage.getItem(MISSING_CACHE_KEY); + if (!raw) return {}; + return JSON.parse(raw) as MissingCache; + } catch { + return {}; + } +} + +function saveMissingCache(cache: MissingCache): void { + try { + localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(cache)); + } catch { + // ignore quota errors + } +} + +async function probeDirectory(directory: string): Promise { + try { + await opencodeClient.listLocalDirectory(directory); + return 'exists'; + } catch { + const looksLikeSdkWorktree = + directory.includes('/opencode/worktree/') || + directory.includes('/.opencode/data/worktree/') || + directory.includes('/.local/share/opencode/worktree/'); + + if (looksLikeSdkWorktree) { + const ok = await opencodeClient.probeDirectory(directory).catch(() => false); + if (ok) return 'exists'; + } + + return 'missing'; + } +} + export const useDirectoryStatusProbe = ({ sortedSessions, projects, directoryStatus, setDirectoryStatus, }: Args): void => { - const directoryStatusRef = React.useRef>(new Map()); - const checkingDirectories = React.useRef>(new Set()); + const directoryStatusRef = React.useRef>(new Map()); + const probeInFlightRef = React.useRef(false); + const missingCacheRef = React.useRef(loadMissingCache()); React.useEffect(() => { directoryStatusRef.current = directoryStatus; @@ -29,68 +77,83 @@ export const useDirectoryStatusProbe = ({ const directories = new Set(); sortedSessions.forEach((session) => { const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (dir) { - directories.add(dir); - } + if (dir) directories.add(dir); }); projects.forEach((project) => { const normalized = normalizePath(project.path); - if (normalized) { - directories.add(normalized); - } + if (normalized) directories.add(normalized); }); - directories.forEach((directory) => { + const now = Date.now(); + const missingCache = missingCacheRef.current; + const toProbe: string[] = []; + const preseeded = new Map(); + + for (const directory of directories) { const known = directoryStatusRef.current.get(directory); - if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) { - return; + if (known && known !== 'unknown') continue; + + // Use cached "missing" status if fresh enough — skip the HTTP probe + const cachedAt = missingCache[directory]; + if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) { + preseeded.set(directory, 'missing'); + continue; } - checkingDirectories.current.add(directory); - opencodeClient - .listLocalDirectory(directory) - .then(() => { - setDirectoryStatus((prev) => { - const next = new Map(prev); - if (next.get(directory) === 'exists') { - return prev; - } - next.set(directory, 'exists'); - return next; - }); - }) - .catch(async () => { - const looksLikeSdkWorktree = - directory.includes('/opencode/worktree/') || - directory.includes('/.opencode/data/worktree/') || - directory.includes('/.local/share/opencode/worktree/'); - if (looksLikeSdkWorktree) { - const ok = await opencodeClient.probeDirectory(directory).catch(() => false); - if (ok) { - setDirectoryStatus((prev) => { - const next = new Map(prev); - if (next.get(directory) === 'exists') { - return prev; - } - next.set(directory, 'exists'); - return next; - }); - return; - } + toProbe.push(directory); + } + + // Apply preseeded missing statuses immediately (no HTTP call) + if (preseeded.size > 0) { + setDirectoryStatus((prev) => { + let changed = false; + const next = new Map(prev); + for (const [dir, status] of preseeded) { + if (next.get(dir) !== status) { + next.set(dir, status); + changed = true; } + } + return changed ? next : prev; + }); + } - setDirectoryStatus((prev) => { - const next = new Map(prev); - if (next.get(directory) === 'missing') { - return prev; - } - next.set(directory, 'missing'); - return next; - }); - }) - .finally(() => { - checkingDirectories.current.delete(directory); + if (toProbe.length === 0 || probeInFlightRef.current) return; + probeInFlightRef.current = true; + + let cancelled = false; + let cacheChanged = false; + + void mapWithConcurrency(toProbe, PROBE_CONCURRENCY, async (directory) => { + const status = await probeDirectory(directory); + + // Update missing cache + if (status === 'missing') { + missingCache[directory] = Date.now(); + cacheChanged = true; + } else if (missingCache[directory]) { + delete missingCache[directory]; + cacheChanged = true; + } + + if (!cancelled) { + setDirectoryStatus((prev) => { + if (prev.get(directory) === status) return prev; + const next = new Map(prev); + next.set(directory, status); + return next; }); + } + return { directory, status }; + }).finally(() => { + probeInFlightRef.current = false; + if (cacheChanged) { + saveMissingCache(missingCache); + } }); + + return () => { + cancelled = true; + }; }, [sortedSessions, projects, setDirectoryStatus]); }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts index 3119e816..c70836cc 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts @@ -1,5 +1,6 @@ import React from 'react'; import { checkIsGitRepository } from '@/lib/gitApi'; +import { mapWithConcurrency } from '@/lib/concurrency'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; type Project = { id: string; path: string; normalizedPath: string }; @@ -39,26 +40,25 @@ export const useProjectRepoStatus = (args: Args): void => { }; } - normalized.forEach((project) => { - checkIsGitRepository(project.path) - .then((result) => { - if (!cancelled) { - setProjectRepoStatus((prev) => { - const next = new Map(prev); - next.set(project.id, result); - return next; - }); - } - }) - .catch(() => { - if (!cancelled) { - setProjectRepoStatus((prev) => { - const next = new Map(prev); - next.set(project.id, null); - return next; - }); - } - }); + void mapWithConcurrency(normalized, 2, async (project) => { + try { + const result = await checkIsGitRepository(project.path); + if (!cancelled) { + setProjectRepoStatus((prev) => { + const next = new Map(prev); + next.set(project.id, result); + return next; + }); + } + } catch { + if (!cancelled) { + setProjectRepoStatus((prev) => { + const next = new Map(prev); + next.set(project.id, null); + return next; + }); + } + } }); return () => { @@ -78,12 +78,10 @@ export const useProjectRepoStatus = (args: Args): void => { React.useEffect(() => { let cancelled = false; const run = async () => { - const entries = await Promise.all( - normalizedProjects.map(async (project) => { - const branch = await getRootBranch(project.normalizedPath).catch(() => null); - return { id: project.id, branch }; - }), - ); + const entries = await mapWithConcurrency(normalizedProjects, 2, async (project) => { + const branch = await getRootBranch(project.normalizedPath).catch(() => null); + return { id: project.id, branch }; + }); if (cancelled) { return; } diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts index ff304858..211ebd07 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts @@ -1,8 +1,10 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getSyncMessages } from '@/sync/sync-refs'; const SESSION_PREFETCH_HOVER_DELAY_MS = 180; +const SESSION_PREFETCH_SETTLE_MS = 600; const SESSION_PREFETCH_CONCURRENCY = 1; const SESSION_PREFETCH_PENDING_LIMIT = 6; @@ -10,7 +12,7 @@ type Args = { currentSessionId: string | null; sortedSessions: Session[]; recentSessionIds?: string[]; - loadMessages: (sessionId: string, limit?: number) => Promise; + loadMessages: (sessionId: string) => Promise; }; export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => { @@ -29,15 +31,14 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes break; } - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); if (state.currentSessionId === nextSessionId) { continue; } - const hasMessages = state.messages.has(nextSessionId); - const historyMeta = state.sessionHistoryMeta.get(nextSessionId); - const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean'; - if (isHydrated) { + // Check if messages already loaded in sync child store + const hasMessages = getSyncMessages(nextSessionId).length > 0; + if (hasMessages) { continue; } @@ -56,11 +57,9 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes return; } - const state = useSessionStore.getState(); - const hasMessages = state.messages.has(sessionId); - const historyMeta = state.sessionHistoryMeta.get(sessionId); - const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean'; - if (isHydrated) { + // Already loaded in sync + const hasMessages = getSyncMessages(sessionId).length > 0; + if (hasMessages) { return; } @@ -89,30 +88,32 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes sessionPrefetchTimersRef.current.set(sessionId, timer); }, [currentSessionId, pumpSessionPrefetchQueue]); + // Wait for the active session to finish loading before prefetching neighbors. + // On rapid session switches the timer resets, so only the final session triggers prefetch. React.useEffect(() => { if (!currentSessionId || sortedSessions.length === 0) { return; } - const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); - if (currentIndex < 0) { - return; - } - scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); - scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); + const timer = window.setTimeout(() => { + const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId); + if (currentIndex < 0) return; + scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id); + scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id); + }, SESSION_PREFETCH_SETTLE_MS); + return () => window.clearTimeout(timer); }, [currentSessionId, scheduleSessionPrefetch, sortedSessions]); React.useEffect(() => { if (!currentSessionId || recentSessionIds.length === 0) { return; } - - const currentIndex = recentSessionIds.indexOf(currentSessionId); - if (currentIndex < 0) { - return; - } - - scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]); - scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]); + const timer = window.setTimeout(() => { + const currentIndex = recentSessionIds.indexOf(currentSessionId); + if (currentIndex < 0) return; + scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]); + scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]); + }, SESSION_PREFETCH_SETTLE_MS); + return () => window.clearTimeout(timer); }, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]); React.useEffect(() => { diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 3075286a..2ae01af4 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -10,43 +10,38 @@ import { CommandShortcut, } from '@/components/ui/command'; import { useUIStore } from '@/stores/useUIStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useDeviceInfo } from '@/lib/device'; -import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react'; +import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata'; export const CommandPalette: React.FC = () => { - const { - isCommandPaletteOpen, - setCommandPaletteOpen, - setHelpDialogOpen, - setActiveMainTab, - setSettingsDialogOpen, - setSettingsPage, - setSessionSwitcherOpen, - setTimelineDialogOpen, - toggleSidebar, - toggleRightSidebar, - setRightSidebarOpen, - setRightSidebarTab, - toggleBottomTerminal, - setBottomTerminalExpanded, - isBottomTerminalExpanded, - shortcutOverrides, - } = useUIStore(); + const isCommandPaletteOpen = useUIStore((s) => s.isCommandPaletteOpen); + const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen); + const setHelpDialogOpen = useUIStore((s) => s.setHelpDialogOpen); + const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); + const setSettingsPage = useUIStore((s) => s.setSettingsPage); + const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); + const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); + const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen); + const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab); + const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal); + const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded); + const isBottomTerminalExpanded = useUIStore((s) => s.isBottomTerminalExpanded); + const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); - const { - openNewSessionDraft, - setCurrentSession, - getSessionsByDirectory, - } = useSessionStore(); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const getSessionsByDirectory = useSessionUIStore((s) => s.getSessionsByDirectory); - const { currentDirectory } = useDirectoryStore(); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const { themeMode, setThemeMode } = useThemeSystem(); const handleClose = () => { @@ -167,11 +162,6 @@ export const CommandPalette: React.FC = () => { handleClose(); }; - const handleOpenTimeline = () => { - setTimelineDialogOpen(true); - handleClose(); - }; - const directorySessions = getSessionsByDirectory(currentDirectory ?? ''); const currentSessions = React.useMemo(() => { return directorySessions.slice(0, 5); @@ -252,11 +242,6 @@ export const CommandPalette: React.FC = () => { Open Git Panel {shortcut('open_git_panel')} - - - Open Timeline - {shortcut('open_timeline')} - Open Settings diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index e09209f7..597cdf7f 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -186,12 +186,6 @@ export const HelpDialog: React.FC = () => { description: "Switch Project", icon: RiLayoutLeftLine, }, - { - id: 'open_timeline', - description: "Open Timeline", - icon: RiTimeLine, - keys: '', - }, { id: 'toggle_services_menu', description: 'Toggle Services Menu', diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index ccb09126..23e97935 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -1,37 +1,162 @@ import React from 'react'; -import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore'; +import { RiBarChartBoxLine, RiCloseLine, RiDatabase2Line, RiFileCopyLine, RiPulseLine, RiRefreshLine } from '@remixicon/react'; + +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useViewportStore } from '@/sync/viewport-store'; +import { useSessions, useDirectorySync } from '@/sync/sync-context'; +import { MEMORY_LIMITS } from '@/stores/types/sessionTypes'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; -import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; +import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; +import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; -import { RiCloseLine, RiDatabase2Line, RiPulseLine } from '@remixicon/react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -interface MemoryDebugPanelProps { +interface DebugPanelProps { onClose?: () => void; } -export const MemoryDebugPanel: React.FC = ({ onClose }) => { - const { - sessions, - messages, - sessionMemoryState, - currentSessionId, - } = useSessionStore(); +type DebugTab = 'memory' | 'streaming'; + +const formatDuration = (durationMs: number): string => { + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms`; + } + + const seconds = durationMs / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + + const minutes = Math.floor(seconds / 60); + const remainderSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainderSeconds}s`; +}; + +const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => { + return ( +
+
{label}
+
{value}
+
+ ); +}; + +const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; emptyLabel: string }> = ({ title, snapshot, emptyLabel }) => { + const topEntries = snapshot.entries.slice(0, 12); + const totalSamples = snapshot.entries.reduce((sum, entry) => sum + entry.count, 0); + + return ( +
+
+
{title}
+
+ {snapshot.startedAt ? formatDuration(snapshot.durationMs) : 'idle'} +
+
+ +
+ + + +
+ + {topEntries.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( + +
+ {topEntries.map((entry) => ( +
+
{entry.metric}
+
+ count {entry.count} + avg {entry.avg} + max {entry.max} + total {entry.total} +
+
+ ))} +
+
+ )} +
+ ); +}; + +export const DebugPanel: React.FC = ({ onClose }) => { + const [activeTab, setActiveTab] = React.useState('memory'); + const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle'); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState); + const sessions = useSessions(); + const messageRecord = useDirectorySync((state) => state.message); const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount); + const [streamSnapshot, setStreamSnapshot] = React.useState(() => getStreamPerfSnapshot()); + const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState(() => getVsCodeStreamPerfSnapshot()); + const streamMetricCounts = React.useMemo(() => { + const counts = new Map(); + streamSnapshot.entries.forEach((entry) => { + counts.set(entry.metric, entry.count); + }); + return { + messageListRender: counts.get('ui.message_list.render') ?? 0, + messageListRenderStreaming: counts.get('ui.message_list.render.streaming') ?? 0, + chatMessageRender: counts.get('ui.chat_message.render') ?? 0, + chatMessageRenderStreaming: counts.get('ui.chat_message.render.streaming') ?? 0, + chatMessageRenderStaticDuringStream: counts.get('ui.chat_message.render.static_during_stream') ?? 0, + chatMessageRenderStaticOutsideActiveTurnDuringStream: + counts.get('ui.chat_message.render.static_outside_active_turn_during_stream') ?? 0, + }; + }, [streamSnapshot.entries]); + + React.useEffect(() => { + const refresh = () => { + setStreamSnapshot(getStreamPerfSnapshot()); + setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot()); + }; + + refresh(); + const intervalId = window.setInterval(refresh, 500); + return () => window.clearInterval(intervalId); + }, []); + + React.useEffect(() => { + if (copyState === 'idle') { + return; + } + + const timeoutId = window.setTimeout(() => { + setCopyState('idle'); + }, 1500); + + return () => window.clearTimeout(timeoutId); + }, [copyState]); const totalMessages = React.useMemo(() => { let total = 0; - messages.forEach((sessionMessages) => { - total += sessionMessages.length; - }); + for (const sessionId of Object.keys(messageRecord)) { + total += messageRecord[sessionId]?.length ?? 0; + } return total; - }, [messages]); + }, [messageRecord]); const sessionStats = React.useMemo(() => { return sessions.map(session => { - const messageCount = messages.get(session.id)?.length || 0; + const messageCount = messageRecord[session.id]?.length || 0; const memoryState = sessionMemoryState.get(session.id); return { id: session.id, @@ -44,120 +169,209 @@ export const MemoryDebugPanel: React.FC = ({ onClose }) = isCurrent: session.id === currentSessionId }; }).sort((a, b) => b.lastAccessed - a.lastAccessed); - }, [sessions, messages, sessionMemoryState, currentSessionId]); + }, [sessions, messageRecord, sessionMemoryState, currentSessionId]); - const cachedSessionCount = messages.size; + const cachedSessionCount = Object.keys(messageRecord).length; + + const handleCopyStreamingDebug = React.useCallback(async () => { + try { + const payload = { + generatedAt: new Date().toISOString(), + ui: getStreamPerfSnapshot(), + vscode: getVsCodeStreamPerfSnapshot(), + }; + await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + setCopyState('copied'); + } catch { + setCopyState('error'); + } + }, []); return ( - -
+ +
- -

Memory Debug Panel

+ {activeTab === 'memory' ? ( + + ) : ( + + )} +

Debug Panel

- {onClose && ( - - )} -
- -
- {} -
-
-
Total Messages
-
{totalMessages}
-
-
-
Cached Sessions
-
{cachedSessionCount} / {MEMORY_LIMITS.MAX_SESSIONS}
-
-
- - {null} - - {} -
-
- Viewport Window: - {getBackgroundTrimLimit()} messages -
-
- Zombie Timeout: - {MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes -
-
- GitHub Total Requests: - {totalGitHubRequests} -
-
- - {} -
-
Sessions in Memory:
- - {sessionStats.map(stat => ( -
-
- {stat.title} - {stat.isStreaming && ( - - )} - {stat.isZombie && ( - ! - )} -
-
- getMessageLimit() ? 'text-status-warning' : '' - }`}> - {stat.messageCount} msgs - - {stat.backgroundCount > 0 && ( - +{stat.backgroundCount} - )} -
-
- ))} -
-
- -
- - +
+ {activeTab === 'streaming' ? ( + <> + - - - Log current memory state to browser console - - + + ) : null} + {onClose ? ( + + ) : null}
+ +
+ + +
+ + {activeTab === 'memory' ? ( +
+
+ + +
+ +
+
+ Viewport Window + {getBackgroundTrimLimit()} messages +
+
+ Zombie Timeout + {MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes +
+
+ GitHub Total Requests + {totalGitHubRequests} +
+
+ +
+
Sessions in Memory
+ + {sessionStats.map(stat => ( +
+
+ {stat.title} + {stat.isStreaming ? : null} + {stat.isZombie ? ! : null} +
+
+ + {stat.messageCount} msgs + + {stat.backgroundCount > 0 ? ( + +{stat.backgroundCount} + ) : null} +
+
+ ))} +
+
+ +
+ + + + + Log current memory state to browser console + +
+
+ ) : ( +
+
+ + {copyState === 'copied' + ? 'Streaming debug JSON copied' + : copyState === 'error' + ? 'Failed to copy JSON' + : 'Copy exports both UI and VS Code streaming metrics as JSON'} + + +
+ +
+ + + + + + + + +
+ + + + {vscodeStreamSnapshot.entries.length > 0 ? ( + + ) : null} +
+ )} ); }; + +export const MemoryDebugPanel = DebugPanel; diff --git a/packages/ui/src/components/ui/OpenChamberLogo.tsx b/packages/ui/src/components/ui/OpenChamberLogo.tsx index 60e9f755..5c8d3063 100644 --- a/packages/ui/src/components/ui/OpenChamberLogo.tsx +++ b/packages/ui/src/components/ui/OpenChamberLogo.tsx @@ -192,6 +192,23 @@ export const OpenChamberLogo: React.FC = ({ role="img" aria-label="OpenChamber logo" > + + {/* Left face - base fill */} = ({ /> {/* OpenCode logo on top face */} - - {isAnimated && ( - - )} + {/* Isometric transform for top face: OpenCode logo (32x40 viewBox) centered and projected to isometric plane diff --git a/packages/ui/src/components/views/ChatView.tsx b/packages/ui/src/components/views/ChatView.tsx index 88538653..bf2e7eb7 100644 --- a/packages/ui/src/components/views/ChatView.tsx +++ b/packages/ui/src/components/views/ChatView.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; export const ChatView: React.FC = () => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return ( diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 162f44c0..f1a4ad8b 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -31,6 +31,7 @@ import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib // Minimum width for side-by-side diff view (px) const SIDE_BY_SIDE_MIN_WIDTH = 1100; const DIFF_REQUEST_TIMEOUT_MS = 15000; +const LARGE_DIFF_CHANGED_LINES = 500; // Perf: limit concurrent expanded diffs in stacked view. // Expanding many diffs mounts many Pierre instances + lots of DOM. @@ -638,6 +639,7 @@ const MultiFileDiffEntry = React.memo(({ const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); const [diffLoadError, setDiffLoadError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); + const [forceRenderLarge, setForceRenderLarge] = React.useState(false); const lastDiffRequestRef = React.useRef(null); const sectionRef = React.useRef(null); @@ -881,7 +883,24 @@ const MultiFileDiffEntry = React.memo(({ Loading diff…
) : null} - {diffData ? ( + {diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? ( +
+
+ Large diff ({file.insertions + file.deletions} changed lines) +
+
+ Rendering may be slow. You can still view the diff by clicking below. +
+ +
+ ) : null} + {diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? ( = ({ const isGitRepo = useIsGitRepo(effectiveDirectory ?? null); const status = useGitStatus(effectiveDirectory ?? null); const isLoadingStatus = useGitStore((state) => state.isLoadingStatus); - const { setActiveDirectory, fetchStatus, setDiff } = useGitStore(); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const fetchStatus = useGitStore((state) => state.fetchStatus); + const setDiff = useGitStore((state) => state.setDiff); const [selectedFile, setSelectedFile] = React.useState(null); const [stackedExpandTarget, setStackedExpandTarget] = React.useState(null); @@ -1722,7 +1743,8 @@ export const useDiffFileCount = (): number => { const { git } = useRuntimeAPIs(); const effectiveDirectory = useEffectiveDirectory(); - const { setActiveDirectory, fetchStatus } = useGitStore(); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const fetchStatus = useGitStore((state) => state.fetchStatus); const fileCount = useGitFileCount(effectiveDirectory ?? null); React.useEffect(() => { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index f202c115..ba5b2ed3 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; @@ -225,13 +225,11 @@ export const GitView: React.FC = () => { const currentDirectory = useEffectiveDirectory(); const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null); const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false); - const { - currentSessionId, - worktreeMetadata: worktreeMap, - availableWorktrees, - newSessionDraft, - setDraftBootstrapPendingDirectory, - } = useSessionStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); + const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); + const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees); const normalizedCurrentDirectory = normalizePath(currentDirectory); const inferredWorktreeMetadata = React.useMemo(() => { if (!normalizedCurrentDirectory) { @@ -276,16 +274,14 @@ export const GitView: React.FC = () => { const currentIdentity = useGitIdentity(currentDirectory ?? null); const isLoading = useGitStore((state) => state.isLoadingStatus); const isLogLoading = useGitStore((state) => state.isLoadingLog); - const { - setActiveDirectory, - fetchAll, - fetchStatus, - fetchBranches, - fetchLog, - fetchIdentity, - prefetchDiffs, - setLogMaxCount, - } = useGitStore(); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const fetchAll = useGitStore((state) => state.fetchAll); + const fetchStatus = useGitStore((state) => state.fetchStatus); + const fetchBranches = useGitStore((state) => state.fetchBranches); + const fetchLog = useGitStore((state) => state.fetchLog); + const fetchIdentity = useGitStore((state) => state.fetchIdentity); + const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); + const setLogMaxCount = useGitStore((state) => state.setLogMaxCount); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); const navigateToDiff = useUIStore((state) => state.navigateToDiff); diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index d456ebdf..6191711c 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -29,6 +29,9 @@ import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; +// Threshold (bytes) above which syntax highlighting is degraded for performance +const LARGE_CONTENT_BYTES = 500_000; + interface PierreDiffViewerProps { original: string; modified: string; @@ -439,6 +442,11 @@ export const PierreDiffViewer: React.FC = ({ }, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]); + const isLargeContent = useMemo(() => + Math.max(original.length, modified.length) > LARGE_CONTENT_BYTES, + [original.length, modified.length], + ); + const options = useMemo(() => ({ theme: { dark: darkTheme.metadata.id, @@ -450,8 +458,9 @@ export const PierreDiffViewer: React.FC = ({ hunkSeparators: 'line-info-basic' as const, // Perf: disable intra-line diff (word-level) globally. lineDiffType: 'none' as const, - maxLineDiffLength: 1000, - maxLineLengthForHighlighting: 1000, + // Perf: degrade tokenization/highlighting for large files (>500KB) + maxLineDiffLength: isLargeContent ? 0 : 1000, + maxLineLengthForHighlighting: isLargeContent ? 1 : 1000, expansionLineCount: 20, overflow: wrapLines ? ('wrap' as const) : ('scroll' as const), disableFileHeader: true, @@ -460,7 +469,7 @@ export const PierreDiffViewer: React.FC = ({ onLineSelected: handleSelectionChange, unsafeCSS: WEBKIT_SCROLL_FIX_CSS, renderAnnotation, - }), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]); + }), [darkTheme.metadata.id, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]); const lineAnnotations = useMemo(() => { diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 29799f34..4ab7e4af 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -15,7 +15,8 @@ import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme'; import { languageByExtension } from '@/lib/codemirror/languageByExtension'; import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { EditorView } from '@codemirror/view'; @@ -80,8 +81,8 @@ type SelectedLineRange = { }; export const PlanView: React.FC = () => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const runtimeApis = useRuntimeAPIs(); useUIStore(); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 90bcb5dc..b64727f5 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { type TerminalStreamEvent } from '@/lib/api/types'; @@ -93,7 +93,8 @@ export const TerminalView: React.FC = () => { const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop); const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop; - const { currentSessionId, newSessionDraft } = useSessionStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true; const effectiveDirectory = useEffectiveDirectory() ?? null; diff --git a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx index 1dcd2853..db5012f8 100644 --- a/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentGroupDetail.tsx @@ -5,6 +5,7 @@ import { RiCheckLine, RiMore2Line, RiFileCopyLine, + RiLoader4Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; @@ -12,7 +13,8 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useGlobalSessionStatus, useAllSessionStatuses } from '@/sync/sync-context'; import { ChatContainer } from '@/components/chat/ChatContainer'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; import { @@ -35,53 +37,57 @@ interface AgentGroupDetailProps { className?: string; } +const SessionStatusDot: React.FC<{ sessionId: string }> = ({ sessionId }) => { + const status = useGlobalSessionStatus(sessionId); + if (!status || status.type === 'idle') return null; + return ( + + + + + ); +}; + export const AgentGroupDetail: React.FC = ({ group, className, }) => { - const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore(); - const { setCurrentSession, currentSessionId } = useSessionStore(); + const selectedSessionId = useAgentGroupsStore((s) => s.selectedSessionId); + const selectSession = useAgentGroupsStore((s) => s.selectSession); + const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions); + const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const [worktreeDialog, setWorktreeDialog] = React.useState(null); const [isProcessing, setIsProcessing] = React.useState(false); - - // Find the currently selected session + const selectedSession = React.useMemo(() => { if (!selectedSessionId) return group.sessions[0] ?? null; return group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] ?? null; }, [group.sessions, selectedSessionId]); - - // When selecting a session, switch to that OpenCode session - // NOTE: We intentionally do NOT change the global directory here to avoid - // re-triggering loadGroups() which would cause groups to disappear + const handleSessionSelect = React.useCallback((session: AgentGroupSession) => { selectSession(session.id); - - // Switch to the OpenCode session - setCurrentSession(session.id); + setCurrentSession(session.id, session.path); }, [selectSession, setCurrentSession]); - + // Auto-select first session when group changes and sync OpenCode session React.useEffect(() => { if (group.sessions.length > 0) { - const session = selectedSessionId + const session = selectedSessionId ? group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] : group.sessions[0]; - - if (session) { - // Always ensure the OpenCode session is synced - if (session.id !== currentSessionId) { - setCurrentSession(session.id); - } - - // Update selection if not already selected - if (!selectedSessionId) { - selectSession(session.id); + + if (session) { + if (session.id !== currentSessionId) { + setCurrentSession(session.id, session.path); + } + if (!selectedSessionId) { + selectSession(session.id); } } } }, [group.name, group.sessions, selectedSessionId, currentSessionId, selectSession, setCurrentSession]); - // Check if the current OpenCode session matches the selected agent group session const isSessionSynced = selectedSession?.id === currentSessionId; const handleCopyWorktreePath = React.useCallback(() => { @@ -98,12 +104,12 @@ export const AgentGroupDetail: React.FC = ({ }); }, [selectedSession?.path]); - const handleRemoveSelectedWorktree = React.useCallback(async () => { + const handleRemoveSelectedWorktree = React.useCallback(() => { if (!selectedSession) return; setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel }); }, [selectedSession]); - const handleKeepOnlySelectedWorktree = React.useCallback(async () => { + const handleKeepOnlySelectedWorktree = React.useCallback(() => { if (!selectedSession) return; setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel }); }, [selectedSession]); @@ -112,32 +118,36 @@ export const AgentGroupDetail: React.FC = ({ if (!worktreeDialog || isProcessing) return; setIsProcessing(true); try { + const normalize = (v: string) => v.replace(/\\/g, '/').replace(/\/+$/, '') || v; + const targetPath = normalize(worktreeDialog.path); + let sessionsToDelete: AgentGroupSession[]; + if (worktreeDialog.kind === 'remove') { toast.info('Removing worktree...'); - const ok = await deleteGroupWorktree(group.name, worktreeDialog.path); - if (ok) { - toast.success('Worktree removed'); - } else { - const error = useAgentGroupsStore.getState().error; - toast.error(error || 'Failed to remove worktree'); - return; - } + sessionsToDelete = group.sessions.filter((s) => normalize(s.path) === targetPath); } else { toast.info('Removing other worktrees...'); - const ok = await keepOnlyGroupWorktree(group.name, worktreeDialog.path); - if (ok) { - toast.success('Removed other worktrees'); - } else { - const error = useAgentGroupsStore.getState().error; - toast.error(error || 'Failed to remove other worktrees'); - return; - } + sessionsToDelete = group.sessions.filter((s) => normalize(s.path) !== targetPath); + } + + const { failedIds, failedWorktreePaths } = await deleteGroupSessions(sessionsToDelete, { removeWorktrees: true }); + if (failedIds.length > 0 || failedWorktreePaths.length > 0) { + toast.error('Failed to fully remove worktree'); + } else { + toast.success(worktreeDialog.kind === 'remove' ? 'Worktree removed' : 'Removed other worktrees'); } setWorktreeDialog(null); } finally { setIsProcessing(false); } - }, [deleteGroupWorktree, group.name, isProcessing, keepOnlyGroupWorktree, worktreeDialog]); + }, [deleteGroupSessions, group.sessions, isProcessing, worktreeDialog]); + + // Group-level status: show if any session is busy + const allStatuses = useAllSessionStatuses(); + const groupBusy = React.useMemo( + () => group.sessions.some((s) => allStatuses[s.id]?.type === 'busy'), + [group.sessions, allStatuses], + ); return (
@@ -145,7 +155,10 @@ export const AgentGroupDetail: React.FC = ({
-

{group.name}

+
+

{group.name}

+ {groupBusy && } +
{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''} · @@ -156,7 +169,7 @@ export const AgentGroupDetail: React.FC = ({
- + {/* Model Selector Dropdown */} {group.sessions.length > 0 && (
@@ -170,9 +183,9 @@ export const AgentGroupDetail: React.FC = ({
{selectedSession && ( <> - {selectedSession.modelId} @@ -182,6 +195,7 @@ export const AgentGroupDetail: React.FC = ({ #{selectedSession.instanceNumber} )} + )}
@@ -195,9 +209,9 @@ export const AgentGroupDetail: React.FC = ({ onClick={() => handleSessionSelect(session)} className="flex items-center gap-2 py-2" > -
@@ -209,6 +223,7 @@ export const AgentGroupDetail: React.FC = ({ #{session.instanceNumber} )} +
{session.branch && (
@@ -236,7 +251,7 @@ export const AgentGroupDetail: React.FC = ({ { e.preventDefault(); - void handleRemoveSelectedWorktree(); + handleRemoveSelectedWorktree(); }} variant="destructive" > @@ -245,7 +260,7 @@ export const AgentGroupDetail: React.FC = ({ { e.preventDefault(); - void handleKeepOnlySelectedWorktree(); + handleKeepOnlySelectedWorktree(); }} > Leave this one, remove others @@ -292,7 +307,7 @@ export const AgentGroupDetail: React.FC = ({ - + {/* Chat Content */}
{selectedSession ? ( @@ -302,7 +317,6 @@ export const AgentGroupDetail: React.FC = ({ ) : (
- {/* Info banner about the worktree */}
@@ -315,8 +329,6 @@ export const AgentGroupDetail: React.FC = ({
- - {/* Loading or no session state */}

diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx index 5ed1f5ee..25ff2a3d 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx @@ -5,6 +5,7 @@ import { RiMore2Line, RiSearchLine, RiGitBranchLine, + RiLoader4Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Input } from '@/components/ui/input'; @@ -26,16 +27,16 @@ import { } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useAllSessionStatuses } from '@/sync/sync-context'; const formatRelativeTime = (timestamp: number): string => { const now = Date.now(); const diff = now - timestamp; - + const minutes = Math.floor(diff / (60 * 1000)); const hours = Math.floor(diff / (60 * 60 * 1000)); const days = Math.floor(diff / (24 * 60 * 60 * 1000)); - + if (minutes < 1) return 'now'; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; @@ -45,30 +46,30 @@ const formatRelativeTime = (timestamp: number): string => { interface AgentGroupItemProps { group: AgentGroup; isSelected: boolean; + isBusy: boolean; onSelect: () => void; } -const AgentGroupItem: React.FC = ({ group, isSelected, onSelect }) => { +const AgentGroupItem: React.FC = ({ group, isSelected, isBusy, onSelect }) => { const [menuOpen, setMenuOpen] = React.useState(false); const [confirmOpen, setConfirmOpen] = React.useState(false); const [isDeleting, setIsDeleting] = React.useState(false); - const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup); + const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions); const handleDeleteGroup = React.useCallback(async () => { if (isDeleting) return; setIsDeleting(true); toast.info(`Deleting "${group.name}"...`); - const ok = await deleteGroup(group.name); - if (ok) { + const { failedIds, failedWorktreePaths } = await deleteGroupSessions(group.sessions, { removeWorktrees: true }); + if (failedIds.length === 0 && failedWorktreePaths.length === 0) { toast.success(`Deleted "${group.name}"`); } else { - const error = useAgentGroupsStore.getState().error; - toast.error(error || `Failed to delete "${group.name}"`); + toast.error(`Failed to fully delete "${group.name}"`); } setIsDeleting(false); setConfirmOpen(false); - }, [deleteGroup, group.name, isDeleting]); - + }, [deleteGroupSessions, group.name, group.sessions, isDeleting]); + return ( <>

= ({ group, isSelected, onSe type="button" className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" > - - {group.name} - +
+ + {group.name} + + {isBusy && } +
@@ -96,7 +100,7 @@ const AgentGroupItem: React.FC = ({ group, isSelected, onSe
- +
@@ -154,6 +158,7 @@ const AgentGroupItem: React.FC = ({ group, isSelected, onSe interface AgentManagerSidebarProps { className?: string; + groups: AgentGroup[]; selectedGroupName?: string | null; onGroupSelect?: (groupName: string) => void; onNewAgent?: () => void; @@ -161,36 +166,40 @@ interface AgentManagerSidebarProps { export const AgentManagerSidebar: React.FC = ({ className, + groups, selectedGroupName, onGroupSelect, onNewAgent, }) => { const [searchQuery, setSearchQuery] = React.useState(''); const [showAll, setShowAll] = React.useState(false); - - const { groups, isLoading, loadGroups } = useAgentGroupsStore(); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - - // Load groups when directory changes - React.useEffect(() => { - if (currentDirectory) { - loadGroups(); + const isLoading = useAgentGroupsStore((s) => s.isLoading); + + // Session statuses for busy indicators + const allStatuses = useAllSessionStatuses(); + const busyGroups = React.useMemo(() => { + const set = new Set(); + for (const group of groups) { + if (group.sessions.some((s) => allStatuses[s.id]?.type === 'busy')) { + set.add(group.name); + } } - }, [currentDirectory, loadGroups]); - + return set; + }, [groups, allStatuses]); + const MAX_VISIBLE = 5; - + const filteredGroups = React.useMemo(() => { if (!searchQuery.trim()) return groups; const query = searchQuery.toLowerCase(); - return groups.filter(group => + return groups.filter(group => group.name.toLowerCase().includes(query) ); }, [searchQuery, groups]); - + const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE); const remainingCount = filteredGroups.length - MAX_VISIBLE; - + return (
{/* Search Input */} @@ -205,7 +214,7 @@ export const AgentManagerSidebar: React.FC = ({ />
- + {/* New Agent Button */}
- + {/* Agent Groups Section Header */}
@@ -230,7 +239,7 @@ export const AgentManagerSidebar: React.FC = ({ )}
- + {/* Group List */} = ({ key={group.name} group={group} isSelected={selectedGroupName === group.name} + isBusy={busyGroups.has(group.name)} onSelect={() => onGroupSelect?.(group.name)} /> ))} - - {/* Show More Link */} + {!showAll && remainingCount > 0 && ( )} - - {/* Show Less Link */} + {showAll && filteredGroups.length > MAX_VISIBLE && ( )} - - {/* Empty State */} + {!isLoading && filteredGroups.length === 0 && (

diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx index 077d0875..c8f49502 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerView.tsx @@ -6,10 +6,8 @@ import { AgentGroupDetail } from './AgentGroupDetail'; import { cn } from '@/lib/utils'; import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore'; import { useMultiRunStore } from '@/stores/useMultiRunStore'; -import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import type { CreateMultiRunParams } from '@/types/multirun'; interface AgentManagerViewProps { @@ -30,37 +28,37 @@ export const AgentManagerView: React.FC = ({ className }) 'connecting' | 'connected' | 'error' | 'disconnected' | undefined : 'connecting') || 'connecting' ); - const configInitialized = useConfigStore((state) => state.isInitialized); - const initializeApp = useConfigStore((state) => state.initializeApp); - const loadSessions = useSessionStore((state) => state.loadSessions); - const setDirectory = useDirectoryStore((state) => state.setDirectory); + const configInitialized = useConfigStore((s) => s.isInitialized); + const initializeApp = useConfigStore((s) => s.initializeApp); + const setDirectory = useDirectoryStore((s) => s.setDirectory); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const bootstrapAttemptAt = React.useRef(0); - const { - selectedGroupName, - selectGroup, - getSelectedGroup, - loadGroups, - } = useAgentGroupsStore(); + const groups = useAgentGroupsStore((s) => s.groups); + const selectedGroupName = useAgentGroupsStore((s) => s.selectedGroupName); + const selectGroup = useAgentGroupsStore((s) => s.selectGroup); + const loadGroups = useAgentGroupsStore((s) => s.loadGroups); - const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore(); + const createMultiRun = useMultiRunStore((s) => s.createMultiRun); + const isCreatingMultiRun = useMultiRunStore((s) => s.isLoading); + const selectedGroup = React.useMemo( + () => (selectedGroupName ? groups.find((g) => g.name === selectedGroupName) ?? null : null), + [groups, selectedGroupName], + ); + + // VS Code connection bootstrap React.useEffect(() => { - if (!isVSCodeRuntime) { - return; - } + if (!isVSCodeRuntime) return; const current = (typeof window !== 'undefined' ? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status : undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined; - if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') { - setConnectionStatus(current); - } + if (current) setConnectionStatus(current); const handler = (event: Event) => { - const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail; - const status = detail?.status; + const status = (event as CustomEvent<{ status?: string }>).detail?.status; if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') { setConnectionStatus(status); } @@ -70,14 +68,9 @@ export const AgentManagerView: React.FC = ({ className }) }, [isVSCodeRuntime]); React.useEffect(() => { - if (!isVSCodeRuntime || connectionStatus !== 'connected') { - return; - } - + if (!isVSCodeRuntime || connectionStatus !== 'connected') return; const now = Date.now(); - if (now - bootstrapAttemptAt.current < 750) { - return; - } + if (now - bootstrapAttemptAt.current < 750) return; bootstrapAttemptAt.current = now; const workspaceFolder = (typeof window !== 'undefined' @@ -85,52 +78,22 @@ export const AgentManagerView: React.FC = ({ className }) : null); if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) { - try { - setDirectory(workspaceFolder, { showOverlay: false }); - } catch { - // ignored - } + try { setDirectory(workspaceFolder, { showOverlay: false }); } catch { /* ignored */ } } - const runBootstrap = async () => { - try { - if (!configInitialized) { - await initializeApp(); - } + if (!configInitialized) void initializeApp(); + }, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, setDirectory]); - const configState = useConfigStore.getState(); - if ( - !configState.isInitialized || - !configState.isConnected || - configState.providers.length === 0 || - configState.agents.length === 0 - ) { - return; - } - - await loadSessions(); - - if (streamDebugEnabled()) { - console.log('[OpenChamber][VSCode][agentManager] bootstrap complete', { - providers: configState.providers.length, - agents: configState.agents.length, - sessions: useSessionStore.getState().sessions.length, - }); - } - } catch { - // ignored - } - }; - - void runBootstrap(); - }, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, loadSessions, setDirectory]); + // Load groups on mount and when directory changes + React.useEffect(() => { + void loadGroups(); + }, [currentDirectory, loadGroups]); const handleGroupSelect = React.useCallback((groupName: string) => { selectGroup(groupName); }, [selectGroup]); const handleNewAgent = React.useCallback(() => { - // Clear selection to show the empty state / new agent form selectGroup(null); }, [selectGroup]); @@ -141,54 +104,30 @@ export const AgentManagerView: React.FC = ({ className }) if (result) { toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`); - const groupSlug = result.groupSlug; - - const waitForGroup = async (attempts = 6) => { - for (let attempt = 0; attempt < attempts; attempt += 1) { - await loadGroups(); - const groupsState = useAgentGroupsStore.getState(); - if (groupsState.groups.some((group) => group.name === groupSlug)) { - return true; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - return false; - }; - - // Refresh sessions + groups and wait briefly for OpenCode to surface the new worktree sessions. - try { - await useSessionStore.getState().loadSessions(); - } catch { - // ignore - } - - await waitForGroup(); - selectGroup(groupSlug); + // Refresh groups — new worktrees + sessions now exist + await loadGroups(); + selectGroup(result.groupSlug); } else { const error = useMultiRunStore.getState().error; toast.error(error || 'Failed to create agent group'); } }, [createMultiRun, loadGroups, selectGroup]); - const selectedGroup = getSelectedGroup(); - return (

- {/* Left Sidebar - Agent Groups List */}
- - {/* Main Content Area */}
{selectedGroup ? ( ) : ( - diff --git a/packages/ui/src/components/views/git/ConflictDialog.tsx b/packages/ui/src/components/views/git/ConflictDialog.tsx index 2e6db823..7354ec58 100644 --- a/packages/ui/src/components/views/git/ConflictDialog.tsx +++ b/packages/ui/src/components/views/git/ConflictDialog.tsx @@ -9,7 +9,8 @@ import { import { Button } from '@/components/ui/button'; import { RiAlertLine, RiLoader4Line, RiChat1Line, RiAddLine } from '@remixicon/react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { toast } from '@/components/ui'; import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi'; @@ -33,10 +34,10 @@ export const ConflictDialog: React.FC = ({ onAbort, onClearState, }) => { - const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const setPendingInputText = useSessionStore((state) => state.setPendingInputText); - const setPendingSyntheticParts = useSessionStore((state) => state.setPendingSyntheticParts); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setPendingInputText = useInputStore((state) => state.setPendingInputText); + const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const [isLoading, setIsLoading] = React.useState(false); diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index 14090745..716c7e12 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -15,7 +15,8 @@ import { CommandList, } from '@/components/ui/command'; import { toast } from '@/components/ui'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { execCommand } from '@/lib/execCommands'; import { @@ -55,7 +56,7 @@ export const IntegrateCommitsSection: React.FC<{ refreshKey, onRefresh, }) => { - const currentSessionId = useSessionStore((s) => s.currentSessionId); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); const searchInputRef = React.useRef(null); @@ -167,7 +168,7 @@ export const IntegrateCommitsSection: React.FC<{ const persistTarget = React.useCallback( (branch: string) => { if (!currentSessionId) return; - useSessionStore.getState().setWorktreeMetadata(currentSessionId, { + useSessionUIStore.getState().setWorktreeMetadata(currentSessionId, { ...worktreeMetadata, createdFromBranch: branch, }); @@ -175,7 +176,7 @@ export const IntegrateCommitsSection: React.FC<{ [currentSessionId, worktreeMetadata] ); - const openNewSessionDraft = useSessionStore((s) => s.openNewSessionDraft); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const buildConflictContext = React.useCallback((payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => { const visibleText = `Resolve cherry-pick conflicts, stage the resolved files, and continue the cherry-pick. Keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}.`; @@ -217,8 +218,8 @@ Important: return { visibleText, instructionsText, payloadText }; }, []); - const setPendingInputText = useSessionStore((s) => s.setPendingInputText); - const setPendingSyntheticParts = useSessionStore((s) => s.setPendingSyntheticParts); + const setPendingInputText = useInputStore((s) => s.setPendingInputText); + const setPendingSyntheticParts = useInputStore((s) => s.setPendingSyntheticParts); const handleResolveWithAi = React.useCallback(( payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }, diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index a3fd649b..e82c30b4 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -51,8 +51,8 @@ import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { useUIStore } from '@/stores/useUIStore'; -import { useMessageStore } from '@/stores/messageStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; @@ -284,7 +284,7 @@ export const PullRequestSection: React.FC<{ const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const { isMobile, hasTouchInput } = useDeviceInfo(); const openGitHubSettings = React.useCallback(() => { @@ -633,7 +633,7 @@ export const PullRequestSection: React.FC<{ } const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); - const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; const providerID = currentProviderId || lastUsedProvider?.providerID; const modelID = currentModelId || lastUsedProvider?.modelID; if (!providerID || !modelID) { @@ -656,14 +656,13 @@ export const PullRequestSection: React.FC<{ instructionsText: string, payloadText: string, ) => { - void useMessageStore.getState().sendMessage( + void useSessionUIStore.getState().sendMessage( visibleText, target.providerID, target.modelID, target.currentAgentName ?? undefined, - target.sessionId, undefined, - null, + undefined, [ { text: instructionsText, synthetic: true }, { text: payloadText, synthetic: true }, diff --git a/packages/ui/src/contexts/RuntimeAPIProvider.tsx b/packages/ui/src/contexts/RuntimeAPIProvider.tsx index 85b47868..8badbaba 100644 --- a/packages/ui/src/contexts/RuntimeAPIProvider.tsx +++ b/packages/ui/src/contexts/RuntimeAPIProvider.tsx @@ -1,7 +1,84 @@ import React, { type JSX, type ReactNode } from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import type { RuntimeAPIs } from '@/lib/api/types'; +import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types'; +import { + approxStringBytes, + evictContentLru, + setContentBytes, + touchContent as touchContentLru, + removeContentBytes, +} from '@/sync/content-cache'; + +/** Wrap a FilesAPI with an in-memory LRU content cache. */ +function withContentCache(files: FilesAPI): FilesAPI { + const cache = new Map(); + + const cachedReadFile: FilesAPI['readFile'] = files.readFile + ? async (path: string) => { + const hit = cache.get(path); + if (hit) { + touchContentLru(path); + return hit; + } + + const result = await files.readFile!(path); + const bytes = approxStringBytes(result.content); + cache.set(path, result); + setContentBytes(path, bytes); + + // Evict if over limits + const keep = new Set(); + evictContentLru(keep, (evictPath) => { + cache.delete(evictPath); + }); + + return result; + } + : undefined; + + // Invalidate cache on writes, deletes, renames + const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile + ? async (path, content) => { + cache.delete(path); + removeContentBytes(path); + return files.writeFile!(path, content); + } + : undefined; + + const cachedDelete: FilesAPI['delete'] = files.delete + ? async (path) => { + cache.delete(path); + removeContentBytes(path); + return files.delete!(path); + } + : undefined; + + const cachedRename: FilesAPI['rename'] = files.rename + ? async (oldPath, newPath) => { + cache.delete(oldPath); + removeContentBytes(oldPath); + cache.delete(newPath); + removeContentBytes(newPath); + return files.rename!(oldPath, newPath); + } + : undefined; + + return { + ...files, + readFile: cachedReadFile, + writeFile: cachedWriteFile, + delete: cachedDelete, + rename: cachedRename, + }; +} export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element { - return {children}; + const cachedApis = React.useMemo( + () => ({ + ...apis, + files: withContentCache(apis.files), + }), + [apis], + ); + return {children}; } diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index 303587c4..53234568 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -1,9 +1,9 @@ import React from 'react'; import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; -import { useShallow } from 'zustand/react/shallow'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useDirectorySync, useSessionPermissions, useSessionStatus } from '@/sync/sync-context'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { useCurrentSessionActivity } from './useSessionActivity'; @@ -52,6 +52,11 @@ interface AssistantSessionMessageRecord { parts: Part[]; } +type SessionMessageRecord = { + info: Message; + parts: Part[]; +}; + const DEFAULT_WORKING: WorkingSummary = { activity: 'idle', hasWorkingContext: false, @@ -74,6 +79,9 @@ const DEFAULT_WORKING: WorkingSummary = { retryInfo: null, }; +const EMPTY_MESSAGES: Message[] = []; +const EMPTY_PARTS: Part[] = []; +const EMPTY_SESSION_MESSAGES: SessionMessageRecord[] = []; const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant'; const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning'; @@ -114,36 +122,68 @@ const getToolDisplayName = (part: ToolPart): string => { }; export function useAssistantStatus(): AssistantStatusSnapshot { - const { currentSessionId, messages, permissions, sessionAbortFlags } = useSessionStore( - useShallow((state) => ({ - currentSessionId: state.currentSessionId, - messages: state.messages, - permissions: state.permissions, - sessionAbortFlags: state.sessionAbortFlags, - })) + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + + const rawSessionMessages = useDirectorySync( + React.useCallback((state) => { + if (!currentSessionId) { + return EMPTY_MESSAGES; + } + return state.message[currentSessionId] ?? EMPTY_MESSAGES; + }, [currentSessionId]) + ); + + // Only subscribe to parts for the last assistant message — avoids re-render + // on every part delta for earlier messages. + const lastAssistantId = React.useMemo(() => { + for (let i = rawSessionMessages.length - 1; i >= 0; i--) { + if (rawSessionMessages[i].role === 'assistant') return rawSessionMessages[i].id; + } + return null; + }, [rawSessionMessages]); + + const lastAssistantParts = useDirectorySync( + React.useCallback((state) => { + if (!lastAssistantId) return EMPTY_PARTS; + return state.part[lastAssistantId] ?? EMPTY_PARTS; + }, [lastAssistantId]) + ); + + const sessionMessages = React.useMemo( + () => { + if (rawSessionMessages.length === 0) { + return EMPTY_SESSION_MESSAGES; + } + return rawSessionMessages.map((msg) => ({ + info: msg, + parts: msg.id === lastAssistantId ? lastAssistantParts : EMPTY_PARTS, + })); + }, + [lastAssistantParts, rawSessionMessages, lastAssistantId] + ); + + const sessionPermissionRequests = useSessionPermissions(currentSessionId ?? ''); + + const sessionAbortRecord = useSessionUIStore( + React.useCallback((state) => { + if (!currentSessionId) { + return null; + } + return state.sessionAbortFlags?.get(currentSessionId) ?? null; + }, [currentSessionId]) ); const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity(); - const sessionRetryAttempt = useSessionStore((state) => { - if (!currentSessionId || !state.sessionStatus) return undefined; - const s = state.sessionStatus.get(currentSessionId); - return s?.type === 'retry' ? s.attempt : undefined; - }); + const currentSessionStatus = useSessionStatus(currentSessionId ?? ''); - const sessionRetryNext = useSessionStore((state) => { - if (!currentSessionId || !state.sessionStatus) return undefined; - const s = state.sessionStatus.get(currentSessionId); - return s?.type === 'retry' ? s.next : undefined; - }); + const sessionRetryAttempt = currentSessionStatus?.type === 'retry' + ? (currentSessionStatus as { type: 'retry'; attempt?: number }).attempt + : undefined; - const sessionMessages = React.useMemo>(() => { - if (!currentSessionId) { - return []; - } - const records = messages.get(currentSessionId) ?? []; - return records as Array<{ info: Message; parts: Part[] }>; - }, [currentSessionId, messages]); + const sessionRetryNext = currentSessionStatus?.type === 'retry' + ? (currentSessionStatus as { type: 'retry'; next?: number }).next + : undefined; type ParsedStatusResult = { activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined; @@ -287,11 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot { }, [sessionMessages]); const abortState = React.useMemo(() => { - const sessionId = currentSessionId; - const abortRecord = sessionId ? sessionAbortFlags?.get(sessionId) ?? null : null; - const hasActiveAbort = Boolean(abortRecord && !abortRecord.acknowledged); + const hasActiveAbort = Boolean(sessionAbortRecord && !sessionAbortRecord.acknowledged); return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort }; - }, [currentSessionId, sessionAbortFlags]); + }, [sessionAbortRecord]); const baseWorking = React.useMemo(() => { @@ -388,9 +426,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { return baseWorking; } - const sessionId = currentSessionId; - const permissionList = sessionId ? permissions?.get(sessionId) ?? [] : []; - const hasPendingPermission = permissionList.length > 0; + const hasPendingPermission = sessionPermissionRequests.length > 0; if (!hasPendingPermission) { return baseWorking; @@ -403,7 +439,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { canAbort: false, retryInfo: null, }; - }, [currentSessionId, permissions, baseWorking]); + }, [baseWorking, sessionPermissionRequests]); return { forming, diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index 3829582c..928b46db 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -27,7 +27,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { browserVoiceService } from '@/lib/voice/browserVoiceService'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useInputStore } from '@/sync/input-store'; +import { getSyncMessages, getSyncParts } from '@/sync/sync-refs'; import { useConfigStore } from '@/stores/useConfigStore'; import { useServerTTS } from './useServerTTS'; import { useSayTTS } from './useSayTTS'; @@ -120,18 +122,16 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { const isActiveRef = useRef(false); const processingMessageRef = useRef(false); const lastTranscriptRef = useRef(''); - const messagesRef = useRef }>>(new Map()); const pendingResumeOnVisibleRef = useRef(false); const pendingFinalTranscriptRef = useRef(''); const finalTranscriptTimerRef = useRef | null>(null); const deviceChangeRestartTimerRef = useRef | null>(null); // Store access - const currentSessionId = useSessionStore((s) => s.currentSessionId); - const sendMessage = useSessionStore((s) => s.sendMessage); - const setPendingInputText = useSessionStore((s) => s.setPendingInputText); - const messages = useSessionStore((s) => s.messages); - const createSession = useSessionStore((s) => s.createSession); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const sendMessage = useSessionUIStore((s) => s.sendMessage); + const setPendingInputText = useInputStore((s) => s.setPendingInputText); + const createSession = useSessionUIStore((s) => s.createSession); const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore(); const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai'; @@ -147,16 +147,6 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { enabled: shouldCheckSayAvailability, }); - // Update messages ref when messages change - useEffect(() => { - if (currentSessionId) { - const sessionMessages = messages.get(currentSessionId); - if (sessionMessages) { - messagesRef.current = new Map(sessionMessages.map(m => [m.info.id, m])); - } - } - }, [messages, currentSessionId]); - // Stop voice when session changes to prevent microphone from staying active // This ensures voice mode doesn't carry over between sessions const prevSessionIdRef = useRef(null); @@ -374,22 +364,23 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { // Wait for AI response and speak it // We'll poll for new assistant messages const checkForResponse = async () => { - if (!isActiveRef.current) return; - - const sessionMessages = messagesRef.current; - const assistantMessages = Array.from(sessionMessages.values()) - .filter(m => m.info.role === 'assistant') + if (!isActiveRef.current || !sessionId) return; + + const rawMessages = getSyncMessages(sessionId); + const assistantMessages = rawMessages + .filter(m => m.role === 'assistant') .sort((a, b) => { - const aTime = (a.info as { time?: { created?: number } }).time?.created ?? 0; - const bTime = (b.info as { time?: { created?: number } }).time?.created ?? 0; + const aTime = (a as { time?: { created?: number } }).time?.created ?? 0; + const bTime = (b as { time?: { created?: number } }).time?.created ?? 0; return bTime - aTime; }); - + if (assistantMessages.length > 0) { const latestMessage = assistantMessages[0]; - const textParts = latestMessage.parts - .filter(p => p.type === 'text') - .map(p => p.text) + const parts = getSyncParts(latestMessage.id); + const textParts = parts + .filter((p: { type: string; text?: string }) => p.type === 'text') + .map((p: { type: string; text?: string }) => p.text ?? '') .join(' '); if (textParts.trim()) { diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index d300492b..a6830b06 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -11,8 +11,6 @@ import { import { useScrollEngine } from './useScrollEngine'; -const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; - export type ContentChangeReason = 'text' | 'structural' | 'permission'; interface ChatMessageRecord { @@ -41,7 +39,6 @@ interface UseChatScrollManagerOptions { isSyncing: boolean; isMobile: boolean; chatRenderMode?: 'sorted' | 'live'; - messageStreamStates: Map; onActiveTurnChange?: (turnId: string | null) => void; } @@ -110,6 +107,7 @@ export const useChatScrollManager = ({ const lastScrollTopRef = React.useRef(0); const touchLastYRef = React.useRef(null); const pinnedSyncRafRef = React.useRef(null); + const preferInstantPinRef = React.useRef(false); const viewportAnchorTimerRef = React.useRef | null>(null); const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); @@ -178,10 +176,20 @@ export const useChatScrollManager = ({ } const distanceFromBottom = getDistanceFromBottom(); + if (distanceFromBottom <= getAutoFollowThreshold()) { + preferInstantPinRef.current = false; + return; + } + + if (preferInstantPinRef.current) { + scrollToBottomInternal({ instant: true }); + return; + } + if (distanceFromBottom > getAutoFollowThreshold()) { scrollPinnedToBottom(); } - }, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]); + }, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, updateScrollButtonVisibility]); const schedulePinnedStateAndIndicators = React.useCallback(() => { if (typeof window === 'undefined') { @@ -264,6 +272,7 @@ export const useChatScrollManager = ({ const releasePinnedScroll = React.useCallback(() => { scrollEngine.cancelFollow(); + preferInstantPinRef.current = false; updatePinnedState(false); schedulePinnedStateAndIndicators(); }, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]); @@ -296,6 +305,7 @@ export const useChatScrollManager = ({ if (!isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom <= getPinThreshold()) { + preferInstantPinRef.current = false; updatePinnedState(true); } } @@ -412,7 +422,7 @@ export const useChatScrollManager = ({ }, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]); // Session switch - always start pinned at bottom - useIsomorphicLayoutEffect(() => { + React.useEffect(() => { if (!currentSessionId || currentSessionId === lastSessionIdRef.current) { return; } @@ -423,6 +433,7 @@ export const useChatScrollManager = ({ pendingViewportAnchorRef.current = null; // Always start pinned at bottom on session switch + preferInstantPinRef.current = true; updatePinnedState(true); setShowScrollButton(false); @@ -534,12 +545,17 @@ export const useChatScrollManager = ({ const container = scrollRef.current; if (!container) { - onActiveTurnChange(null); return; } + let lastActiveTurnId: string | null = null; + const spy = createScrollSpy({ onActive: (turnId) => { + if (turnId === lastActiveTurnId) { + return; + } + lastActiveTurnId = turnId; onActiveTurnChange(turnId); }, }); @@ -626,7 +642,6 @@ export const useChatScrollManager = ({ container.removeEventListener('scroll', handleScroll); mutationObserver.disconnect(); spy.destroy(); - onActiveTurnChange(null); }; }, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]); diff --git a/packages/ui/src/hooks/useChatSearchDirectory.ts b/packages/ui/src/hooks/useChatSearchDirectory.ts index 8a6fb67b..297bc890 100644 --- a/packages/ui/src/hooks/useChatSearchDirectory.ts +++ b/packages/ui/src/hooks/useChatSearchDirectory.ts @@ -1,13 +1,14 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import type { Session } from '@opencode-ai/sdk/v2'; export const useChatSearchDirectory = (): string | undefined => { - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const sessions = useSessionStore((state) => state.sessions); - const worktreeMap = useSessionStore((state) => state.worktreeMetadata); - const newSessionDraft = useSessionStore((state) => state.newSessionDraft); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); + const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata); + const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); diff --git a/packages/ui/src/hooks/useEffectiveDirectory.ts b/packages/ui/src/hooks/useEffectiveDirectory.ts index 72a8ff2b..cf4d2da1 100644 --- a/packages/ui/src/hooks/useEffectiveDirectory.ts +++ b/packages/ui/src/hooks/useEffectiveDirectory.ts @@ -1,27 +1,26 @@ -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import type { Session } from '@opencode-ai/sdk/v2'; /** * Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal). - * + * * Priority order: * 1. Worktree metadata path (for worktree sessions) * 2. Session directory (for active sessions) * 3. Draft session directoryOverride (when creating a new session) * 4. Fallback directory from DirectoryStore - * + * * This ensures that tabs show content from the correct project directory * even when a draft session is being created. */ export const useEffectiveDirectory = (): string | undefined => { - const { - currentSessionId, - sessions, - worktreeMetadata: worktreeMap, - newSessionDraft, - } = useSessionStore(); - const { currentDirectory: fallbackDirectory } = useDirectoryStore(); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); + const sessions = useSessions(); + const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); + const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); // If we have an active session, use its directory if (currentSessionId) { diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts deleted file mode 100644 index 01e463d9..00000000 --- a/packages/ui/src/hooks/useEventStream.ts +++ /dev/null @@ -1,2676 +0,0 @@ -import React from 'react'; -import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'; -import { saveSessionCursor } from '@/lib/messageCursorPersistence'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useMessageStore } from '@/stores/messageStore'; -import { getMessageLimit, STUCK_SESSION_TIMEOUT_MS } from '@/stores/types/sessionTypes'; -import { useConfigStore } from '@/stores/useConfigStore'; -import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import type { Part, Session, Message } from '@opencode-ai/sdk/v2'; -import type { PermissionRequest } from '@/types/permission'; -import type { QuestionRequest } from '@/types/question'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { streamDebugEnabled } from '@/stores/utils/streamDebug'; -import { handleTodoUpdatedEvent } from '@/stores/useTodoStore'; -import { useMcpStore } from '@/stores/useMcpStore'; -import { useContextStore } from '@/stores/contextStore'; -import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { isDesktopLocalOriginActive } from '@/lib/desktop'; -import { triggerSessionStatusPoll } from '@/hooks/useServerSessionStatus'; -import { PermissionToastActions } from '@/components/chat/PermissionToastActions'; - -interface EventData { - type: string; - properties?: Record; -} - -const readStringProp = (obj: unknown, keys: string[]): string | null => { - if (!obj || typeof obj !== 'object') return null; - const record = obj as Record; - for (let i = 0; i < keys.length; i++) { - const value = record[keys[i]]; - if (typeof value === 'string' && value.length > 0) return value; - } - return null; -}; - -const readStringArrayProp = (value: unknown): string[] => { - if (!Array.isArray(value)) { - return []; - } - - return value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -}; - -const normalizePermissionRequest = (value: unknown): PermissionRequest | null => { - if (!value || typeof value !== 'object') { - return null; - } - - const record = value as Record; - const id = readStringProp(record, ['id']); - const sessionID = readStringProp(record, ['sessionID']); - if (!id || !sessionID) { - return null; - } - - const permission = typeof record.permission === 'string' ? record.permission : ''; - const patterns = readStringArrayProp(record.patterns); - const metadata = typeof record.metadata === 'object' && record.metadata !== null - ? record.metadata as Record - : {}; - const always = readStringArrayProp(record.always); - - const toolValue = record.tool; - const tool = (toolValue && typeof toolValue === 'object') - ? { - messageID: readStringProp(toolValue, ['messageID']) ?? '', - callID: readStringProp(toolValue, ['callID']) ?? '', - } - : undefined; - - return { - id, - sessionID, - permission, - patterns, - metadata, - always, - tool: tool && tool.messageID.length > 0 && tool.callID.length > 0 ? tool : undefined, - }; -}; - -const readPermissionMetadataPreview = (metadata: Record): string => { - const preferredKeys = [ - 'command', - 'cmd', - 'script', - 'path', - 'filePath', - 'filepath', - 'file_path', - 'directory', - 'working_directory', - 'cwd', - 'url', - 'uri', - 'endpoint', - 'description', - 'action', - 'operation', - ]; - - for (let i = 0; i < preferredKeys.length; i++) { - const value = metadata[preferredKeys[i]]; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (trimmed.length > 0) { - return trimmed; - } - continue; - } - - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - - if (Array.isArray(value)) { - const joined = value - .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) - .slice(0, 3) - .join(', ') - .trim(); - if (joined.length > 0) { - return joined; - } - } - } - - const metadataEntries = Object.entries(metadata); - if (metadataEntries.length === 0) { - return ''; - } - - try { - return JSON.stringify(metadata); - } catch { - return ''; - } -}; - -const buildPermissionToastBody = (request: PermissionRequest): string => { - const patterns = Array.isArray(request.patterns) ? request.patterns : []; - const patternSummary = patterns - .filter((pattern): pattern is string => typeof pattern === 'string' && pattern.trim().length > 0) - .join(', ') - .trim(); - - const metadata = typeof request.metadata === 'object' && request.metadata !== null ? request.metadata : {}; - const metadataSummary = readPermissionMetadataPreview(metadata); - - if (patternSummary.length > 0 && metadataSummary.length > 0) { - return `${patternSummary} | ${metadataSummary}`; - } - - if (patternSummary.length > 0) { - return patternSummary; - } - - if (metadataSummary.length > 0) { - return metadataSummary; - } - - const fallback = typeof request.permission === 'string' ? request.permission.trim() : ''; - return fallback.length > 0 ? fallback : 'Permission details unavailable'; -}; - -type MessageTracker = (messageId: string, event?: string, extraData?: Record) => void; - -declare global { - interface Window { - __messageTracker?: MessageTracker; - } -} - -const RESYNC_DEBOUNCE_MS = 1800; -const QUESTION_RECONCILE_COOLDOWN_MS = 3000; -const PERMISSION_RECONCILE_COOLDOWN_MS = 3000; -const DERIVED_STATE_REFRESH_COOLDOWN_MS = 2500; -const GIT_REFRESH_HINT_DEDUP_WINDOW_MS = 5000; -const GIT_REFRESH_HINT_TOOL_NAMES = new Set([ - 'edit', - 'multiedit', - 'apply_patch', - 'write', - 'file_write', - 'create', - 'bash', -]); -const GIT_REFRESH_HINT_COMPLETED_STATES = new Set([ - 'completed', - 'complete', - 'failed', - 'error', - 'cancelled', - 'canceled', -]); - -const readEventDirectory = (props: Record): string => { - const directory = readStringProp(props, ['directory']); - return directory ?? 'global'; -}; - -const MAX_MESSAGE_CACHE_SIZE = 500; -const MESSAGE_CACHE_EVICT_COUNT = 100; -const messageCache = new Map(); -const getMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => { - const cacheKey = `${sessionId}:${messageId}`; - const cached = messageCache.get(cacheKey); - if (cached && cached.sessionId === sessionId) { - return cached.message; - } - - const storeState = useSessionStore.getState(); - const sessionMessages = storeState.messages.get(sessionId) || []; - const message = sessionMessages.find(m => m.info.id === messageId) || null; - - if (messageCache.size >= MAX_MESSAGE_CACHE_SIZE) { - // Evict oldest entries (Map preserves insertion order) - let count = 0; - for (const key of messageCache.keys()) { - if (count++ >= MESSAGE_CACHE_EVICT_COUNT) break; - messageCache.delete(key); - } - } - - messageCache.set(cacheKey, { sessionId, message }); - return message; -}; - -const getLatestMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => { - const storeState = useSessionStore.getState(); - const sessionMessages = storeState.messages.get(sessionId) || []; - return sessionMessages.find((message) => message.info.id === messageId) || null; -}; - -export const useEventStream = (options?: { enabled?: boolean }) => { - const enabled = options?.enabled ?? true; - const { - addStreamingPart, - applyPartDelta, - completeStreamingMessage, - updateMessageInfo, - updateSessionCompaction, - addPermission, - dismissPermission, - addQuestion, - dismissQuestion, - currentSessionId, - applySessionMetadata, - getWorktreeMetadata, - loadMessages, - loadSessions, - updateSession, - removeSessionFromStore - } = useSessionStore(); - - const { checkConnection } = useConfigStore(); - const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); - - const activeSessionDirectory = React.useMemo(() => { - if (!currentSessionId) return undefined; - - try { - const metadata = getWorktreeMetadata?.(currentSessionId); - if (metadata?.path) return metadata.path; - } catch (error) { - console.warn('Failed to inspect worktree metadata for session directory:', error); - } - - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionRecord = currentSessions.find((entry) => entry.id === currentSessionId); - if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) { - return sessionRecord.directory.trim(); - } - - return undefined; - }, [currentSessionId, getWorktreeMetadata]); - - const effectiveDirectory = React.useMemo(() => { - if (activeSessionDirectory && activeSessionDirectory.length > 0) { - return activeSessionDirectory; - } - if (typeof fallbackDirectory === 'string' && fallbackDirectory.trim().length > 0) { - return fallbackDirectory.trim(); - } - return undefined; - }, [activeSessionDirectory, fallbackDirectory]); - - const bootstrapPendingQuestions = React.useCallback(async () => { - try { - const projects = useProjectsStore.getState().projects; - const projectDirs = projects.map((project) => project.path); - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory); - - const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs]; - const pending = await opencodeClient.listPendingQuestions({ directories }); - if (pending.length === 0) { - return; - } - - for (const request of pending) { - addQuestion(request as unknown as QuestionRequest); - } - } catch { - // ignored - } - }, [addQuestion, effectiveDirectory]); - - const lastQuestionRefreshAtRef = React.useRef(0); - const requestPendingQuestionsRefresh = React.useCallback((force = false) => { - const now = Date.now(); - if (!force && now - lastQuestionRefreshAtRef.current < QUESTION_RECONCILE_COOLDOWN_MS) { - return; - } - lastQuestionRefreshAtRef.current = now; - void bootstrapPendingQuestions(); - }, [bootstrapPendingQuestions]); - - const bootstrapPendingPermissions = React.useCallback(async () => { - try { - const projects = useProjectsStore.getState().projects; - const projectDirs = projects.map((project) => project.path); - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionDirs = currentSessions.map((session) => (session as { directory?: string | null }).directory); - - const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs]; - const pending = await opencodeClient.listPendingPermissions({ directories }); - if (pending.length === 0) { - return; - } - - for (const request of pending) { - const normalizedRequest = normalizePermissionRequest(request); - if (!normalizedRequest) { - continue; - } - addPermission(normalizedRequest); - } - } catch { - // ignored - } - }, [addPermission, effectiveDirectory]); - - const lastPermissionRefreshAtRef = React.useRef(0); - const requestPendingPermissionsRefresh = React.useCallback((force = false) => { - const now = Date.now(); - if (!force && now - lastPermissionRefreshAtRef.current < PERMISSION_RECONCILE_COOLDOWN_MS) { - return; - } - lastPermissionRefreshAtRef.current = now; - void bootstrapPendingPermissions(); - }, [bootstrapPendingPermissions]); - - const requestPendingPermissionsRefreshRef = React.useRef(requestPendingPermissionsRefresh); - React.useEffect(() => { - requestPendingPermissionsRefreshRef.current = requestPendingPermissionsRefresh; - }, [requestPendingPermissionsRefresh]); - - React.useEffect(() => { - if (!enabled) { - return; - } - - requestPendingPermissionsRefresh(true); - requestPendingQuestionsRefresh(true); - }, [enabled, requestPendingPermissionsRefresh, requestPendingQuestionsRefresh]); - - const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const normalized = trimmed.replace(/\\/g, '/'); - return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; - }, []); - - const resolveSessionDirectoryForStatus = React.useCallback( - (sessionId: string | null | undefined): string | null => { - if (!sessionId) return null; - try { - const metadata = getWorktreeMetadata?.(sessionId); - const metaPath = normalizeDirectory(metadata?.path ?? null); - if (metaPath) return metaPath; - } catch { - // ignored - } - - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const record = currentSessions.find((entry) => entry.id === sessionId); - return normalizeDirectory((record as { directory?: string | null })?.directory ?? null); - }, - [getWorktreeMetadata, normalizeDirectory] - ); - - const setEventStreamStatus = useUIStore((state) => state.setEventStreamStatus); - const lastStatusRef = React.useRef<{ status: EventStreamStatus; hint: string | null } | null>(null); - - const publishStatus = React.useCallback( - (status: EventStreamStatus, hint?: string | null) => { - const normalizedHint = hint ?? null; - const last = lastStatusRef.current; - if (last && last.status === status && last.hint === normalizedHint) { - return; - } - - lastStatusRef.current = { status, hint: normalizedHint }; - - if (streamDebugEnabled()) { - const prefixMap: Record = { - idle: '[IDLE]', - connecting: '[CONNECT]', - connected: '[CONNECTED]', - reconnecting: '[RECONNECT]', - paused: '[PAUSED]', - offline: '[OFFLINE]', - error: '[ERROR]' - }; - - const prefix = prefixMap[status] ?? '[INFO]'; - const message = normalizedHint ? `${prefix} SSE ${status}: ${normalizedHint}` : `${prefix} SSE ${status}`; - console.info(message); - } - - setEventStreamStatus(status, normalizedHint); - }, - [setEventStreamStatus] - ); - - const resyncMessages = React.useCallback( - (sessionId: string, reason: string, limit?: number) => { - if (!sessionId) { - return Promise.resolve(); - } - const now = Date.now(); - if (resyncInFlightRef.current) { - return resyncInFlightRef.current; - } - if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) { - return Promise.resolve(); - } - const task = loadMessages(sessionId, limit) - .catch((error) => { - console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error); - }) - .finally(() => { - resyncInFlightRef.current = null; - lastResyncAtRef.current = Date.now(); - }); - resyncInFlightRef.current = task; - return task; - }, - [loadMessages] - ); - - const bootstrapState = React.useCallback( - async (reason: string) => { - if (streamDebugEnabled()) { - console.info('[useEventStream] Bootstrapping state:', reason); - } - try { - const activeLimit = getMessageLimit(); - await Promise.all([ - loadSessions(), - currentSessionId ? resyncMessages(currentSessionId, reason, activeLimit) : Promise.resolve(), - ]); - } catch (error) { - console.warn('[useEventStream] Bootstrap failed:', reason, error); - } - }, - [currentSessionId, loadSessions, resyncMessages] - ); - - const scheduleSoftResync = React.useCallback( - (sessionId: string, reason: string, limit = getMessageLimit()): Promise => { - if (!sessionId) return Promise.resolve(); - - const memory = useSessionStore.getState().sessionMemoryState.get(sessionId); - const cooldownUntil = memory?.streamingCooldownUntil; - const now = Date.now(); - if (typeof cooldownUntil === 'number' && cooldownUntil > now) { - const delay = Math.min(3000, Math.max(0, cooldownUntil - now)); - return new Promise((resolve) => { - setTimeout(() => { - resyncMessages(sessionId, reason, limit).finally(resolve); - }, delay); - }); - } - - return resyncMessages(sessionId, reason, limit); - }, - [resyncMessages] - ); - - React.useEffect(() => { - scheduleSoftResyncRef.current = scheduleSoftResync; - }, [scheduleSoftResync]); - - const trackMessage = React.useCallback((messageId: string, event?: string, extraData?: Record) => { - if (streamDebugEnabled()) { - console.debug(`[MessageTracker] ${messageId}: ${event}`, extraData); - } - }, []); - - const reportMessage = React.useCallback((messageId: string) => { - if (streamDebugEnabled()) { - console.debug(`[MessageTracker] ${messageId}: reported`); - } - }, []); - - const unsubscribeRef = React.useRef<(() => void) | null>(null); - const reconnectTimeoutRef = React.useRef(null); - const reconnectAttemptsRef = React.useRef(0); - const missingMessageHydrationRef = React.useRef>(new Set()); - const metadataRefreshTimestampsRef = React.useRef>(new Map()); - const sessionRefreshTimeoutRef = React.useRef(null); - const isCleaningUpRef = React.useRef(false); - const resyncInFlightRef = React.useRef | null>(null); - const lastResyncAtRef = React.useRef(0); - const permissionToastShownRef = React.useRef>(new Set()); - const questionToastShownRef = React.useRef>(new Set()); - const notifiedMessagesRef = React.useRef>(new Set()); - const notifiedQuestionsRef = React.useRef>(new Set()); - const serverNotificationEventSeenRef = React.useRef(false); - const modeSwitchToastShownRef = React.useRef>(new Set()); - const lastUserAgentSelectionRef = React.useRef>(new Map()); - - const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => { - if (typeof document === 'undefined') return 'visible'; - - const state = document.visibilityState; - return state === 'hidden' && document.hasFocus() ? 'visible' : state; - }, []); - - const visibilityStateRef = React.useRef<'visible' | 'hidden'>(resolveVisibilityState()); - const onlineStatusRef = React.useRef(typeof navigator === 'undefined' ? true : navigator.onLine); - const pendingResumeRef = React.useRef(false); - const pauseTimeoutRef = React.useRef(null); - const staleCheckIntervalRef = React.useRef(null); - const lastEventTimestampRef = React.useRef(Date.now()); - const lastMessageEventBySessionRef = React.useRef>(new Map()); - const pendingMessageStallTimersRef = React.useRef>(new Map()); - const lastMessageStallRecoveryBySessionRef = React.useRef>(new Map()); - const partTypeHintsByKeyRef = React.useRef>(new Map()); - const sessionCooldownTimersRef = React.useRef>(new Map()); - const sessionActivityPhaseRef = React.useRef>(new Map()); - const sessionActivityLastRefreshAtRef = React.useRef(0); - const sessionActivityRefreshInFlightRef = React.useRef | null>(null); - const lastDerivedActivityRepairAtRef = React.useRef(0); - const lastDerivedStatusRepairAtRef = React.useRef(0); - const lastGitRefreshHintAtRef = React.useRef>(new Map()); - const scheduleSoftResyncRef = React.useRef< - (sessionId: string, reason: string, limit?: number) => Promise - >(() => Promise.resolve()); - const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {}); - - const writePartTypeHint = React.useCallback((key: string, type: string) => { - const map = partTypeHintsByKeyRef.current; - map.set(key, type); - if (map.size > 4000) { - const firstKey = map.keys().next().value; - if (typeof firstKey === 'string') { - map.delete(firstKey); - } - } - }, []); - - const isNotificationContextHidden = React.useCallback((isVSCodeRuntime: boolean): boolean => { - if (visibilityStateRef.current === 'hidden') { - return true; - } - if (isVSCodeRuntime && typeof document !== 'undefined') { - return !document.hasFocus(); - } - return false; - }, []); - - const dispatchRuntimeNotification = React.useCallback((payload: { - title: string; - body?: string; - tag?: string; - requireHidden?: boolean; - }) => { - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (!runtimeAPIs?.notifications) { - return; - } - - const title = typeof payload.title === 'string' ? payload.title.trim() : ''; - if (!title) { - return; - } - - const settings = useUIStore.getState(); - if (!settings.nativeNotificationsEnabled) { - return; - } - - const isVSCodeRuntime = Boolean(runtimeAPIs.runtime?.isVSCode); - const shouldRequireHidden = Boolean(payload.requireHidden) || settings.notificationMode === 'hidden-only'; - if (shouldRequireHidden && !isNotificationContextHidden(isVSCodeRuntime)) { - return; - } - - void runtimeAPIs.notifications.notifyAgentCompletion({ - title, - body: typeof payload.body === 'string' ? payload.body : '', - tag: typeof payload.tag === 'string' ? payload.tag : undefined, - }); - }, [isNotificationContextHidden]); - - const maybeBootstrapIfStale = React.useCallback( - (reason: string) => { - const now = Date.now(); - if (now - lastEventTimestampRef.current > 25000) { - void bootstrapState(reason); - lastEventTimestampRef.current = now; - } - }, - [bootstrapState] - ); - - const emitGitRefreshHint = React.useCallback((params: { - directory: string; - sessionId: string; - messageId: string; - partId?: string | null; - toolName: string; - toolState: string; - }) => { - if (typeof window === 'undefined') { - return; - } - - const dedupKey = `${params.sessionId}:${params.messageId}:${params.partId ?? 'unknown'}:${params.toolName}:${params.toolState}`; - const now = Date.now(); - const lastAt = lastGitRefreshHintAtRef.current.get(dedupKey) ?? 0; - if (now - lastAt < GIT_REFRESH_HINT_DEDUP_WINDOW_MS) { - return; - } - - lastGitRefreshHintAtRef.current.set(dedupKey, now); - if (lastGitRefreshHintAtRef.current.size > 600) { - const firstKey = lastGitRefreshHintAtRef.current.keys().next().value; - if (typeof firstKey === 'string') { - lastGitRefreshHintAtRef.current.delete(firstKey); - } - } - - window.dispatchEvent(new CustomEvent('openchamber:git-refresh-hint', { - detail: { - directory: params.directory, - sessionId: params.sessionId, - messageId: params.messageId, - toolName: params.toolName, - toolState: params.toolState, - }, - })); - }, []); - - - const currentSessionIdRef = React.useRef(currentSessionId); - const previousSessionIdRef = React.useRef(null); - const previousSessionDirectoryRef = React.useRef(null); - React.useEffect(() => { - currentSessionIdRef.current = currentSessionId; - }, [currentSessionId]); - - const requestSessionMetadataRefresh = React.useCallback( - (sessionId: string | undefined | null, directoryOverride?: string | null) => { - if (!sessionId) return; - - const now = Date.now(); - const timestamps = metadataRefreshTimestampsRef.current; - const lastRefresh = timestamps.get(sessionId); - - if (lastRefresh && now - lastRefresh < 3000) return; - - timestamps.set(sessionId, now); - - const resolveDirectoryForSession = (id: string): string | null => { - if (typeof directoryOverride === 'string' && directoryOverride.trim().length > 0) { - return directoryOverride.trim(); - } - - try { - const metadata = getWorktreeMetadata?.(id); - if (metadata?.path) { - return metadata.path; - } - } catch { - // ignored - } - - // Use getState() to avoid sessions dependency which causes cascading updates - const currentSessions = useSessionStore.getState().sessions; - const sessionRecord = currentSessions.find((entry) => entry.id === id) as Session & { directory?: string | null }; - if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) { - return sessionRecord.directory.trim(); - } - - return null; - }; - - setTimeout(async () => { - try { - const directory = resolveDirectoryForSession(sessionId); - const session = directory - ? await opencodeClient.withDirectory(directory, () => opencodeClient.getSession(sessionId)) - : await opencodeClient.getSession(sessionId); - - if (session) { - const patch: Partial = {}; - if (typeof session.title === 'string' && session.title.length > 0) { - patch.title = session.title; - } - if (session.summary !== undefined) { - patch.summary = session.summary; - } - if (Object.keys(patch).length > 0) { - applySessionMetadata(sessionId, patch); - } - } - } catch (error) { - console.warn('Failed to refresh session metadata:', error); - } - }, 100); - }, - [applySessionMetadata, getWorktreeMetadata] - ); - - type SessionStatusPayload = { - type: 'idle' | 'busy' | 'retry'; - attempt?: number; - message?: string; - next?: number; - }; - - const updateSessionStatus = React.useCallback(( - sessionId: string, - status: SessionStatusPayload, - source: string = 'unknown' - ) => { - if (!sessionId) return; - - const storeStatus = useSessionStore.getState().sessionStatus?.get(sessionId); - const prevType = storeStatus?.type ?? 'idle'; - const nextType = status?.type ?? 'idle'; - - // Note: needs_attention logic is now handled by the server - // Server maintains authoritative state based on view tracking and message events - - if (process.env.NODE_ENV === 'development' && prevType !== nextType) { - try { - console.info('[SESSION-STATUS]', { - sessionId, - from: prevType, - to: nextType, - source, - ...(nextType === 'retry' - ? { - attempt: status.attempt, - next: status.next, - message: status.message, - } - : {}), - }); - } catch { - // ignore - } - } - - const shouldArmMessageStallCheck = prevType === 'idle' && (nextType === 'busy' || nextType === 'retry'); - const shouldDisarmMessageStallCheck = nextType === 'idle'; - - if (shouldDisarmMessageStallCheck) { - const pending = pendingMessageStallTimersRef.current.get(sessionId); - if (pending) { - clearTimeout(pending); - pendingMessageStallTimersRef.current.delete(sessionId); - } - } - - if (shouldArmMessageStallCheck) { - const pending = pendingMessageStallTimersRef.current.get(sessionId); - if (pending) { - clearTimeout(pending); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - const startAt = Date.now(); - const timer = setTimeout(() => { - const current = useSessionStore.getState().sessionStatus?.get(sessionId); - if (current?.type !== 'busy' && current?.type !== 'retry') { - return; - } - - const lastRecoveryAt = lastMessageStallRecoveryBySessionRef.current.get(sessionId) ?? 0; - if (Date.now() - lastRecoveryAt < 15000) { - return; - } - - const lastMsgAt = lastMessageEventBySessionRef.current.get(sessionId) ?? 0; - if (lastMsgAt >= startAt) { - return; - } - - lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now()); - void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getMessageLimit()) - .finally(() => { - scheduleReconnectRef.current('No message events after busy status'); - }); - }, 2000); - - pendingMessageStallTimersRef.current.set(sessionId, timer); - } - - const next = new Map(useSessionStore.getState().sessionStatus ?? new Map()); - if (nextType === 'idle') { - next.set(sessionId, { ...status, confirmedAt: Date.now() }); - } else { - const existing = next.get(sessionId); - if (existing?.confirmedAt) { - next.set(sessionId, { ...status, confirmedAt: existing.confirmedAt }); - } else { - next.set(sessionId, status); - } - } - useSessionStore.setState({ sessionStatus: next }); - }, []); - - const updateSessionActivityPhase = React.useCallback(( - sessionId: string, - phase: 'idle' | 'busy' | 'cooldown', - source: string = 'unknown', - options?: { syncStatus?: boolean } - ) => { - if (!sessionId) return; - const syncStatus = options?.syncStatus !== false; - - const current = sessionActivityPhaseRef.current.get(sessionId); - if (current === phase) { - return; - } - - const existingTimer = sessionCooldownTimersRef.current.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionCooldownTimersRef.current.delete(sessionId); - } - - const next = new Map(sessionActivityPhaseRef.current); - next.set(sessionId, phase); - sessionActivityPhaseRef.current = next; - - if (!syncStatus) { - return; - } - - if (phase === 'idle') { - updateSessionStatus(sessionId, { type: 'idle' }, `${source}:idle`); - return; - } - - updateSessionStatus(sessionId, { type: 'busy' }, `${source}:${phase}`); - - if (phase === 'cooldown') { - const timer = setTimeout(() => { - sessionCooldownTimersRef.current.delete(sessionId); - if (sessionActivityPhaseRef.current.get(sessionId) !== 'cooldown') { - return; - } - const latest = new Map(sessionActivityPhaseRef.current); - latest.set(sessionId, 'idle'); - sessionActivityPhaseRef.current = latest; - updateSessionStatus(sessionId, { type: 'idle' }, `${source}:cooldown_timeout`); - }, 2000); - sessionCooldownTimersRef.current.set(sessionId, timer); - } - }, [updateSessionStatus]); - - const refreshSessionActivityStatus = React.useCallback(async () => { - const now = Date.now(); - if (sessionActivityRefreshInFlightRef.current) { - return sessionActivityRefreshInFlightRef.current; - } - if (now - sessionActivityLastRefreshAtRef.current < 1500) { - return; - } - sessionActivityLastRefreshAtRef.current = now; - - const applyStatusMap = (statusMap: Record) => { - const observed = new Set(); - const currentSessions = useSessionStore.getState().sessions; - const knownSessionIds = new Set(currentSessions.map((session) => session.id)); - - for (const [sessionId, raw] of Object.entries(statusMap)) { - if (!sessionId || !raw) continue; - observed.add(sessionId); - const phase: 'idle' | 'busy' | 'cooldown' = - raw.type === 'cooldown' - ? 'cooldown' - : raw.type === 'busy' || raw.type === 'retry' - ? 'busy' - : 'idle'; - updateSessionActivityPhase(sessionId, phase, 'snapshot'); - } - - for (const [sessionId, phase] of sessionActivityPhaseRef.current.entries()) { - if (!knownSessionIds.has(sessionId)) continue; - if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) { - updateSessionActivityPhase(sessionId, 'idle', 'snapshot_missing'); - } - } - }; - - const task = (async (): Promise => { - try { - const webServerActivity = await opencodeClient.getWebServerSessionActivity(); - if (webServerActivity && Object.keys(webServerActivity).length > 0) { - applyStatusMap(webServerActivity); - return; - } - - const globalStatusMap = await opencodeClient.getGlobalSessionStatus(); - if (globalStatusMap && Object.keys(globalStatusMap).length > 0) { - applyStatusMap(globalStatusMap); - } - } catch { - // ignored - } - })().finally(() => { - sessionActivityRefreshInFlightRef.current = null; - }); - - sessionActivityRefreshInFlightRef.current = task; - return task; - }, [updateSessionActivityPhase]); - - const clearSessionActivityTimers = React.useCallback(() => { - const cooldownTimers = sessionCooldownTimersRef.current; - for (const timer of cooldownTimers.values()) { - clearTimeout(timer); - } - cooldownTimers.clear(); - sessionActivityPhaseRef.current.clear(); - }, []); - - const repairSessionDerivedState = React.useCallback(( - reason: string, - options?: { refreshActivity?: boolean; pollStatus?: boolean; immediate?: boolean } - ) => { - const refreshActivity = options?.refreshActivity !== false; - const pollStatus = options?.pollStatus !== false; - const immediate = options?.immediate === true; - const now = Date.now(); - - if (streamDebugEnabled()) { - console.debug('[useEventStream] Repairing derived session state', { reason, refreshActivity, pollStatus, immediate }); - } - - if (refreshActivity) { - if (immediate || now - lastDerivedActivityRepairAtRef.current >= DERIVED_STATE_REFRESH_COOLDOWN_MS) { - lastDerivedActivityRepairAtRef.current = now; - void refreshSessionActivityStatus(); - } - } - - if (pollStatus) { - if (immediate || now - lastDerivedStatusRepairAtRef.current >= DERIVED_STATE_REFRESH_COOLDOWN_MS) { - lastDerivedStatusRepairAtRef.current = now; - triggerSessionStatusPoll(); - } - } - }, [refreshSessionActivityStatus]); - - React.useEffect(() => { - const nextSessionId = currentSessionId ?? null; - const prevSessionId = previousSessionIdRef.current; - const nextDirectory = resolveSessionDirectoryForStatus(nextSessionId); - const prevDirectory = previousSessionDirectoryRef.current; - - if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) { - // Clear the message cache on session switch to free memory - messageCache.clear(); - - if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) { - repairSessionDerivedState('session_switch_directory'); - } - } - - previousSessionIdRef.current = nextSessionId; - previousSessionDirectoryRef.current = nextDirectory; - }, [currentSessionId, repairSessionDerivedState, resolveSessionDirectoryForStatus]); - - const handleEvent = React.useCallback((event: EventData) => { - lastEventTimestampRef.current = Date.now(); - - if (streamDebugEnabled()) { - console.debug('[useEventStream] Received event:', event.type, event.properties); - } - - if (!event.properties) return; - - const props = event.properties as Record; - const nonMetadataSessionEvents = new Set(['session.abort', 'session.error']); - - if (!nonMetadataSessionEvents.has(event.type)) { - const sessionPayload = (typeof props.session === 'object' && props.session !== null ? props.session : null) || - (typeof props.sessionInfo === 'object' && props.sessionInfo !== null ? props.sessionInfo : null) as Record | null; - - if (sessionPayload) { - const sessionPayloadAny = sessionPayload as Record; - const sessionId = (typeof sessionPayloadAny.id === 'string' && sessionPayloadAny.id.length > 0) ? sessionPayloadAny.id : - (typeof sessionPayloadAny.sessionID === 'string' && sessionPayloadAny.sessionID.length > 0) ? sessionPayloadAny.sessionID : - (typeof props.sessionID === 'string' && props.sessionID.length > 0) ? props.sessionID : - (typeof props.id === 'string' && props.id.length > 0) ? props.id : undefined; - - if (sessionId) { - const titleCandidate = typeof sessionPayloadAny.title === 'string' ? sessionPayloadAny.title : - typeof props.title === 'string' ? props.title : undefined; - - const summaryCandidate = (typeof sessionPayloadAny.summary === 'object' && sessionPayloadAny.summary !== null) ? sessionPayloadAny.summary as Session['summary'] : - (typeof props.summary === 'object' && props.summary !== null) ? props.summary as Session['summary'] : undefined; - - if (titleCandidate !== undefined || summaryCandidate !== undefined) { - const patch: Partial = {}; - if (titleCandidate !== undefined) patch.title = titleCandidate; - if (summaryCandidate !== undefined) patch.summary = summaryCandidate; - applySessionMetadata(sessionId, patch); - } - } - } - } - - switch (event.type) { - case 'server.connected': - checkConnection(); - break; - case 'global.disposed': - case 'server.instance.disposed': { - void bootstrapState('server_disposed_event'); - break; - } - - case 'mcp.tools.changed': { - const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory; - void useMcpStore.getState().refresh({ directory: directory ?? null, silent: true }); - break; - } - - case 'session.status': - { - const sessionId = readStringProp(props, ['sessionID', 'sessionId']); - const statusRaw = (props as { status?: unknown }).status; - const statusObj = (typeof statusRaw === 'object' && statusRaw !== null) ? statusRaw as Record : null; - const statusType = - typeof statusRaw === 'string' - ? statusRaw - : typeof statusObj?.type === 'string' - ? statusObj.type - : typeof statusObj?.status === 'string' - ? statusObj.status - : typeof (props as { type?: unknown }).type === 'string' - ? ((props as { type: string }).type) - : typeof (props as { phase?: unknown }).phase === 'string' - ? ((props as { phase: string }).phase) - : typeof (props as { state?: unknown }).state === 'string' - ? ((props as { state: string }).state) - : null; - const statusInfo = statusObj ?? ({} as Record); - const metadata = (props as { metadata?: unknown }).metadata; - const metadataObj = (typeof metadata === 'object' && metadata !== null) ? metadata as Record : null; - - if (sessionId && statusType) { - if (statusType === 'busy') { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false }); - } else if (statusType === 'retry') { - updateSessionStatus(sessionId, { - type: 'retry', - attempt: - typeof statusInfo.attempt === 'number' - ? statusInfo.attempt - : typeof (props as { attempt?: unknown }).attempt === 'number' - ? (props as { attempt: number }).attempt - : typeof metadataObj?.attempt === 'number' - ? metadataObj.attempt - : undefined, - message: - typeof statusInfo.message === 'string' - ? statusInfo.message - : typeof (props as { message?: unknown }).message === 'string' - ? (props as { message: string }).message - : typeof metadataObj?.message === 'string' - ? metadataObj.message - : undefined, - next: - typeof statusInfo.next === 'number' - ? statusInfo.next - : typeof (props as { next?: unknown }).next === 'number' - ? (props as { next: number }).next - : typeof metadataObj?.next === 'number' - ? metadataObj.next - : undefined, - }, 'sse:session.status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false }); - } else { - updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status'); - updateSessionActivityPhase(sessionId, 'idle', 'sse:session.status', { syncStatus: false }); - repairSessionDerivedState('session.status_idle', { refreshActivity: false }); - } - requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); - } - } - break; - - case 'openchamber:session-activity': - { - const sessionId = readStringProp(props, ['sessionId', 'sessionID']); - const phase = typeof props.phase === 'string' ? props.phase : null; - if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) { - updateSessionActivityPhase(sessionId, phase, 'sse:openchamber:session-activity'); - requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null); - } - } - break; - - case 'openchamber:session-status': - { - const sessionId = readStringProp(props, ['sessionId', 'sessionID']); - const status = typeof props.status === 'string' ? props.status : null; - const needsAttention = typeof props.needsAttention === 'boolean' ? props.needsAttention : false; - const timestamp = typeof props.timestamp === 'number' ? props.timestamp : Date.now(); - - if (sessionId && status) { - // Update session status - if (status === 'busy') { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:openchamber:session-status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false }); - } else if (status === 'retry') { - const metadata = (typeof props.metadata === 'object' && props.metadata !== null) ? props.metadata as Record : {}; - updateSessionStatus(sessionId, { - type: 'retry', - attempt: typeof metadata.attempt === 'number' ? metadata.attempt : undefined, - message: typeof metadata.message === 'string' ? metadata.message : undefined, - next: typeof metadata.next === 'number' ? metadata.next : undefined, - }, 'sse:openchamber:session-status'); - updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false }); - } else { - updateSessionStatus(sessionId, { type: 'idle' }, 'sse:openchamber:session-status'); - updateSessionActivityPhase(sessionId, 'idle', 'sse:openchamber:session-status', { syncStatus: false }); - if (needsAttention) { - repairSessionDerivedState('openchamber.session-status_attention_idle', { refreshActivity: false }); - } - } - - // Update attention state in the same update to ensure atomicity - const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map(); - const newAttentionStates = new Map(currentAttentionStates); - const existing = newAttentionStates.get(sessionId); - - newAttentionStates.set(sessionId, { - needsAttention, - lastStatusChangeAt: timestamp, - lastUserMessageAt: existing?.lastUserMessageAt ?? null, - status: status as 'idle' | 'busy' | 'retry', - isViewed: existing?.isViewed ?? false, - }); - - useSessionStore.setState({ sessionAttentionStates: newAttentionStates }); - } - } - break; - - case 'message.part.updated': { - const part = (typeof props.part === 'object' && props.part !== null) ? (props.part as Part) : null; - if (!part) break; - - const partExt = part as Record; - const messageInfo = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record) : props; - - const messageInfoSessionId = readStringProp(messageInfo, ['sessionID', 'sessionId']); - - const resolvedSessionId = - readStringProp(partExt, ['sessionID', 'sessionId']) || - messageInfoSessionId || - readStringProp(props, ['sessionID', 'sessionId']); - - const messageInfoId = readStringProp(messageInfo, ['messageID', 'messageId', 'id']); - - const resolvedMessageId = - readStringProp(partExt, ['messageID', 'messageId']) || - messageInfoId || - readStringProp(props, ['messageID', 'messageId']); - - if (!resolvedSessionId || !resolvedMessageId) { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Skipping message.part.updated without resolvable session/message id', { - sessionID: partExt.sessionID ?? messageInfoSessionId ?? props.sessionID, - messageID: partExt.messageID ?? messageInfoId ?? props.messageID, - }); - } - break; - } - - const sessionId = resolvedSessionId; - const messageId = resolvedMessageId; - - lastMessageEventBySessionRef.current.set(sessionId, Date.now()); - const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId); - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - const shouldKeepSyntheticUserText = (value: unknown): boolean => { - const text = typeof value === 'string' ? value.trim() : ''; - if (!text) return false; - return ( - text.startsWith('User has requested to enter plan mode') || - text.startsWith('The plan at ') || - text.startsWith('The following tool was executed by the user') - ); - }; - - const inferUserRoleFromPart = (): boolean => { - const partType = typeof partExt.type === 'string' ? partExt.type : ''; - if (partType === 'subtask' || partType === 'agent' || partType === 'file') { - return true; - } - if (partType === 'text' && partExt.synthetic === true) { - const text = (partExt as { text?: unknown }).text; - return shouldKeepSyntheticUserText(text); - } - return false; - }; - - let roleInfo = 'assistant'; - const existingMessage = getLatestMessageFromStore(sessionId, messageId); - const existingPartForType = existingMessage?.parts?.find((item) => item?.id === partExt.id); - const existingPartType = typeof (existingPartForType as { type?: unknown } | undefined)?.type === 'string' - ? (existingPartForType as { type: string }).type - : undefined; - if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') { - roleInfo = (messageInfo as { role?: string }).role as string; - } else { - if (existingMessage) { - const existingRole = (existingMessage.info as Record).role; - if (typeof existingRole === 'string') { - roleInfo = existingRole; - } - } - } - - if (roleInfo !== 'user' && inferUserRoleFromPart()) { - roleInfo = 'user'; - } - - trackMessage(messageId, 'part_received', { role: roleInfo }); - - if (roleInfo === 'user' && partExt.synthetic === true) { - const text = (partExt as { text?: unknown }).text; - if (!shouldKeepSyntheticUserText(text)) { - trackMessage(messageId, 'skipped_synthetic_user_part'); - break; - } - } - - const updatedPartId = readStringProp(partExt, ['id', 'partID', 'partId']); - const directory = readEventDirectory(props); - const partTypeHintKey = updatedPartId ? `${directory}:${messageId}:${updatedPartId}` : null; - const hintedPartType = partTypeHintKey ? partTypeHintsByKeyRef.current.get(partTypeHintKey) : undefined; - - const resolvedPartType = - part.type || - existingPartType || - hintedPartType || - 'text'; - - const messagePartBase: Part = { - ...part, - type: resolvedPartType, - } as Part; - - const messagePart: Part = { - ...messagePartBase, - } as Part; - - if (partTypeHintKey && typeof resolvedPartType === 'string' && resolvedPartType.length > 0) { - writePartTypeHint(partTypeHintKey, resolvedPartType); - } - - if (roleInfo === 'assistant') { - const partType = (messagePart as { type?: unknown }).type; - const partTime = (messagePart as { time?: { end?: unknown } }).time; - const partHasEnded = typeof partTime?.end === 'number'; - const toolState = (messagePart as { state?: { status?: unknown } }).state?.status; - const normalizedToolState = typeof toolState === 'string' ? toolState.toLowerCase() : null; - const toolName = typeof (messagePart as { tool?: unknown }).tool === 'string' - ? (messagePart as { tool: string }).tool.toLowerCase() - : null; - const textContent = (messagePart as { text?: unknown }).text; - - if ( - partType === 'tool' - && toolName - && GIT_REFRESH_HINT_TOOL_NAMES.has(toolName) - && normalizedToolState - && GIT_REFRESH_HINT_COMPLETED_STATES.has(normalizedToolState) - ) { - emitGitRefreshHint({ - directory, - sessionId, - messageId, - partId: updatedPartId, - toolName, - toolState: normalizedToolState, - }); - } - - if (partType === 'tool' && toolName === 'question') { - requestPendingQuestionsRefresh(); - } - - const isStreamingPart = (() => { - if (partType === 'tool') { - return normalizedToolState === 'running' || normalizedToolState === 'pending'; - } - if (partType === 'reasoning') { - return !partHasEnded; - } - if (partType === 'text') { - const hasText = typeof textContent === 'string' && textContent.trim().length > 0; - return hasText && !partHasEnded; - } - if (partType === 'step-start') { - return true; - } - return false; - })(); - - if (isStreamingPart) { - const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId); - const recentlyConfirmedIdle = - currentStatus?.type === 'idle' && - typeof currentStatus.confirmedAt === 'number' && - Date.now() - currentStatus.confirmedAt < 1200; - if (!currentStatus || currentStatus.type === 'idle') { - if (!recentlyConfirmedIdle) { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated'); - } - } - } - } - - trackMessage(messageId, 'addStreamingPart_called'); - addStreamingPart(sessionId, messageId, messagePart, roleInfo); - break; - } - - case 'message.part.delta': { - const sessionId = readStringProp(props, ['sessionID', 'sessionId']); - const messageId = readStringProp(props, ['messageID', 'messageId']); - const partId = readStringProp(props, ['partID', 'partId']); - const field = readStringProp(props, ['field']); - const delta = typeof props.delta === 'string' ? props.delta : null; - - if (!sessionId || !messageId || !partId || !field || delta === null) { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Skipping message.part.delta with missing payload', { - sessionID: props.sessionID, - messageID: props.messageID, - partID: props.partID, - field: props.field, - }); - } - break; - } - - lastMessageEventBySessionRef.current.set(sessionId, Date.now()); - const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId); - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - const existingMessage = getLatestMessageFromStore(sessionId, messageId); - const existingPart = existingMessage?.parts?.find((item) => item?.id === partId); - const existingRole = (existingMessage?.info as Record | undefined)?.role; - const roleInfo = typeof existingRole === 'string' ? existingRole : 'assistant'; - - if (!existingPart) { - if (field === 'text' || field === 'content' || field === 'value') { - const directory = readEventDirectory(props); - const deltaPartTypeHint = - readStringProp(props, ['partType', 'type', 'part_type']) || - readStringProp(props, ['kind']); - const partTypeHintKey = `${directory}:${messageId}:${partId}`; - const hintedPartType = partTypeHintsByKeyRef.current.get(partTypeHintKey); - const bootstrappedPartType = - typeof deltaPartTypeHint === 'string' && deltaPartTypeHint.trim().length > 0 - ? deltaPartTypeHint - : (typeof hintedPartType === 'string' && hintedPartType.trim().length > 0 - ? hintedPartType - : 'text'); - - const bootstrappedPart = { - id: partId, - type: bootstrappedPartType, - sessionID: sessionId, - messageID: messageId, - delta, - [field]: '', - } as unknown as Part; - - if (typeof bootstrappedPartType === 'string' && bootstrappedPartType.length > 0) { - writePartTypeHint(partTypeHintKey, bootstrappedPartType); - } - - addStreamingPart(sessionId, messageId, bootstrappedPart, roleInfo); - } - break; - } - - if (roleInfo === 'assistant' && delta.length > 0) { - const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId); - const recentlyConfirmedIdle = - currentStatus?.type === 'idle' && - typeof currentStatus.confirmedAt === 'number' && - Date.now() - currentStatus.confirmedAt < 1200; - if (!currentStatus || currentStatus.type === 'idle') { - if (!recentlyConfirmedIdle) { - updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.delta'); - } - } - } - - trackMessage(messageId, 'part_delta_received', { role: roleInfo, field }); - applyPartDelta(sessionId, messageId, partId, field, delta, roleInfo); - break; - } - - case 'message.updated': { - const message = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record) : props; - const messageExt = message as Record; - - const resolvedSessionId = - readStringProp(messageExt, ['sessionID', 'sessionId']) || - readStringProp(props, ['sessionID', 'sessionId']); - - const resolvedMessageId = - readStringProp(messageExt, ['messageID', 'messageId', 'id']) || - readStringProp(props, ['messageID', 'messageId']); - - if (!resolvedSessionId || !resolvedMessageId) { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Skipping message.updated without resolvable session/message id', { - sessionID: messageExt.sessionID ?? props.sessionID, - messageID: messageExt.id ?? props.messageID, - }); - } - break; - } - - const sessionId = resolvedSessionId; - const messageId = resolvedMessageId; - - lastMessageEventBySessionRef.current.set(sessionId, Date.now()); - const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId); - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingMessageStallTimersRef.current.delete(sessionId); - } - - if (streamDebugEnabled()) { - try { - const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || []; - const textParts = Array.isArray(serverParts) - ? serverParts.filter((p: unknown) => (p as { type?: string })?.type === 'text') - : []; - const textJoined = textParts - .map((p: unknown) => { - const part = p as { text?: string; content?: string }; - return typeof part?.text === 'string' ? part.text : typeof part?.content === 'string' ? part.content : ''; - }) - .join('\n'); - console.info('[STREAM-TRACE] message.updated', { - messageId, - role: (messageExt as { role?: unknown }).role, - status: (messageExt as { status?: unknown }).status, - textLen: textJoined.length, - textPreview: textJoined.slice(0, 120), - partsCount: Array.isArray(serverParts) ? serverParts.length : 0, - }); - } catch { /* ignored */ } - } - - trackMessage(messageId, 'message_updated', { role: (messageExt as { role?: unknown }).role }); - - if ((messageExt as { role?: unknown }).role === 'user') { - // Update lastUserMessageAt in session memory state - const { sessionMemoryState } = useMessageStore.getState(); - const currentMemory = sessionMemoryState.get(sessionId); - if (currentMemory) { - const newMemoryState = new Map(sessionMemoryState); - newMemoryState.set(sessionId, { - ...currentMemory, - lastUserMessageAt: Date.now(), - }); - useMessageStore.setState({ sessionMemoryState: newMemoryState }); - } - - const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts; - const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : []; - const existingUserMessage = getMessageFromStore(sessionId, messageId); - - const agentCandidate = (() => { - const rawAgent = (messageExt as { agent?: unknown }).agent; - if (typeof rawAgent === 'string' && rawAgent.trim().length > 0) return rawAgent.trim(); - const rawMode = (messageExt as { mode?: unknown }).mode; - if (typeof rawMode === 'string' && rawMode.trim().length > 0) return rawMode.trim(); - return ''; - })(); - - const createdAt = (() => { - const rawTime = (messageExt as { time?: unknown }).time as { created?: unknown } | undefined; - const created = rawTime?.created; - return typeof created === 'number' ? created : null; - })(); - - const isSyntheticOnly = - partsArray.length > 0 && - partsArray.every((part) => (part as unknown as { synthetic?: boolean })?.synthetic === true); - - const shouldApplyUserAgentSelection = (() => { - if (!agentCandidate) return false; - - // Mode switches are server-injected synthetic user messages; always accept. - if (isSyntheticOnly && (agentCandidate === 'plan' || agentCandidate === 'build')) { - return true; - } - - if (currentSessionIdRef.current === sessionId) { - const explicitSelection = useContextStore.getState().getSessionAgentSelection(sessionId); - if (explicitSelection && explicitSelection !== agentCandidate) { - const status = useSessionStore.getState().sessionStatus?.get(sessionId); - const isBusy = status?.type === 'busy' || status?.type === 'retry'; - if (isBusy) { - return false; - } - } - } - - const last = lastUserAgentSelectionRef.current.get(sessionId); - if (!last) return true; - - if (createdAt === null) { - // If timestamp is missing, never allow it to override a newer selection. - return false; - } - - if (messageId === last.messageId) return true; - return createdAt >= last.created; - })(); - - if (agentCandidate && shouldApplyUserAgentSelection) { - try { - const agents = useConfigStore.getState().agents; - if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentCandidate)) { - const context = useContextStore.getState(); - context.saveSessionAgentSelection(sessionId, agentCandidate); - - lastUserAgentSelectionRef.current.set(sessionId, { - created: createdAt ?? Date.now(), - messageId, - }); - - if (currentSessionIdRef.current === sessionId) { - try { - useConfigStore.getState().setAgent(agentCandidate); - } catch { - // ignored - } - } - - const modelObj = (messageExt as { model?: { providerID?: unknown; modelID?: unknown } }).model; - const providerID = typeof modelObj?.providerID === 'string' ? modelObj.providerID : null; - const modelID = typeof modelObj?.modelID === 'string' ? modelObj.modelID : null; - if (providerID && modelID) { - context.saveSessionModelSelection(sessionId, providerID, modelID); - context.saveAgentModelForSession(sessionId, agentCandidate, providerID, modelID); - const variant = typeof (messageExt as { variant?: unknown }).variant === 'string' - ? (messageExt as { variant: string }).variant - : undefined; - context.saveAgentModelVariantForSession(sessionId, agentCandidate, providerID, modelID, variant); - - if (currentSessionIdRef.current === sessionId) { - try { - useConfigStore.getState().setProvider(providerID); - useConfigStore.getState().setModel(modelID); - } catch { - // ignored - } - } - } - } - } catch { - // ignored - } - } - - if ( - isSyntheticOnly && - (agentCandidate === 'plan' || agentCandidate === 'build') && - currentSessionIdRef.current === sessionId - ) { - const toastKey = `${sessionId}:${messageId}:${agentCandidate}`; - if (!modeSwitchToastShownRef.current.has(toastKey)) { - modeSwitchToastShownRef.current.add(toastKey); - import('sonner').then(({ toast }) => { - toast.info(agentCandidate === 'plan' ? 'Plan mode active' : 'Build mode active', { - description: agentCandidate === 'plan' - ? 'Edits restricted to plan file' - : 'You can now edit files', - duration: 5000, - }); - }); - } - } - - const userMessageInfo = { - ...message, - userMessageMarker: true, - clientRole: 'user', - ...(agentCandidate ? { mode: agentCandidate } : {}), - } as unknown as Message; - - updateMessageInfo(sessionId, messageId, userMessageInfo); - - // Some backends send user message updates without parts. Hydrate from session history. - if (!existingUserMessage && partsArray.length === 0) { - const hydrateKey = `${sessionId}:${messageId}`; - if (!missingMessageHydrationRef.current.has(hydrateKey)) { - missingMessageHydrationRef.current.add(hydrateKey); - void opencodeClient - .getSessionMessages(sessionId) - .then((messages) => { - useSessionStore.getState().syncMessages(sessionId, messages); - }) - .catch(() => { - // ignored - }); - } - } - - if (partsArray.length > 0) { - const directory = readEventDirectory(props); - for (let i = 0; i < partsArray.length; i++) { - const serverPart = partsArray[i]; - const isSynthetic = (serverPart as Record).synthetic === true; - if (isSynthetic) { - const text = (serverPart as { text?: unknown }).text; - const textStr = typeof text === 'string' ? text.trim() : ''; - const shouldKeep = - textStr.startsWith('User has requested to enter plan mode') || - textStr.startsWith('The plan at ') || - textStr.startsWith('The following tool was executed by the user'); - if (!shouldKeep) continue; - } - - const enrichedPart: Part = { - ...serverPart, - type: serverPart?.type || 'text', - sessionID: (serverPart as { sessionID?: string })?.sessionID || sessionId, - messageID: (serverPart as { messageID?: string })?.messageID || messageId, - } as Part; - if (typeof enrichedPart.id === 'string' && typeof enrichedPart.type === 'string') { - writePartTypeHint(`${directory}:${messageId}:${enrichedPart.id}`, enrichedPart.type); - } - addStreamingPart(sessionId, messageId, enrichedPart, 'user'); - } - } - - trackMessage(messageId, 'user_message_created_from_event', { partsCount: partsArray.length }); - break; - } - - const existingMessage = getMessageFromStore(sessionId, messageId); - const existingStopMarker = (existingMessage?.info as { finish?: string } | undefined)?.finish === 'stop'; - - const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts; - const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : []; - const hasParts = partsArray.length > 0; - const timeObj = (messageExt as { time?: { completed?: number } }).time || {}; - const completedFromServer = typeof timeObj?.completed === 'number'; - const rawStatus = (message as { status?: unknown }).status; - const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : null; - const hasCompletedStatus = status === 'completed' || status === 'complete'; - const finishCandidate = (message as { finish?: unknown }).finish; - const finish = typeof finishCandidate === 'string' ? finishCandidate : null; - const eventHasStopFinish = finish === 'stop'; - const eventHasErrorFinish = finish === 'error'; - - if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish && !eventHasErrorFinish) break; - - const messageInfoOnly = { ...messageExt } as Record; - delete messageInfoOnly.parts; - - updateMessageInfo(sessionId, messageId, messageInfoOnly as unknown as Message); - - const messageRole = typeof (message as { role?: unknown }).role === 'string' - ? (message as { role: string }).role - : null; - const runtimeAPIs = getRegisteredRuntimeAPIs(); - const shouldSynthesizeNotifications = Boolean(runtimeAPIs?.runtime?.isVSCode) && !serverNotificationEventSeenRef.current; - if (shouldSynthesizeNotifications && messageRole === 'assistant') { - const settings = useUIStore.getState(); - const sessionInfo = useSessionStore.getState().sessions.find((entry) => entry.id === sessionId); - const sessionTitle = typeof sessionInfo?.title === 'string' ? sessionInfo.title.trim() : ''; - - if (eventHasStopFinish && settings.notifyOnCompletion !== false) { - const isSubtask = Boolean(sessionInfo?.parentID); - if (!(settings.notifyOnSubtasks === false && isSubtask)) { - const notificationKey = `ready:${sessionId}:${messageId}`; - if (!notifiedMessagesRef.current.has(notificationKey)) { - notifiedMessagesRef.current.add(notificationKey); - dispatchRuntimeNotification({ - title: 'Agent is ready', - body: sessionTitle || 'Task completed', - tag: `ready-${sessionId}`, - }); - } - } - } - - if (eventHasErrorFinish && settings.notifyOnError !== false) { - const notificationKey = `error:${sessionId}:${messageId}`; - if (!notifiedMessagesRef.current.has(notificationKey)) { - notifiedMessagesRef.current.add(notificationKey); - dispatchRuntimeNotification({ - title: 'Tool error', - body: sessionTitle || 'An error occurred', - tag: `error-${sessionId}`, - }); - } - } - } - - const messageTime = (message as { time?: { completed?: unknown } }).time; - const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed; - const hasCompletedTimestamp = typeof completedCandidate === 'number' && Number.isFinite(completedCandidate); - - const stopMarkerPresent = finish === 'stop' || existingStopMarker; - - const shouldFinalizeAssistantMessage = - (message as { role?: string }).role === 'assistant' && - (hasCompletedTimestamp || hasCompletedStatus || stopMarkerPresent); - - if (shouldFinalizeAssistantMessage && (message as { role?: string }).role === 'assistant') { - - const storeState = useSessionStore.getState(); - const sessionMessages = storeState.messages.get(sessionId) || []; - let latestAssistantMessageId: string | null = null; - let maxId = ''; - - for (let i = 0; i < sessionMessages.length; i++) { - const msg = sessionMessages[i]; - if (msg.info.role === 'assistant' && msg.info.id > maxId) { - maxId = msg.info.id; - latestAssistantMessageId = msg.info.id; - } - } - - const isActiveSession = currentSessionId === sessionId; - if (isActiveSession && messageId !== latestAssistantMessageId) break; - - const timeCompleted = - hasCompletedTimestamp - ? (completedCandidate as number) - : Date.now(); - - if (!hasCompletedTimestamp) { - updateMessageInfo(sessionId, messageId, { - ...message, - time: { ...(messageTime ?? {}), completed: timeCompleted }, - } as unknown as Message); - } - - trackMessage(messageId, 'completed', { timeCompleted }); - reportMessage(messageId); - - void saveSessionCursor(sessionId, messageId, timeCompleted); - - completeStreamingMessage(sessionId, messageId); - repairSessionDerivedState('assistant_message_completed'); - - const rawMessageSessionId = (message as { sessionID?: string }).sessionID; - const messageSessionId: string = - typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0 - ? rawMessageSessionId - : sessionId; - requestSessionMetadataRefresh( - messageSessionId, - typeof props.directory === 'string' ? props.directory : null, - ); - - - const summaryInfo = message as Message & { summary?: boolean }; - if (summaryInfo.summary && typeof messageSessionId === 'string') { - updateSessionCompaction(messageSessionId, null); - } - } - break; - } - - case 'session.created': - case 'session.updated': { - const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record : - (typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record : - (typeof props.session === 'object' && props.session !== null) ? props.session as Record : props; - - const sessionId = (typeof candidate.id === 'string' && candidate.id.length > 0) ? candidate.id : - (typeof candidate.sessionID === 'string' && candidate.sessionID.length > 0) ? candidate.sessionID : - (typeof props.sessionID === 'string' && props.sessionID.length > 0) ? props.sessionID : - (typeof props.id === 'string' && props.id.length > 0) ? props.id : undefined; - - if (sessionId) { - const timeSource = (typeof candidate.time === 'object' && candidate.time !== null) ? candidate.time as Record : - (typeof props.time === 'object' && props.time !== null) ? props.time as Record : null; - const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null; - updateSessionCompaction(sessionId, compactingTimestamp); - - const sessionDirectory = typeof (candidate as { directory?: unknown }).directory === 'string' - ? (candidate as { directory: string }).directory - : typeof props.directory === 'string' - ? (props.directory as string) - : null; - - const patchedSession = { - ...(candidate as unknown as Record), - id: sessionId, - ...(sessionDirectory ? { directory: sessionDirectory } : {}), - } as unknown as Session; - - updateSession(patchedSession); - } - break; - } - - case 'session.deleted': { - const sessionId = typeof props.sessionID === 'string' - ? props.sessionID - : typeof props.id === 'string' - ? props.id - : null; - if (sessionId) { - removeSessionFromStore(sessionId); - } - break; - } - - case 'session.abort': { - const sessionId = - typeof props.sessionID === 'string' && (props.sessionID as string).length > 0 - ? (props.sessionID as string) - : null; - const messageId = - typeof props.messageID === 'string' && (props.messageID as string).length > 0 - ? (props.messageID as string) - : null; - - if (sessionId) { - updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.abort'); - } - if (sessionId && messageId) { - completeStreamingMessage(sessionId, messageId); - } - break; - } - - case 'permission.asked': { - const request = normalizePermissionRequest(props); - if (!request) { - break; - } - - addPermission(request); - - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (runtimeAPIs?.runtime?.isVSCode && !serverNotificationEventSeenRef.current) { - const settings = useUIStore.getState(); - if (settings.notifyOnQuestion !== false) { - const notificationKey = `permission:${request.sessionID}:${request.id}`; - if (!notifiedQuestionsRef.current.has(notificationKey)) { - notifiedQuestionsRef.current.add(notificationKey); - const sessionTitle = - useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || - 'Agent is waiting for your approval'; - dispatchRuntimeNotification({ - title: 'Permission required', - body: sessionTitle, - tag: `permission-${request.sessionID}:${request.id}`, - }); - } - } - } - - // Notify if permission is for another session (common with child sessions). - const toastKey = `${request.sessionID}:${request.id}`; - if (!permissionToastShownRef.current.has(toastKey)) { - setTimeout(() => { - const current = currentSessionIdRef.current; - if (current === request.sessionID) { - return; - } - - const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID); - if (requestSession?.parentID && requestSession.parentID === current) { - return; - } - - const pending = useSessionStore - .getState() - .permissions - .get(request.sessionID) - ?.some((entry) => entry.id === request.id); - - if (!pending) { - return; - } - - permissionToastShownRef.current.add(toastKey); - - const sessionTitle = - useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || - 'Session'; - const permissionBody = buildPermissionToastBody(request); - - import('sonner').then(({ toast }) => { - const isMobile = useUIStore.getState().isMobile; - - if (isMobile) { - toast.warning('Permission required', { - id: toastKey, - description: sessionTitle, - duration: 30000, - action: { - label: 'Open', - onClick: () => { - useUIStore.getState().setActiveMainTab('chat'); - void useSessionStore.getState().setCurrentSession(request.sessionID); - }, - }, - }); - } else { - toast.warning('Permission required', { - id: toastKey, - description: React.createElement(PermissionToastActions, { - sessionTitle, - permissionBody, - onOnce: async () => { - try { - await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'once'); - toast.dismiss(toastKey); - } catch (error) { - console.error('Failed to respond to permission:', error); - } - }, - onAlways: async () => { - try { - await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'always'); - toast.dismiss(toastKey); - } catch (error) { - console.error('Failed to respond to permission:', error); - } - }, - onDeny: async () => { - try { - await useSessionStore.getState().respondToPermission(request.sessionID, request.id, 'reject'); - toast.dismiss(toastKey); - } catch (error) { - console.error('Failed to respond to permission:', error); - } - }, - }), - duration: 30000, - }); - } - }); - - }, 0); - } - - break; - } - - case 'permission.replied': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const requestId = - typeof props.requestID === 'string' ? props.requestID : - typeof props.id === 'string' ? props.id : null; - if (sessionId && requestId) { - dismissPermission(sessionId, requestId); - } - break; - } - - case 'question.asked': { - if (!('sessionID' in props) || typeof props.sessionID !== 'string') { - break; - } - - const request = props as unknown as QuestionRequest; - addQuestion(request); - - const runtimeAPIs = getRegisteredRuntimeAPIs(); - if (runtimeAPIs?.runtime?.isVSCode && !serverNotificationEventSeenRef.current) { - const settings = useUIStore.getState(); - if (settings.notifyOnQuestion !== false) { - const notificationKey = `question:${request.sessionID}:${request.id}`; - if (!notifiedQuestionsRef.current.has(notificationKey)) { - notifiedQuestionsRef.current.add(notificationKey); - const firstQuestion = Array.isArray(request.questions) ? request.questions[0] : undefined; - const questionHeader = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; - const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; - dispatchRuntimeNotification({ - title: questionHeader || 'Input needed', - body: questionText || 'Agent is waiting for your response', - tag: `question-${request.sessionID}:${request.id}`, - }); - } - } - } - - const toastKey = `${request.sessionID}:${request.id}`; - - // web/desktop use server-emitted notifications; VS Code may synthesize locally - - if (!questionToastShownRef.current.has(toastKey)) { - setTimeout(() => { - const current = currentSessionIdRef.current; - if (current === request.sessionID) { - return; - } - - const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID); - if (requestSession?.parentID && requestSession.parentID === current) { - return; - } - - const pending = useSessionStore - .getState() - .questions - .get(request.sessionID) - ?.some((entry) => entry.id === request.id); - - if (!pending) { - return; - } - - questionToastShownRef.current.add(toastKey); - - const sessionTitle = - useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title || - 'Session'; - - import('sonner').then(({ toast }) => { - toast.info('Input needed', { - id: toastKey, - description: sessionTitle, - duration: 30000, - action: { - label: 'Open', - onClick: () => { - useUIStore.getState().setActiveMainTab('chat'); - void useSessionStore.getState().setCurrentSession(request.sessionID); - }, - }, - }); - }); - }, 0); - } - - break; - } - - case 'question.replied': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const requestId = typeof props.requestID === 'string' ? props.requestID : null; - if (sessionId && requestId) { - dismissQuestion(sessionId, requestId); - } - break; - } - - case 'question.rejected': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const requestId = typeof props.requestID === 'string' ? props.requestID : null; - if (sessionId && requestId) { - dismissQuestion(sessionId, requestId); - } - break; - } - - case 'openchamber:notification': { - serverNotificationEventSeenRef.current = true; - const title = typeof (props as { title?: unknown }).title === 'string' ? (props as { title: string }).title : ''; - const body = typeof (props as { body?: unknown }).body === 'string' ? (props as { body: string }).body : ''; - const tag = typeof (props as { tag?: unknown }).tag === 'string' ? (props as { tag: string }).tag : undefined; - const requireHidden = Boolean((props as { requireHidden?: unknown }).requireHidden); - - // When the sidecar stdout notification channel is active (production desktop builds), - // skip this SSE notification to avoid duplicating the native notification already - // shown by the Tauri process. In dev mode the stdout channel is not available, - // so we fall through and let the UI handle it via Tauri IPC. - if (isDesktopLocalOriginActive() && Boolean((props as { desktopStdoutActive?: unknown }).desktopStdoutActive)) { - break; - } - - dispatchRuntimeNotification({ title, body, tag, requireHidden }); - - break; - } - - case 'todo.updated': { - const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null; - const todos = Array.isArray(props.todos) ? props.todos : null; - if (sessionId && todos) { - handleTodoUpdatedEvent( - sessionId, - todos as Array<{ id: string; content: string; status: string; priority: string }> - ); - } - break; - } - } - }, [ - currentSessionId, - addStreamingPart, - applyPartDelta, - completeStreamingMessage, - updateMessageInfo, - addPermission, - dismissPermission, - addQuestion, - dismissQuestion, - checkConnection, - requestSessionMetadataRefresh, - updateSessionCompaction, - applySessionMetadata, - trackMessage, - reportMessage, - requestPendingQuestionsRefresh, - - updateSession, - removeSessionFromStore, - bootstrapState, - effectiveDirectory, - updateSessionStatus, - updateSessionActivityPhase, - repairSessionDerivedState, - dispatchRuntimeNotification, - emitGitRefreshHint, - writePartTypeHint, - ]); - - // --- Stable callback refs (Part A) --- - // Keep refs up to date with the latest version of each callback. - // This lets startStream use stable wrappers with empty deps so SSE connections - // are NOT torn down on every session switch. - const handleEventRef = React.useRef(handleEvent); - React.useEffect(() => { - handleEventRef.current = handleEvent; - }, [handleEvent]); - - const bootstrapStateRef = React.useRef(bootstrapState); - React.useEffect(() => { - bootstrapStateRef.current = bootstrapState; - }, [bootstrapState]); - - // Stable wrappers — identity never changes, so startStream deps stay minimal. - const stableHandleEvent = React.useCallback((event: EventData) => { - handleEventRef.current(event); - }, []); // intentionally empty deps - - const stableBootstrapState = React.useCallback((reason: string) => { - return bootstrapStateRef.current(reason); - }, []); // intentionally empty deps - - const shouldHoldConnection = React.useCallback(() => { - const currentVisibility = resolveVisibilityState(); - visibilityStateRef.current = currentVisibility; - return currentVisibility === 'visible' && onlineStatusRef.current; - }, [resolveVisibilityState]); - - const debugConnectionState = React.useCallback(() => { - if (streamDebugEnabled()) { - console.debug('[useEventStream] Connection state:', { - hasUnsubscribe: Boolean(unsubscribeRef.current), - currentSessionId: currentSessionIdRef.current, - effectiveDirectory, - onlineStatus: onlineStatusRef.current, - visibilityState: visibilityStateRef.current, - lastEventTimestamp: lastEventTimestampRef.current, - reconnectAttempts: reconnectAttemptsRef.current, - }); - } - }, [effectiveDirectory]); - - const stopStream = React.useCallback(() => { - if (isCleaningUpRef.current) { - if (streamDebugEnabled()) { - console.info('[useEventStream] Already cleaning up, skipping stopStream'); - } - return; - } - - isCleaningUpRef.current = true; - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - - if (unsubscribeRef.current) { - const unsubscribe = unsubscribeRef.current; - unsubscribeRef.current = null; - try { - - unsubscribe(); - } catch (error) { - console.warn('[useEventStream] Error during unsubscribe:', error); - } - } - - isCleaningUpRef.current = false; - }, []); - - const startStream = React.useCallback(async (options?: { resetAttempts?: boolean }) => { - debugConnectionState(); - - if (!shouldHoldConnection()) { - pendingResumeRef.current = true; - if (!onlineStatusRef.current) { - publishStatus('offline', 'Waiting for network'); - } else { - publishStatus('paused', 'Paused while hidden'); - } - return; - } - - if (options?.resetAttempts) { - reconnectAttemptsRef.current = 0; - } - - stopStream(); - lastEventTimestampRef.current = Date.now(); - publishStatus('connecting', null); - - if (streamDebugEnabled()) { - console.info('[useEventStream] Starting event stream...'); - } - - const onError = (error: unknown) => { - console.warn('Event stream error:', error); - - }; - - const onOpen = () => { - const shouldRefresh = pendingResumeRef.current; - reconnectAttemptsRef.current = 0; - pendingResumeRef.current = false; - lastEventTimestampRef.current = Date.now(); - publishStatus('connected', null); - checkConnection(); - repairSessionDerivedState('stream_open'); - - requestPendingPermissionsRefreshRef.current(shouldRefresh); - - if (shouldRefresh) { - void stableBootstrapState('sse_reconnected'); - } else { - const sessionId = currentSessionIdRef.current; - if (sessionId) { - setTimeout(() => { - scheduleSoftResyncRef.current(sessionId, 'sse_reconnected', getMessageLimit()) - .then(() => requestSessionMetadataRefresh(sessionId)) - .catch((error: unknown) => { - console.warn('[useEventStream] Failed to resync messages after reconnect:', error); - }); - }, 0); - } - } - }; - - if (streamDebugEnabled()) { - console.info('[useEventStream] Connecting to event source (SDK SSE only):', { - effectiveDirectory, - isCleaningUp: isCleaningUpRef.current, - }); - } - - if (isCleaningUpRef.current) { - if (streamDebugEnabled()) { - console.info('[useEventStream] Skipping subscription due to cleanup in progress'); - } - return; - } - - try { - const sdkUnsub = opencodeClient.subscribeToGlobalEvents( - (event: RoutedOpencodeEvent) => { - const payload = event.payload as unknown as EventData; - const payloadRecord = event.payload as unknown as Record; - const baseProperties = - typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null - ? (payloadRecord.properties as Record) - : {}; - - const properties = - event.directory && event.directory !== 'global' - ? { ...baseProperties, directory: event.directory } - : baseProperties; - - stableHandleEvent({ - type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '', - properties, - }); - }, - onError, - onOpen, - ); - - - const compositeUnsub = () => { - try { - sdkUnsub(); - } catch (cleanupError) { - console.warn('[useEventStream] Error during unsubscribe:', cleanupError); - } - }; - - if (!isCleaningUpRef.current) { - unsubscribeRef.current = compositeUnsub; - } else { - compositeUnsub(); - } - } catch (subscriptionError) { - console.error('[useEventStream] Error during subscription:', subscriptionError); - onError(subscriptionError); - } - }, [ - shouldHoldConnection, - stopStream, - publishStatus, - checkConnection, - requestSessionMetadataRefresh, - stableHandleEvent, - stableBootstrapState, - repairSessionDerivedState, - effectiveDirectory, - debugConnectionState, - ]); - - const scheduleReconnect = React.useCallback((hint?: string) => { - if (!shouldHoldConnection()) { - pendingResumeRef.current = true; - stopStream(); - if (!onlineStatusRef.current) { - publishStatus('offline', 'Waiting for network'); - } else { - publishStatus('paused', 'Paused while hidden'); - } - return; - } - - if (reconnectTimeoutRef.current) { - return; - } - - const nextAttempt = reconnectAttemptsRef.current + 1; - reconnectAttemptsRef.current = nextAttempt; - const statusHint = hint ?? `Retrying (${nextAttempt})`; - publishStatus('reconnecting', statusHint); - - const baseDelay = nextAttempt <= 3 - ? Math.min(1000 * Math.pow(2, nextAttempt - 1), 8000) - : Math.min(2000 * Math.pow(2, nextAttempt - 3), 32000); - const jitter = Math.floor(Math.random() * 250); - const delay = baseDelay + jitter; - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - - reconnectTimeoutRef.current = setTimeout(() => { - startStream({ resetAttempts: false }); - }, delay); - }, [shouldHoldConnection, stopStream, publishStatus, startStream]); - - React.useEffect(() => { - scheduleReconnectRef.current = scheduleReconnect; - }, [scheduleReconnect]); - - React.useEffect(() => { - if (!enabled) { - stopStream(); - publishStatus('idle', null); - return; - } - - if (typeof window !== 'undefined') { - window.__messageTracker = trackMessage; - } - - // No-op - - const desktopActivityHandler = null; - - const clearPauseTimeout = () => { - if (pauseTimeoutRef.current) { - clearTimeout(pauseTimeoutRef.current); - pauseTimeoutRef.current = null; - } - }; - - const handleVisibilityChange = () => { - visibilityStateRef.current = resolveVisibilityState(); - - if (visibilityStateRef.current !== 'visible') { - // Keep SSE connection alive while hidden; browsers may briefly toggle - // visibility during tab/window transitions. - return; - } - - clearPauseTimeout(); - maybeBootstrapIfStale('visibility_restore'); - repairSessionDerivedState('visibility_restore'); - - const isStalled = Date.now() - lastEventTimestampRef.current > 45000; - if (isStalled) { - console.info('[useEventStream] Visibility restored with stalled stream, reconnecting...'); - pendingResumeRef.current = true; - } - - if (pendingResumeRef.current || !unsubscribeRef.current) { - console.info('[useEventStream] Visibility restored, triggering soft refresh...'); - const sessionId = currentSessionIdRef.current; - if (sessionId) { - scheduleSoftResync(sessionId, 'visibility_restore', getMessageLimit()); - requestSessionMetadataRefresh(sessionId); - } - requestPendingPermissionsRefreshRef.current(false); - repairSessionDerivedState('visibility_restore_resume'); - publishStatus('connecting', 'Resuming stream'); - startStream({ resetAttempts: true }); - } - }; - - const handleWindowFocus = () => { - visibilityStateRef.current = resolveVisibilityState(); - - if (visibilityStateRef.current === 'visible') { - clearPauseTimeout(); - maybeBootstrapIfStale('window_focus'); - repairSessionDerivedState('window_focus'); - - const isStalled = Date.now() - lastEventTimestampRef.current > 45000; - if (isStalled) { - pendingResumeRef.current = true; - } - - if (pendingResumeRef.current || !unsubscribeRef.current) { - console.info('[useEventStream] Window focused after pause, triggering soft refresh...'); - const sessionId = currentSessionIdRef.current; - if (sessionId) { - requestSessionMetadataRefresh(sessionId); - scheduleSoftResync(sessionId, 'window_focus', getMessageLimit()); - } - requestPendingPermissionsRefreshRef.current(false); - repairSessionDerivedState('window_focus_resume'); - - publishStatus('connecting', 'Resuming stream'); - startStream({ resetAttempts: true }); - } - } - }; - - const handleOnline = () => { - onlineStatusRef.current = true; - maybeBootstrapIfStale('network_restored'); - repairSessionDerivedState('network_restored'); - requestPendingPermissionsRefreshRef.current(false); - if (pendingResumeRef.current || !unsubscribeRef.current) { - publishStatus('connecting', 'Network restored'); - startStream({ resetAttempts: true }); - } - }; - - const handleOffline = () => { - onlineStatusRef.current = false; - pendingResumeRef.current = true; - publishStatus('offline', 'Waiting for network'); - stopStream(); - }; - - const handlePageHide = () => { - pendingResumeRef.current = true; - stopStream(); - publishStatus('paused', 'Paused while hidden'); - }; - - const handlePageShow = (event: PageTransitionEvent) => { - // If page was restored from bfcache, SSE is definitely gone. - pendingResumeRef.current = pendingResumeRef.current || Boolean(event.persisted); - visibilityStateRef.current = resolveVisibilityState(); - if (visibilityStateRef.current === 'visible') { - const sessionId = currentSessionIdRef.current; - if (sessionId) { - void scheduleSoftResync(sessionId, 'page_show', getMessageLimit()); - requestSessionMetadataRefresh(sessionId); - } - requestPendingPermissionsRefreshRef.current(false); - repairSessionDerivedState('page_show'); - startStream({ resetAttempts: true }); - } - }; - - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', handleVisibilityChange); - } - - if (typeof window !== 'undefined') { - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - window.addEventListener('focus', handleWindowFocus); - window.addEventListener('pagehide', handlePageHide); - window.addEventListener('pageshow', handlePageShow as EventListener); - } - - const startTimer = setTimeout(() => { - startStream({ resetAttempts: true }); - }, 100); - - if (staleCheckIntervalRef.current) { - clearInterval(staleCheckIntervalRef.current); - } - - staleCheckIntervalRef.current = setInterval(() => { - if (!shouldHoldConnection()) return; - - const now = Date.now(); - const hasBusySessions = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some( - (status) => status?.type === 'busy' || status?.type === 'retry' - ); - - if (hasBusySessions) { - repairSessionDerivedState('stale_check_busy_sessions'); - } - if (now - lastEventTimestampRef.current > 45000) { - Promise.resolve().then(async () => { - try { - const healthy = await opencodeClient.checkHealth(); - if (!healthy) { - scheduleReconnect('Refreshing stalled stream'); - return; - } - - // If health is ok but SSE has been silent (including heartbeat), - // treat it as a stalled connection and reconnect. - scheduleReconnect('Refreshing stalled stream'); - } catch (error) { - console.warn('Health check after stale stream failed:', error); - scheduleReconnect('Refreshing stalled stream'); - } - }); - } - }, 10000); - - // Part B: Idle timeout recovery — scan for sessions stuck in 'busy'/'retry' - // with no recent SSE events and force-reset them to 'idle'. - const stuckCheckInterval = setInterval(() => { - const sessionStatus = useSessionStore.getState().sessionStatus; - if (!sessionStatus) return; - const now = Date.now(); - sessionStatus.forEach((status, sessionId) => { - if (status.type !== 'busy' && status.type !== 'retry') return; - const lastMsgAt = lastMessageEventBySessionRef.current.get(sessionId) ?? 0; - const busyTooLong = now - lastMsgAt > STUCK_SESSION_TIMEOUT_MS; - const noRecentEvents = now - lastMsgAt > 60000; - if (busyTooLong && noRecentEvents) { - console.warn('[useEventStream] Session stuck in busy state, forcing idle:', sessionId); - updateSessionStatus(sessionId, { type: 'idle' }, 'timeout_recovery'); - } - }); - }, 30000); // check every 30s - - return () => { - clearTimeout(startTimer); - clearInterval(stuckCheckInterval); - - void desktopActivityHandler; - - if (typeof document !== 'undefined') { - document.removeEventListener('visibilitychange', handleVisibilityChange); - } - - if (typeof window !== 'undefined') { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - window.removeEventListener('focus', handleWindowFocus); - window.removeEventListener('pagehide', handlePageHide); - window.removeEventListener('pageshow', handlePageShow as EventListener); - } - - clearPauseTimeout(); - - if (staleCheckIntervalRef.current) { - clearInterval(staleCheckIntervalRef.current); - staleCheckIntervalRef.current = null; - } - - clearSessionActivityTimers(); - - messageCache.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time - notifiedMessagesRef.current.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time - notifiedQuestionsRef.current.clear(); - serverNotificationEventSeenRef.current = false; - - pendingResumeRef.current = false; - visibilityStateRef.current = resolveVisibilityState(); - onlineStatusRef.current = typeof navigator === 'undefined' ? true : navigator.onLine; - - stopStream(); - - if (sessionRefreshTimeoutRef.current) { - clearTimeout(sessionRefreshTimeoutRef.current); - sessionRefreshTimeoutRef.current = null; - } - - publishStatus('idle', null); - }; - }, [ - enabled, - effectiveDirectory, - trackMessage, - resolveVisibilityState, - stopStream, - publishStatus, - startStream, - scheduleReconnect, - loadMessages, - requestSessionMetadataRefresh, - refreshSessionActivityStatus, - clearSessionActivityTimers, - repairSessionDerivedState, - - - shouldHoldConnection, - loadSessions, - maybeBootstrapIfStale, - resyncMessages, - scheduleSoftResync, - updateSessionStatus, - ]); -}; diff --git a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts index 1b2fb61e..94056273 100644 --- a/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts +++ b/packages/ui/src/hooks/useGitHubPrBackgroundTracking.ts @@ -1,11 +1,13 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import type { RuntimeAPIs } from '@/lib/api/types'; +import { mapWithConcurrency } from '@/lib/concurrency'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; const MAX_BACKGROUND_PR_DIRECTORIES = 20; const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000; @@ -94,34 +96,6 @@ const prioritizeDirectoriesForFetch = ( }); }; -const mapWithConcurrency = async ( - values: T[], - concurrency: number, - mapper: (value: T) => Promise, -): Promise => { - if (values.length === 0) { - return []; - } - - const safeConcurrency = Math.max(1, Math.min(concurrency, values.length)); - const results = new Array(values.length); - let cursor = 0; - - const worker = async () => { - while (true) { - const nextIndex = cursor; - cursor += 1; - if (nextIndex >= values.length) { - return; - } - results[nextIndex] = await mapper(values[nextIndex]); - } - }; - - await Promise.all(Array.from({ length: safeConcurrency }, () => worker())); - return results; -}; - const toPrTargets = (cache: Map, directories: string[]): PrTarget[] => { const result: PrTarget[] = []; directories.forEach((directory) => { @@ -144,10 +118,9 @@ export const useGitHubPrBackgroundTracking = ( ): void => { const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const projects = useProjectsStore((state) => state.projects); - const sessions = useSessionStore((state) => state.sessions); - const archivedSessions = useSessionStore((state) => state.archivedSessions); - const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); - const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata); + const sessions = useSessions(); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); @@ -229,7 +202,7 @@ export const useGitHubPrBackgroundTracking = ( add(metadata.path); }); - [...sessions, ...archivedSessions] + [...sessions] .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) .forEach((rawSession) => { const session = rawSession as SessionLike; @@ -238,7 +211,7 @@ export const useGitHubPrBackgroundTracking = ( }); return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES); - }, [archivedSessions, availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]); + }, [availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]); React.useEffect(() => { let cancelled = false; @@ -275,7 +248,7 @@ export const useGitHubPrBackgroundTracking = ( STATUS_FETCH_CONCURRENCY, async (directory) => { try { - const status = await git.getGitStatus(directory); + const status = await git.getGitStatus(directory, { mode: 'light' }); const branch = typeof status.current === 'string' ? status.current.trim() : ''; return { directory, @@ -383,7 +356,11 @@ export const useGitHubPrBackgroundTracking = ( } }; - void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME }); + // Delay initial PR tracking to avoid startup CPU burst + const startupDelayId = window.setTimeout(() => { + if (cancelled) return; + void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME }); + }, 5_000); const intervalId = window.setInterval(() => { if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { @@ -442,6 +419,7 @@ export const useGitHubPrBackgroundTracking = ( return () => { cancelled = true; + window.clearTimeout(startupDelayId); window.clearInterval(intervalId); window.removeEventListener('focus', refreshOnResume); document.removeEventListener('visibilitychange', refreshOnResume); diff --git a/packages/ui/src/hooks/useGitPollingHook.ts b/packages/ui/src/hooks/useGitPollingHook.ts index c38ffd2c..004e8df5 100644 --- a/packages/ui/src/hooks/useGitPollingHook.ts +++ b/packages/ui/src/hooks/useGitPollingHook.ts @@ -2,7 +2,8 @@ import React from 'react'; import { useGitStore } from '@/stores/useGitStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions, useSessionStatus } from '@/sync/sync-context'; /** * Background git polling hook - monitors git status regardless of which tab is open. @@ -20,8 +21,17 @@ export function useGitPolling() { const { git } = useRuntimeAPIs(); const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); - const { currentSessionId, sessions, worktreeMetadata: worktreeMap, sessionStatus } = useSessionStore(); - const { setActiveDirectory, startPolling, setPollingMode, stopPolling, fetchAll, fetchStatus, clearDiffCache } = useGitStore(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const sessions = useSessions(); + const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata); + const currentStatus = useSessionStatus(currentSessionId ?? ''); + const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); + const startPolling = useGitStore((state) => state.startPolling); + const setPollingMode = useGitStore((state) => state.setPollingMode); + const stopPolling = useGitStore((state) => state.stopPolling); + const fetchAll = useGitStore((state) => state.fetchAll); + const fetchStatus = useGitStore((state) => state.fetchStatus); + const clearDiffCache = useGitStore((state) => state.clearDiffCache); const immediateRefreshTimerRef = React.useRef | null>(null); const lastImmediateRefreshAtRef = React.useRef(0); @@ -40,12 +50,12 @@ export function useGitPolling() { if (!currentSessionId) { return 'idle'; } - const activeStatus = sessionStatus?.get(currentSessionId)?.type; + const activeStatus = currentStatus?.type; if (activeStatus === 'busy' || activeStatus === 'retry') { return activeStatus; } return 'idle'; - }, [currentSessionId, sessionStatus]); + }, [currentSessionId, currentStatus]); const pollingMode = activeSessionStatus === 'busy' || activeSessionStatus === 'retry' ? 'busy' : 'normal'; @@ -84,7 +94,7 @@ export function useGitPolling() { immediateRefreshTimerRef.current = null; lastImmediateRefreshAtRef.current = Date.now(); void (async () => { - const statusChanged = await fetchStatus(targetDirectory, git, { silent: true }); + const statusChanged = await fetchStatus(targetDirectory, git, { silent: true, mode: 'light' }); if (shouldForceDiffRefresh && !statusChanged) { clearDiffCache(targetDirectory); } diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 43ee4671..88ff4735 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,5 +1,7 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; import { useUIStore } from '@/stores/useUIStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; @@ -10,24 +12,26 @@ import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; export const useKeyboardShortcuts = () => { - const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore(); - const { - toggleCommandPalette, - toggleHelpDialog, - toggleSidebar, - toggleRightSidebar, - setRightSidebarOpen, - setRightSidebarTab, - toggleBottomTerminal, - setBottomTerminalExpanded, - isMobile, - setSessionSwitcherOpen, - setActiveMainTab, - setSettingsDialogOpen, - setModelSelectorOpen, - toggleExpandedInput, - shortcutOverrides, - } = useUIStore(); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const armAbortPrompt = useSessionUIStore((s) => s.armAbortPrompt); + const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const abortCurrentOperation = sessionActions.abortCurrentOperation;; + const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); + const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); + const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); + const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen); + const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab); + const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal); + const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded); + const isMobile = useUIStore((s) => s.isMobile); + const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); + const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); + const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen); + const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput); + const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); const { themeMode, setThemeMode } = useThemeSystem(); const { working } = useAssistantStatus(); const abortPrimedUntilRef = React.useRef(null); @@ -108,13 +112,6 @@ export const useKeyboardShortcuts = () => { return; } - if (eventMatchesShortcut(e, combo('open_timeline'))) { - e.preventDefault(); - const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState(); - setTimelineDialogOpen(!isTimelineDialogOpen); - return; - } - if (eventMatchesShortcut(e, combo('open_settings'))) { e.preventDefault(); const { isSettingsDialogOpen } = useUIStore.getState(); @@ -270,14 +267,13 @@ export const useKeyboardShortcuts = () => { configState.cycleCurrentVariant(); const nextVariant = useConfigStore.getState().currentVariant; - const sessionState = useSessionStore.getState(); - const sessionId = sessionState.currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; const agentName = useConfigStore.getState().currentAgentName; const providerId = useConfigStore.getState().currentProviderId; const modelId = useConfigStore.getState().currentModelId; if (sessionId && agentName && providerId && modelId) { - sessionState.saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); + useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); } return; @@ -387,7 +383,7 @@ export const useKeyboardShortcuts = () => { if (primedUntil && now < primedUntil) { e.preventDefault(); resetAbortPriming(); - void abortCurrentOperation(sessionId || undefined); + void abortCurrentOperation(sessionId ?? ''); return; } diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index d71ac08e..1fed8542 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -1,6 +1,6 @@ import React from 'react'; import { toast } from '@/components/ui'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; @@ -48,16 +48,14 @@ type MenuAction = export const useMenuActions = ( onToggleMemoryDebug?: () => void ) => { - const { openNewSessionDraft } = useSessionStore(); - const { - toggleCommandPalette, - toggleHelpDialog, - toggleSidebar, - setSessionSwitcherOpen, - setActiveMainTab, - setSettingsDialogOpen, - setAboutDialogOpen, - } = useUIStore(); + const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); + const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); + const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); + const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); + const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); + const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); const { addProject } = useProjectsStore(); const checkForUpdates = useUpdateStore((state) => state.checkForUpdates); const { requestAccess, startAccessing } = useFileSystemAccess(); diff --git a/packages/ui/src/hooks/usePwaManifestSync.ts b/packages/ui/src/hooks/usePwaManifestSync.ts index d2770dbc..4d3ce7b5 100644 --- a/packages/ui/src/hooks/usePwaManifestSync.ts +++ b/packages/ui/src/hooks/usePwaManifestSync.ts @@ -1,5 +1,6 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessions } from '@/sync/sync-context'; import { isWebRuntime } from '@/lib/desktop'; import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa'; @@ -60,8 +61,8 @@ const buildRecentShortcuts = ( }; export const usePwaManifestSync = () => { - const sessions = useSessionStore((state) => state.sessions); - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessions = useSessions(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const recentShortcuts = React.useMemo(() => { return buildRecentShortcuts(sessions, currentSessionId); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 044ae412..df3241ac 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -1,32 +1,26 @@ import React from 'react'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useMessageStore } from '@/stores/messageStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; +import { getSyncSessionStatus } from '@/sync/sync-refs'; +import { useDirectorySync } from '@/sync/sync-context'; type SessionStatusType = 'idle' | 'busy' | 'retry'; const RECENT_ABORT_WINDOW_MS = 2000; const hasRecentAbort = (sessionId: string): boolean => { - const abortRecord = useSessionStore.getState().sessionAbortFlags.get(sessionId); + const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId); if (!abortRecord) { return false; } return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS; }; -const setSessionStatus = (sessionId: string, type: SessionStatusType) => { - useSessionStore.setState((state) => { - const next = new Map(state.sessionStatus ?? new Map()); - next.set(sessionId, { type }); - return { sessionStatus: next }; - }); -}; - const buildQueuedPayload = (queue: QueuedMessage[]) => { const agents = useConfigStore.getState().getVisibleAgents(); let primaryText = ''; @@ -64,7 +58,7 @@ const buildQueuedPayload = (queue: QueuedMessage[]) => { const resolveSessionSendConfig = (sessionId: string) => { const context = useContextStore.getState(); const config = useConfigStore.getState(); - const message = useMessageStore.getState(); + const selection = useSelectionStore.getState(); const selectedAgent = context.getSessionAgentSelection(sessionId) @@ -81,16 +75,17 @@ const resolveSessionSendConfig = (sessionId: string) => { agentModel?.providerId ?? sessionModel?.providerId ?? config.currentProviderId - ?? message.lastUsedProvider?.providerID; + ?? selection.lastUsedProvider?.providerID; const modelID = agentModel?.modelId ?? sessionModel?.modelId ?? config.currentModelId - ?? message.lastUsedProvider?.modelID; + ?? selection.lastUsedProvider?.modelID; const variant = selectedAgent && providerID && modelID - ? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID) + ? (selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID) + ?? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)) : undefined; return { @@ -101,10 +96,10 @@ const resolveSessionSendConfig = (sessionId: string) => { }; }; -export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { - const enabled = options?.enabled ?? true; +export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) { + const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true); const queuedMessages = useMessageQueueStore((state) => state.queuedMessages); - const sessionStatus = useSessionStore((state) => state.sessionStatus); + const sessionStatusRecord = useDirectorySync((state) => state.session_status); const inFlightSessionsRef = React.useRef>(new Set()); const previousStatusRef = React.useRef>(new Map()); @@ -125,7 +120,7 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { return; } - const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId)?.type ?? 'idle'; + const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle'; if (currentStatus !== 'idle') { return; } @@ -135,21 +130,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { return; } - const resolved = resolveSessionSendConfig(sessionId); + // Use send config captured at queue time; fall back to current config + const captured = queueSnapshot[0]?.sendConfig; + const resolved = captured?.providerID && captured?.modelID + ? captured + : resolveSessionSendConfig(sessionId); if (!resolved.providerID || !resolved.modelID) { return; } inFlightSessionsRef.current.add(sessionId); - setSessionStatus(sessionId, 'busy'); try { - await useMessageStore.getState().sendMessage( + await useSessionUIStore.getState().sendMessage( payload.primaryText, resolved.providerID, resolved.modelID, resolved.agent, - sessionId, payload.primaryAttachments, payload.agentMentionName, payload.additionalParts, @@ -162,22 +159,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { removeFromQueue(sessionId, item.id); }); } catch (error) { - setSessionStatus(sessionId, 'idle'); console.warn('[queue] queued auto-send failed:', error); } finally { inFlightSessionsRef.current.delete(sessionId); } }; + const statusRecord = sessionStatusRecord ?? {}; const nextStatusMap = new Map(previousStatusRef.current); - const statusEntries = sessionStatus ? Array.from(sessionStatus.entries()) : []; - statusEntries.forEach(([sessionId, status]) => { - nextStatusMap.set(sessionId, status.type); - }); + for (const [sessionId, status] of Object.entries(statusRecord)) { + if (status) { + nextStatusMap.set(sessionId, status.type as SessionStatusType); + } + } const queueEntries = Object.entries(queuedMessages); queueEntries.forEach(([sessionId, queue]) => { - const currentStatusType = (sessionStatus?.get(sessionId)?.type ?? 'idle') as SessionStatusType; + const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType; const previousStatusType = previousStatusRef.current.get(sessionId); const becameIdle = (previousStatusType === 'busy' || previousStatusType === 'retry') @@ -192,5 +190,5 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) { }); previousStatusRef.current = nextStatusMap; - }, [enabled, queuedMessages, sessionStatus]); + }, [enabled, queuedMessages, sessionStatusRecord]); } diff --git a/packages/ui/src/hooks/useRouter.ts b/packages/ui/src/hooks/useRouter.ts index b706dd19..e49f905e 100644 --- a/packages/ui/src/hooks/useRouter.ts +++ b/packages/ui/src/hooks/useRouter.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useUIStore } from '@/stores/useUIStore'; import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router'; import type { RouteState, AppRouteState } from '@/lib/router'; @@ -38,7 +38,7 @@ export function useRouter(): void { const isApplyingRouteRef = React.useRef(false); // Get store actions (stable references) - const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); @@ -58,7 +58,7 @@ export function useRouter(): void { try { // 1. Apply session first (may trigger async operations) if (route.sessionId) { - const currentSessionId = useSessionStore.getState().currentSessionId; + const currentSessionId = useSessionUIStore.getState().currentSessionId; if (route.sessionId !== currentSessionId) { await setCurrentSession(route.sessionId); } @@ -97,7 +97,7 @@ export function useRouter(): void { * Get current app state for URL serialization. */ const getCurrentAppState = React.useCallback((): AppRouteState => { - const sessionState = useSessionStore.getState(); + const sessionState = useSessionUIStore.getState(); const uiState = useUIStore.getState(); return { @@ -158,9 +158,9 @@ export function useRouter(): void { return; } - let prevSessionId: string | null = useSessionStore.getState().currentSessionId; + let prevSessionId: string | null = useSessionUIStore.getState().currentSessionId; - const unsubscribe = useSessionStore.subscribe((state) => { + const unsubscribe = useSessionUIStore.subscribe((state) => { const sessionId = state.currentSessionId; // Skip if no change or if we're currently applying a route @@ -261,7 +261,7 @@ export function navigateToRoute(route: Partial): void { if (win.__VSCODE_CONFIG__ !== undefined) { // In VS Code, just apply state changes directly if (route.sessionId) { - void useSessionStore.getState().setCurrentSession(route.sessionId); + void useSessionUIStore.getState().setCurrentSession(route.sessionId); } if (route.settingsPath) { useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath)); @@ -300,7 +300,7 @@ export function navigateToRoute(route: Partial): void { // Also apply to state if (route.sessionId) { - void useSessionStore.getState().setCurrentSession(route.sessionId); + void useSessionUIStore.getState().setCurrentSession(route.sessionId); } if (route.settingsPath) { useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath)); @@ -321,7 +321,7 @@ export function getShareableURL(): string { return '/'; } - const sessionState = useSessionStore.getState(); + const sessionState = useSessionUIStore.getState(); const uiState = useUIStore.getState(); const params = new URLSearchParams(); diff --git a/packages/ui/src/hooks/useServerSessionStatus.ts b/packages/ui/src/hooks/useServerSessionStatus.ts deleted file mode 100644 index 794a0a02..00000000 --- a/packages/ui/src/hooks/useServerSessionStatus.ts +++ /dev/null @@ -1,334 +0,0 @@ -import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { opencodeClient } from '@/lib/opencode/client'; - -interface SessionState { - status: 'idle' | 'busy' | 'retry'; - lastUpdateAt: number; - metadata?: { - attempt?: number; - message?: string; - next?: number; - }; -} - -interface SessionAttentionState { - needsAttention: boolean; - lastUserMessageAt: number | null; - lastStatusChangeAt: number; - status: 'idle' | 'busy' | 'retry'; - isViewed: boolean; -} - -interface ServerSnapshotResponse { - statusSessions: Record; - attentionSessions: Record; - serverTime: number; -} - -const IMMEDIATE_POLL_DELAY_MS = 150; -const FOLLOW_UP_POLL_DELAY_MS = 1100; -const MIN_IMMEDIATE_POLL_GAP_MS = 1200; -const FOLLOW_UP_REARM_COOLDOWN_MS = 5000; - -// Ref to be accessed from outside (e.g., useEventStream) for triggering immediate poll -let triggerImmediatePollRef: (() => void) | null = null; - -// Global function to trigger immediate poll from outside React -export const triggerSessionStatusPoll = () => { - if (triggerImmediatePollRef) { - triggerImmediatePollRef(); - } -}; - -/** - * Hook to synchronize session status and attention state from server. - * - * Architecture: server maintains authoritative state, client applies snapshots. - * SSE remains the primary transport; snapshots repair missed updates. - */ -export function useServerSessionStatus(options?: { enabled?: boolean }) { - const enabled = options?.enabled ?? true; - const isSyncingRef = React.useRef(false); - const hasPendingImmediateSyncRef = React.useRef(false); - const lastSyncAtRef = React.useRef(0); - const lastImmediatePollRequestAtRef = React.useRef(0); - const lastFollowUpPollRequestAtRef = React.useRef(0); - const timeoutRef = React.useRef(null); - const followUpTimeoutRef = React.useRef(null); - - const fetchSessionStatus = React.useCallback(async (immediate = false) => { - const now = Date.now(); - if (!immediate && now - lastSyncAtRef.current < 1000) { - return; - } - if (immediate && now - lastSyncAtRef.current < 600) { - return; - } - - // Prevent concurrent syncs; if an immediate sync is requested while running, - // queue one more pass right after current request settles. - if (isSyncingRef.current) { - if (immediate) { - hasPendingImmediateSyncRef.current = true; - } - return; - } - - isSyncingRef.current = true; - lastSyncAtRef.current = now; - - try { - const [snapshotResult, upstreamStatusResult] = await Promise.allSettled([ - fetch('/api/sessions/snapshot', { - method: 'GET', - cache: 'no-store', - headers: { Accept: 'application/json' }, - }).then(async (r) => { - if (!r.ok) { - console.warn('[useServerSessionStatus] API returned', r.status); - if (r.status === 401) { - console.warn('[useServerSessionStatus] Authentication required - session may have expired'); - } - throw new Error(String(r.status)); - } - return (await r.json()) as ServerSnapshotResponse; - }), - opencodeClient.getGlobalSessionStatus(), - ]); - - const snapshotData: ServerSnapshotResponse | null = - snapshotResult.status === 'fulfilled' ? snapshotResult.value : null; - const statusSessions = snapshotData?.statusSessions ?? {}; - const attentionSessions = snapshotData?.attentionSessions ?? {}; - - const upstreamStatuses = - upstreamStatusResult.status === 'fulfilled' ? (upstreamStatusResult.value ?? {}) : {}; - - // Update the session store with server state - const currentStatuses = useSessionStore.getState().sessionStatus || new Map(); - let newStatuses: Map | null = null; - const ensureStatusesMap = () => { - if (!newStatuses) { - newStatuses = new Map(currentStatuses); - } - return newStatuses; - }; - - for (const [sessionId, state] of Object.entries(statusSessions)) { - const existing = currentStatuses.get(sessionId); - const hasChanged = - !existing || - existing.type !== state.status || - existing.attempt !== state.metadata?.attempt || - existing.message !== state.metadata?.message || - existing.next !== state.metadata?.next || - existing.confirmedAt !== state.lastUpdateAt; - - // Only update if server state is different - if (hasChanged) { - ensureStatusesMap().set(sessionId, { - type: state.status, - confirmedAt: state.lastUpdateAt, - attempt: state.metadata?.attempt, - message: state.metadata?.message, - next: state.metadata?.next, - }); - } - } - - // Overlay OpenCode's own session status endpoint. - // This is the source-of-truth for retry message payload and works even when - // OpenChamber server-side tracking misses transient updates. - for (const [sessionId, upstream] of Object.entries(upstreamStatuses)) { - const existing = (newStatuses ?? currentStatuses).get(sessionId); - const hasChanged = - !existing || - existing.type !== upstream.type || - existing.attempt !== upstream.attempt || - existing.message !== upstream.message || - existing.next !== upstream.next; - - if (hasChanged) { - ensureStatusesMap().set(sessionId, { - type: upstream.type, - confirmedAt: Date.now(), - attempt: upstream.attempt, - message: upstream.message, - next: upstream.next, - }); - } - } - - // Check for sessions that are no longer in server state (treat as idle) - const activeServerStatusIds = new Set(Object.keys(statusSessions)); - const activeUpstreamIds = new Set(Object.keys(upstreamStatuses)); - - for (const [sessionId, currentStatus] of (newStatuses ?? currentStatuses)) { - if ((currentStatus.type === 'busy' || currentStatus.type === 'retry') && - !activeServerStatusIds.has(sessionId) && - !activeUpstreamIds.has(sessionId)) { - // Session was busy but not in server state anymore -> mark as idle - ensureStatusesMap().set(sessionId, { - type: 'idle', - confirmedAt: Date.now(), - }); - } - } - - // Update attention state from server - const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map(); - let newAttentionStates: Map | null = null; - const ensureAttentionMap = () => { - if (!newAttentionStates) { - newAttentionStates = new Map(currentAttentionStates); - } - return newAttentionStates; - }; - let attentionStatesChanged = false; - - for (const [sessionId, attentionState] of Object.entries(attentionSessions)) { - const existing = currentAttentionStates.get(sessionId); - const serverState = attentionState as SessionAttentionState; - const hasChanged = - !existing || - existing.needsAttention !== serverState.needsAttention || - existing.lastUserMessageAt !== serverState.lastUserMessageAt || - existing.lastStatusChangeAt !== serverState.lastStatusChangeAt || - existing.status !== serverState.status || - existing.isViewed !== serverState.isViewed; - - if (hasChanged) { - ensureAttentionMap().set(sessionId, serverState); - attentionStatesChanged = true; - } - } - - // Remove attention states for sessions that no longer exist - for (const sessionId of (newAttentionStates ?? currentAttentionStates).keys()) { - const inStatus = !!statusSessions[sessionId]; - const inAttention = !!attentionSessions[sessionId]; - if (!inStatus && !inAttention) { - ensureAttentionMap().delete(sessionId); - attentionStatesChanged = true; - } - } - - // Only update store if something actually changed - const statusChanged = newStatuses !== null; - if (statusChanged || attentionStatesChanged) { - useSessionStore.setState({ - ...(statusChanged && newStatuses ? { sessionStatus: newStatuses } : {}), - ...(attentionStatesChanged && newAttentionStates ? { sessionAttentionStates: newAttentionStates } : {}), - }); - } - - if (process.env.NODE_ENV === 'development') { - console.debug('[useServerSessionStatus] Updated session statuses from server:', { - statusCount: Object.keys(statusSessions).length, - upstreamCount: Object.keys(upstreamStatuses).length, - attentionCount: Object.keys(attentionSessions).length, - serverTime: snapshotData?.serverTime, - }); - } - } catch (error) { - console.warn('[useServerSessionStatus] Error fetching session status:', error); - } finally { - isSyncingRef.current = false; - if (hasPendingImmediateSyncRef.current) { - hasPendingImmediateSyncRef.current = false; - setTimeout(() => { - void fetchSessionStatus(true); - }, 120); - } - } - }, []); - - // Function to trigger immediate snapshot sync from external modules - const triggerImmediatePoll = React.useCallback(() => { - const now = Date.now(); - const elapsed = now - lastImmediatePollRequestAtRef.current; - lastImmediatePollRequestAtRef.current = now; - - if (!timeoutRef.current) { - const minGapDelay = elapsed >= MIN_IMMEDIATE_POLL_GAP_MS - ? IMMEDIATE_POLL_DELAY_MS - : Math.max(IMMEDIATE_POLL_DELAY_MS, MIN_IMMEDIATE_POLL_GAP_MS - elapsed); - - timeoutRef.current = setTimeout(() => { - timeoutRef.current = null; - void fetchSessionStatus(true); - }, minGapDelay); - } - - // Run one follow-up sync after short settle period to catch delayed - // server status transitions that happen right after reconnect/restore. - // Re-arm at most once per cooldown window to avoid stacked follow-ups. - if (!followUpTimeoutRef.current && now - lastFollowUpPollRequestAtRef.current >= FOLLOW_UP_REARM_COOLDOWN_MS) { - lastFollowUpPollRequestAtRef.current = now; - followUpTimeoutRef.current = setTimeout(() => { - followUpTimeoutRef.current = null; - void fetchSessionStatus(true); - }, FOLLOW_UP_POLL_DELAY_MS); - } - }, [fetchSessionStatus]); - - // Initial snapshot sync on mount - React.useEffect(() => { - if (!enabled) { - return; - } - - void fetchSessionStatus(true); - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - if (followUpTimeoutRef.current) { - clearTimeout(followUpTimeoutRef.current); - } - }; - }, [enabled, fetchSessionStatus]); - - // Sync snapshot when tab becomes visible - React.useEffect(() => { - if (!enabled) { - return; - } - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - triggerImmediatePoll(); - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [enabled, triggerImmediatePoll]); - - // Update the ref for external access - React.useEffect(() => { - if (!enabled) { - triggerImmediatePollRef = null; - return; - } - - triggerImmediatePollRef = triggerImmediatePoll; - return () => { - triggerImmediatePollRef = null; - }; - }, [enabled, triggerImmediatePoll]); - - return { - fetchSessionStatus, - triggerImmediatePoll, - }; -} - -// Export ref accessor for external modules -export const getTriggerImmediatePoll = () => triggerImmediatePollRef; - -export default useServerSessionStatus; diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index a3668bfc..a8c4da36 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -1,20 +1,14 @@ - - import React from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionStatus, useSessionMessages, useSessionPermissions } from '@/sync/sync-context'; // Mirrors OpenCode SessionStatus: busy|retry|idle. export type SessionActivityPhase = 'idle' | 'busy' | 'retry'; export interface SessionActivityResult { - phase: SessionActivityPhase; - isWorking: boolean; - isBusy: boolean; - - // Kept for backward compatibility; always false with server session.status. isCooldown: boolean; } @@ -25,33 +19,49 @@ const IDLE_RESULT: SessionActivityResult = { isCooldown: false, }; +/** + * Determines if a session is actively working. + * Checks session_status and, as a narrow fallback, only the trailing + * assistant message when its completion update has not landed yet. + * Returns idle when permissions are pending (permission indicator takes priority). + */ export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult { - - const phase = useSessionStore((state) => { - if (!sessionId || !state.sessionStatus) { - return 'idle' as SessionActivityPhase; - } - const status = state.sessionStatus.get(sessionId); - return (status?.type ?? 'idle') as SessionActivityPhase; - }); + const status = useSessionStatus(sessionId ?? ''); + const messages = useSessionMessages(sessionId ?? ''); + const permissions = useSessionPermissions(sessionId ?? ''); return React.useMemo(() => { - if (phase === 'idle') { - return IDLE_RESULT; - } - const isBusy = phase === 'busy'; - // No cooldown in server session.status; treat retry as working. - const isCooldown = false; + if (!sessionId) return IDLE_RESULT; + + // Permissions pending → idle (permission indicator takes priority) + if (permissions.length > 0) return IDLE_RESULT; + + const phase: SessionActivityPhase = (status?.type ?? 'idle') as SessionActivityPhase; + + // Only trust the trailing assistant message as a transient fallback while + // waiting for session.status/message.updated to settle. + const lastMessage = messages[messages.length - 1]; + const hasPendingAssistant = Boolean( + lastMessage + && lastMessage.role === 'assistant' + && typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number', + ); + + const statusWorking = phase !== 'idle'; + const isWorking = statusWorking || hasPendingAssistant; + + if (!isWorking) return IDLE_RESULT; + return { - phase, - isWorking: phase === 'busy' || phase === 'retry', - isBusy, - isCooldown, + phase: statusWorking ? phase : 'busy', + isWorking: true, + isBusy: phase === 'busy' || (!statusWorking && hasPendingAssistant), + isCooldown: false, }; - }, [phase]); + }, [sessionId, status, messages, permissions]); } export function useCurrentSessionActivity(): SessionActivityResult { - const currentSessionId = useSessionStore((state) => state.currentSessionId); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); return useSessionActivity(currentSessionId); } diff --git a/packages/ui/src/hooks/useSessionAutoCleanup.ts b/packages/ui/src/hooks/useSessionAutoCleanup.ts index 966989fb..ba03e440 100644 --- a/packages/ui/src/hooks/useSessionAutoCleanup.ts +++ b/packages/ui/src/hooks/useSessionAutoCleanup.ts @@ -1,6 +1,9 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { opencodeClient } from '@/lib/opencode/client'; +import { ensureGlobalSessionsLoaded, useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getAllSyncSessions } from '@/sync/sync-refs'; import { useUIStore } from '@/stores/useUIStore'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -50,8 +53,9 @@ export const buildAutoDeleteCandidates = ({ }; type CleanupResult = { - deletedIds: string[]; + completedIds: string[]; failedIds: string[]; + action: 'archive' | 'delete'; skippedReason?: 'disabled' | 'loading' | 'cooldown' | 'no-candidates' | 'running'; }; @@ -60,57 +64,65 @@ type CleanupOptions = { enabled?: boolean; }; -export const useSessionAutoCleanup = (options?: CleanupOptions) => { +export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOptions) => { + const options = typeof enabledOrOptions === 'object' ? enabledOrOptions : undefined; const autoRun = options?.autoRun !== false; - const enabled = options?.enabled ?? true; + const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (options?.enabled ?? true); - const sessions = useSessionStore((state) => state.sessions); - const currentSessionId = useSessionStore((state) => state.currentSessionId); - const isLoading = useSessionStore((state) => state.isLoading); - const deleteSessions = useSessionStore((state) => state.deleteSessions); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const isLoading = useSessionUIStore((state) => state.isLoading); + const globalSessions = useGlobalSessionsStore((state) => state.activeSessions); + const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled); const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays); + const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction); const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt); const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt); const [isRunning, setIsRunning] = React.useState(false); const runningRef = React.useRef(false); + React.useEffect(() => { + void ensureGlobalSessionsLoaded(getAllSyncSessions()); + }, []); + const candidates = React.useMemo(() => { if (autoDeleteAfterDays <= 0) { return []; } return buildAutoDeleteCandidates({ - sessions, + sessions: globalSessions, currentSessionId, cutoffDays: autoDeleteAfterDays, }); - }, [autoDeleteAfterDays, currentSessionId, sessions]); + }, [autoDeleteAfterDays, currentSessionId, globalSessions]); const runCleanup = React.useCallback( - async ({ force = false }: { force?: boolean } = {}): Promise => { + async ({ force = false }: { force?: boolean } = {}): Promise => { if (runningRef.current) { - return { deletedIds: [], failedIds: [], skippedReason: 'running' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'running' }; } if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) { if (!force) { - return { deletedIds: [], failedIds: [], skippedReason: 'disabled' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'disabled' }; } } if (isLoading) { - return { deletedIds: [], failedIds: [], skippedReason: 'loading' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'loading' }; } const now = Date.now(); if (!force && autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) { - return { deletedIds: [], failedIds: [], skippedReason: 'cooldown' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'cooldown' }; } + const { activeSessions: sessions } = await ensureGlobalSessionsLoaded(getAllSyncSessions()); + if (sessions.length === 0) { - return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' }; } const candidateIds = buildAutoDeleteCandidates({ @@ -122,14 +134,44 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { if (candidateIds.length === 0) { setAutoDeleteLastRunAt(now); - return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' }; + return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' }; } runningRef.current = true; setIsRunning(true); try { - const result = await deleteSessions(candidateIds, { silent: true }); - return result; + const sessionMap = new Map(sessions.map((session) => [session.id, session])); + const completedIds: string[] = []; + const failedIds: string[] = []; + + for (const id of candidateIds) { + const session = sessionMap.get(id); + const directory = session ? resolveGlobalSessionDirectory(session) : null; + if (!directory) { + failedIds.push(id); + continue; + } + + const scopedSdk = opencodeClient.getScopedSdkClient(directory); + + try { + if (sessionRetentionAction === 'archive') { + await scopedSdk.session.update({ sessionID: id, directory, time: { archived: Date.now() } }); + } else { + await scopedSdk.session.delete({ sessionID: id, directory }); + } + completedIds.push(id); + } catch { + failedIds.push(id); + } + } + + if (sessionRetentionAction === 'archive') { + useGlobalSessionsStore.getState().archiveSessions(completedIds); + } else { + useGlobalSessionsStore.getState().removeSessions(completedIds); + } + return { completedIds, failedIds, action: sessionRetentionAction }; } finally { runningRef.current = false; setIsRunning(false); @@ -141,9 +183,8 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { autoDeleteEnabled, autoDeleteLastRunAt, currentSessionId, - deleteSessions, isLoading, - sessions, + sessionRetentionAction, setAutoDeleteLastRunAt, ] ); @@ -159,7 +200,7 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) { return; } - if (isLoading || sessions.length === 0) { + if (isLoading || !hasLoadedGlobalSessions || globalSessions.length === 0) { return; } const now = Date.now(); @@ -173,8 +214,9 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { autoDeleteLastRunAt, autoRun, enabled, + hasLoadedGlobalSessions, + globalSessions.length, isLoading, - sessions.length, runCleanup, ]); @@ -183,5 +225,6 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => { isRunning, runCleanup, keepRecentCount: AUTO_DELETE_KEEP_RECENT, + action: sessionRetentionAction, }; }; diff --git a/packages/ui/src/hooks/useSessionStatusBootstrap.ts b/packages/ui/src/hooks/useSessionStatusBootstrap.ts index d52399f7..43a69a20 100644 --- a/packages/ui/src/hooks/useSessionStatusBootstrap.ts +++ b/packages/ui/src/hooks/useSessionStatusBootstrap.ts @@ -1,47 +1,8 @@ -import React from 'react'; -import { opencodeClient } from '@/lib/opencode/client'; -import { useSessionStore } from '@/stores/useSessionStore'; - -type SessionStatusPayload = { - type: 'idle' | 'busy' | 'retry'; - attempt?: number; - message?: string; - next?: number; -}; - -export const useSessionStatusBootstrap = (options?: { enabled?: boolean }) => { - const enabled = options?.enabled ?? true; - React.useEffect(() => { - if (!enabled) { - return; - } - - let cancelled = false; - - const bootstrap = async () => { - try { - // Use global status to detect busy sessions across all directories, - // including sessions started externally (e.g., via CLI) before UI opened - const statusMap = await opencodeClient.getGlobalSessionStatus(); - if (cancelled || !statusMap) return; - - const nextStatus = new Map(); - Object.entries(statusMap).forEach(([sessionId, raw]) => { - if (!sessionId || !raw) return; - const status = raw as SessionStatusPayload; - nextStatus.set(sessionId, status); - }); - - if (nextStatus.size > 0) { - useSessionStore.setState({ sessionStatus: nextStatus }); - } - } catch { /* ignored */ } - }; - - void bootstrap(); - - return () => { - cancelled = true; - }; - }, [enabled]); +/** + * Session status bootstrap is now handled by the sync system's own bootstrap + * (sync/bootstrap.ts). This hook is retained as a no-op for call-site compat. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useSessionStatusBootstrap = (_options?: { enabled?: boolean }) => { + // no-op — session_status is bootstrapped by sync child stores }; diff --git a/packages/ui/src/hooks/useTimelineStaging.ts b/packages/ui/src/hooks/useTimelineStaging.ts new file mode 100644 index 00000000..abec4ee2 --- /dev/null +++ b/packages/ui/src/hooks/useTimelineStaging.ts @@ -0,0 +1,114 @@ +import { useState, useRef, useEffect, useMemo } from "react" + +type StageConfig = { + /** How many messages to show on first paint */ + init: number + /** How many to add per animation frame */ + batch: number +} + +type UseTimelineStagingInput = { + /** Key that changes when session switches */ + sessionKey: string + /** All messages (sorted) */ + messages: T[] + /** Config for staging behavior */ + config?: StageConfig +} + +type UseTimelineStagingResult = { + /** The subset of messages that should be rendered */ + stagedMessages: T[] + /** Whether staging is still in progress */ + isStaging: boolean +} + +const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 } + +/** + * Defer-mounts small timeline windows so revealing older turns does not + * block first paint with a large DOM mount. + * + * Once staging completes for a session it never re-stages — backfill and + * new messages render immediately. + * + * Defers mounting older turns so first paint isn't blocked by large DOM. + */ +export function useTimelineStaging( + input: UseTimelineStagingInput, +): UseTimelineStagingResult { + const config = input.config ?? DEFAULT_CONFIG + const { sessionKey, messages } = input + + const [stagedCount, setStagedCount] = useState(() => messages.length) + const completedSessions = useRef(new Set()) + const activeSession = useRef("") + const frameRef = useRef(null) + + useEffect(() => { + // Cancel any pending animation frame + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + const total = messages.length + + // If already completed for this session, show all immediately + if (completedSessions.current.has(sessionKey)) { + setStagedCount(total) + return + } + + // Small message list — no staging needed + if (total <= config.init) { + setStagedCount(total) + completedSessions.current.add(sessionKey) + return + } + + // Start staging + activeSession.current = sessionKey + let count = Math.min(total, config.init) + setStagedCount(count) + + const step = () => { + // Session changed mid-staging — bail + if (activeSession.current !== sessionKey) { + frameRef.current = null + return + } + + count = Math.min(messages.length, count + config.batch) + setStagedCount(count) + + if (count >= messages.length) { + completedSessions.current.add(sessionKey) + activeSession.current = "" + frameRef.current = null + return + } + + frameRef.current = requestAnimationFrame(step) + } + + frameRef.current = requestAnimationFrame(step) + + return () => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + } + }, [sessionKey, messages.length, config.init, config.batch]) + + const stagedMessages = useMemo(() => { + if (stagedCount >= messages.length) return messages + return messages.slice(Math.max(0, messages.length - stagedCount)) + }, [messages, stagedCount]) + + const isStaging = activeSession.current === sessionKey && + !completedSessions.current.has(sessionKey) + + return { stagedMessages, isStaging } +} diff --git a/packages/ui/src/hooks/useVoiceContext.ts b/packages/ui/src/hooks/useVoiceContext.ts index d4e1fa47..1e83273d 100644 --- a/packages/ui/src/hooks/useVoiceContext.ts +++ b/packages/ui/src/hooks/useVoiceContext.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionMessageRecords, useSessionPermissions } from '@/sync/sync-context'; import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice'; /** @@ -7,45 +8,41 @@ import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice'; * Call this inside VoiceProvider to enable session awareness during voice. */ export function useVoiceContext() { - const currentSessionId = useSessionStore((s) => s.currentSessionId); - const messages = useSessionStore((s) => - currentSessionId ? s.messages.get(currentSessionId) : undefined - ); - const permissions = useSessionStore((s) => - currentSessionId ? s.permissions.get(currentSessionId) : undefined - ); - + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const messages = useSessionMessageRecords(currentSessionId ?? ''); + const permissions = useSessionPermissions(currentSessionId ?? ''); + // Track last seen message count to only forward new messages const lastMessageCountRef = useRef(0); - + // Forward new messages to voice agent useEffect(() => { - if (!currentSessionId || !messages || !isVoiceSessionStarted()) return; - + if (!currentSessionId || !messages || messages.length === 0 || !isVoiceSessionStarted()) return; + const currentCount = messages.length; if (currentCount <= lastMessageCountRef.current) return; - + // Get only new messages (messages since last check) const newMessages = messages.slice(lastMessageCountRef.current); lastMessageCountRef.current = currentCount; - + // Format for voice hooks (extract role and content) const formattedMessages = newMessages.map(m => ({ role: m.info.role, - content: m.parts.map(p => ('text' in p ? p.text : '')).join('') + content: m.parts.map((p: Record) => ('text' in p ? p.text : '')).join('') })); - + voiceHooks.onMessages(currentSessionId, formattedMessages); }, [currentSessionId, messages]); - + // Forward permission requests to voice agent useEffect(() => { if (!currentSessionId || !permissions || permissions.length === 0) return; if (!isVoiceSessionStarted()) return; - + const request = permissions[0]; if (!request) return; - + voiceHooks.onPermissionRequested( currentSessionId, request.id, @@ -53,7 +50,7 @@ export function useVoiceContext() { request.metadata ); }, [currentSessionId, permissions]); - + // Reset message count when session changes useEffect(() => { lastMessageCountRef.current = 0; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7ed04f19..a67b2496 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -394,7 +394,7 @@ export interface GitWorktreeAPI { export interface GitAPI { checkIsGitRepository(directory: string): Promise; - getGitStatus(directory: string): Promise; + getGitStatus(directory: string, options?: { mode?: 'light' }): Promise; getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; revertGitFile(directory: string, filePath: string): Promise; @@ -536,6 +536,7 @@ export interface SettingsPayload { notificationMode?: 'always' | 'hidden-only'; autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; + sessionRetentionAction?: 'archive' | 'delete'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index ae1d5911..1e492ba6 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -23,6 +23,7 @@ type AppearanceSlice = { maxLastMessageLength: number; autoDeleteEnabled: boolean; autoDeleteAfterDays: number; + sessionRetentionAction: 'archive' | 'delete'; fontSize: number; terminalFontSize: number; padding: number; @@ -57,6 +58,7 @@ export const startAppearanceAutoSave = (): void => { maxLastMessageLength: useUIStore.getState().maxLastMessageLength, autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled, autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays, + sessionRetentionAction: useUIStore.getState().sessionRetentionAction, fontSize: useUIStore.getState().fontSize, terminalFontSize: useUIStore.getState().terminalFontSize, padding: useUIStore.getState().padding, @@ -103,6 +105,7 @@ export const startAppearanceAutoSave = (): void => { maxLastMessageLength: state.maxLastMessageLength, autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, + sessionRetentionAction: state.sessionRetentionAction, fontSize: state.fontSize, terminalFontSize: state.terminalFontSize, padding: state.padding, @@ -159,6 +162,9 @@ export const startAppearanceAutoSave = (): void => { if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) { diff.autoDeleteAfterDays = current.autoDeleteAfterDays; } + if (current.sessionRetentionAction !== previous.sessionRetentionAction) { + diff.sessionRetentionAction = current.sessionRetentionAction; + } if (current.fontSize !== previous.fontSize) { diff.fontSize = current.fontSize; } diff --git a/packages/ui/src/lib/concurrency.ts b/packages/ui/src/lib/concurrency.ts new file mode 100644 index 00000000..e402a3c5 --- /dev/null +++ b/packages/ui/src/lib/concurrency.ts @@ -0,0 +1,27 @@ +export const mapWithConcurrency = async ( + values: T[], + concurrency: number, + mapper: (value: T) => Promise, +): Promise => { + if (values.length === 0) { + return []; + } + + const safeConcurrency = Math.max(1, Math.min(concurrency, values.length)); + const results = new Array(values.length); + let cursor = 0; + + const worker = async () => { + while (true) { + const nextIndex = cursor; + cursor += 1; + if (nextIndex >= values.length) { + return; + } + results[nextIndex] = await mapper(values[nextIndex]); + } + }; + + await Promise.all(Array.from({ length: safeConcurrency }, () => worker())); + return results; +}; diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index 9c82de7a..144afb4d 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -1,11 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard'; +import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs'; +import { useStreamingStore } from '@/sync/streaming'; export interface DebugMessageInfo { messageId: string; @@ -28,7 +30,7 @@ export interface DebugMessageInfo { export const debugUtils = { getLastAssistantMessage(): DebugMessageInfo | null { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -36,7 +38,7 @@ export const debugUtils = { return null; } - const messages = state.messages.get(currentSessionId); + const messages = getSyncMessages(currentSessionId); if (!messages || messages.length === 0) { console.log('[ERROR] No messages in current session'); return null; @@ -44,8 +46,9 @@ export const debugUtils = { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg.info.role === 'assistant') { - const parts = msg.parts.map((part: any) => { + if (msg.role === 'assistant') { + const msgParts = getSyncParts(msg.id) || []; + const parts = msgParts.map((part: any) => { const info: any = { id: part.id, type: part.type, @@ -71,9 +74,9 @@ export const debugUtils = { const isEmptyResponse = !hasText && !hasTools && (!isEmpty || hasStepMarkers); const info: DebugMessageInfo = { - messageId: msg.info.id, - role: msg.info.role, - timestamp: msg.info.time?.created || 0, + messageId: msg.id, + role: msg.role, + timestamp: (msg as any).time?.created || 0, partsCount: parts.length, parts, isEmpty, @@ -124,7 +127,7 @@ export const debugUtils = { truncateMessages(messages: any[]): any[] { return messages.map((msg) => ({ ...msg, - parts: (msg.parts || []).map((part: any) => { + parts: (getSyncParts(msg.id) || []).map((part: any) => { const truncatedPart: any = { ...part }; if ('text' in part) { @@ -157,7 +160,7 @@ export const debugUtils = { }, getAllMessages(truncate: boolean = false) { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -165,24 +168,25 @@ export const debugUtils = { return []; } - const messages = state.messages.get(currentSessionId) || []; + const messages = getSyncMessages(currentSessionId); console.log(`[MESSAGES] Total messages in session: ${messages.length}`); messages.forEach((msg, idx) => { - console.log(`[${idx}] ${msg.info.role} - ${msg.info.id} - ${msg.parts.length} parts`); + const msgParts = getSyncParts(msg.id) || []; + console.log(`[${idx}] ${msg.role} - ${msg.id} - ${msgParts.length} parts`); }); - return truncate ? this.truncateMessages(messages) : messages; + return truncate ? this.truncateMessages(messages as any[]) : messages; }, async getAppStatus() { const directoryState = useDirectoryStore.getState(); - const sessionState = useSessionStore.getState(); + const sessionState = useSessionUIStore.getState(); const projectsState = useProjectsStore.getState(); const currentDirectory = directoryState.currentDirectory || null; const opencodeDirectory = opencodeClient.getDirectory() ?? null; - const sessions = sessionState.sessions || []; + const sessions = getSyncSessions(); const sessionDirectories = new Set(); const sessionDirectoryCounts: Record = {}; @@ -410,24 +414,25 @@ export const debugUtils = { }, getStreamingState() { - const state = useSessionStore.getState(); - const currentStreamingId = state.currentSessionId - ? state.streamingMessageIds.get(state.currentSessionId) ?? null + const sessionState = useSessionUIStore.getState(); + const streamingState = useStreamingStore.getState(); + const currentStreamingId = sessionState.currentSessionId + ? streamingState.streamingMessageIds.get(sessionState.currentSessionId) ?? null : null; console.log('[STREAM] Streaming State:', { streamingMessageId: currentStreamingId, - streamingMessageIds: Array.from(state.streamingMessageIds.entries()), - messageStreamStates: Array.from(state.messageStreamStates.entries()), + streamingMessageIds: Array.from(streamingState.streamingMessageIds.entries()), + messageStreamStates: Array.from(streamingState.messageStreamStates.entries()), }); return { streamingMessageId: currentStreamingId, - streamingMessageIds: state.streamingMessageIds, - streamStates: state.messageStreamStates, + streamingMessageIds: streamingState.streamingMessageIds, + streamStates: streamingState.messageStreamStates, }; }, findEmptyMessages() { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -435,11 +440,11 @@ export const debugUtils = { return []; } - const messages = state.messages.get(currentSessionId) || []; + const messages = getSyncMessages(currentSessionId); const emptyMessages = messages - .filter((msg) => msg.info.role === 'assistant') + .filter((msg) => msg.role === 'assistant') .filter((msg) => { - const parts = msg.parts || []; + const parts = getSyncParts(msg.id) || []; const hasTextContent = parts.some( (p: any) => p.type === 'text' && p.text && p.text.trim().length > 0 ); @@ -451,12 +456,13 @@ export const debugUtils = { console.log(`[INSPECT] Found ${emptyMessages.length} empty assistant messages`); emptyMessages.forEach((msg, idx) => { + const parts = getSyncParts(msg.id) || []; console.log(`[${idx}] Empty message:`, { - messageId: msg.info.id, - partsCount: msg.parts.length, - provider: (msg.info as any).providerID, - model: (msg.info as any).modelID, - timestamp: msg.info.time?.created, + messageId: msg.id, + partsCount: parts.length, + provider: (msg as any).providerID, + model: (msg as any).modelID, + timestamp: (msg as any).time?.created, }); }); @@ -486,7 +492,7 @@ export const debugUtils = { maxTableRows?: number; } = {}) { const { includeNonAssistant = false, verbose = true, maxTableRows = 25 } = options; - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -494,10 +500,10 @@ export const debugUtils = { return { summary: null, rows: [] }; } - const messages = state.messages.get(currentSessionId) || []; + const messages = getSyncMessages(currentSessionId); const targetMessages = includeNonAssistant ? messages - : messages.filter((msg) => msg.info.role === 'assistant'); + : messages.filter((msg) => msg.role === 'assistant'); const summary = { totalMessages: messages.length, @@ -520,8 +526,8 @@ export const debugUtils = { }; const rows = targetMessages.map((message, index) => { - const info = message.info ?? {}; - const parts = Array.isArray(message.parts) ? message.parts : []; + const info = message as any; + const parts = getSyncParts(message.id) || []; const timeInfo = (info.time ?? {}) as { completed?: number }; const completedAt = toNumber(timeInfo.completed); @@ -615,7 +621,7 @@ export const debugUtils = { }, checkCompletionStatus() { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) { @@ -623,8 +629,8 @@ export const debugUtils = { return null; } - const messages = state.messages.get(currentSessionId) || []; - const assistantMessages = messages.filter(m => m.info.role === 'assistant'); + const messages = getSyncMessages(currentSessionId); + const assistantMessages = messages.filter(m => m.role === 'assistant'); if (assistantMessages.length === 0) { console.log('[ERROR] No assistant messages'); @@ -632,24 +638,25 @@ export const debugUtils = { } const lastMessage = assistantMessages[assistantMessages.length - 1]; - const stepFinishParts = lastMessage.parts.filter((p: any) => p.type === 'step-finish'); - const hasStopReason = (lastMessage.info as { finish?: string }).finish === 'stop'; + const lastParts = getSyncParts(lastMessage.id) || []; + const stepFinishParts = lastParts.filter((p: any) => p.type === 'step-finish'); + const hasStopReason = (lastMessage as any).finish === 'stop'; - const timeInfo = lastMessage.info.time as any; + const timeInfo = (lastMessage as any).time; const completedAt = timeInfo?.completed; - const messageStatus = (lastMessage.info as any).status; + const messageStatus = (lastMessage as any).status; const hasCompletedFlag = (typeof completedAt === 'number' && completedAt > 0) || messageStatus === 'completed'; const messageIsComplete = Boolean(hasCompletedFlag && hasStopReason); - const messageStreamStates = state.messageStreamStates; - const streamingMessageId = (lastMessage.info as { sessionID?: string }).sessionID - ? state.streamingMessageIds.get((lastMessage.info as { sessionID?: string }).sessionID as string) ?? null + const streamingState = useStreamingStore.getState(); + const streamingMessageId = (lastMessage as any).sessionID + ? streamingState.streamingMessageIds.get((lastMessage as any).sessionID as string) ?? null : null; - const lifecycle = messageStreamStates.get(lastMessage.info.id); - const isStreamingCandidate = lastMessage.info.id === streamingMessageId; + const lifecycle = streamingState.messageStreamStates.get(lastMessage.id); + const isStreamingCandidate = lastMessage.id === streamingMessageId; console.log('[SUMMARY] Completion Status:'); - console.log('Message ID:', lastMessage.info.id); + console.log('Message ID:', lastMessage.id); console.log('time.completed:', completedAt, '(type:', typeof completedAt, ')'); console.log('status:', messageStatus); console.log('hasCompletedFlag:', hasCompletedFlag); @@ -661,7 +668,7 @@ export const debugUtils = { console.log('Step-finish parts:', stepFinishParts); return { - messageId: lastMessage.info.id, + messageId: lastMessage.id, completed: completedAt, status: messageStatus, hasCompletedFlag, diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 662b821b..36e3b026 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -94,6 +94,7 @@ export type DesktopSettings = { }>; // Per-provider custom model groups configuration autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; + sessionRetentionAction?: 'archive' | 'delete'; tunnelProvider?: string; tunnelMode?: 'quick' | 'managed-remote' | 'managed-local'; tunnelBootstrapTtlMs?: number | null; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 6dd2d6c2..be11f5c8 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -3,7 +3,7 @@ import type { RuntimeAPIs } from './api/types'; import * as gitHttp from './gitApiHttp'; import { opencodeClient } from './opencode/client'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -56,10 +56,10 @@ export async function checkIsGitRepository(directory: string): Promise return gitHttp.checkIsGitRepository(directory); } -export async function getGitStatus(directory: string): Promise { +export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.getGitStatus(directory); - return gitHttp.getGitStatus(directory); + return gitHttp.getGitStatus(directory, options); } export async function getGitDiff(directory: string, options: import('./api/types').GetGitDiffOptions): Promise { @@ -326,7 +326,7 @@ type SessionGenerationContext = { }; const resolveSessionGenerationContext = (): SessionGenerationContext | null => { - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; if (!sessionId) { return null; } diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 89983135..8288ed2e 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -119,8 +119,9 @@ export async function checkIsGitRepository(directory: string): Promise } } -export async function getGitStatus(directory: string): Promise { - const key = normalizeDirectoryKey(directory); +export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { + const mode = options?.mode; + const key = mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory); const now = Date.now(); const cached = gitStatusCache.get(key); if (cached && cached.expiresAt > now) { @@ -133,7 +134,7 @@ export async function getGitStatus(directory: string): Promise { } const task = (async () => { - const response = await fetch(buildUrl(`${API_BASE}/status`, directory)); + const response = await fetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined)); if (!response.ok) { throw new Error(`Failed to get git status: ${response.statusText}`); } diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 0527d2b5..2de11c9a 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -1,4 +1,5 @@ -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getSyncSessions } from '@/sync/sync-refs'; import { useUIStore } from '@/stores/useUIStore'; declare const __APP_VERSION__: string | undefined; @@ -37,10 +38,11 @@ type OpenChamberOpencodeResolution = { }; const getCurrentDirectory = (): string => { - const state = useSessionStore.getState(); + const state = useSessionUIStore.getState(); const currentSessionId = state.currentSessionId; if (!currentSessionId) return ''; - const session = state.sessions.find((s) => s.id === currentSessionId); + const sessions = getSyncSessions(); + const session = sessions.find((s) => s.id === currentSessionId); return typeof session?.directory === 'string' ? session.directory : ''; }; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index a15bccac..394e0c42 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -11,22 +11,10 @@ import type { Agent, TextPartInput, FilePartInput, - Event, } from "@opencode-ai/sdk/v2"; import type { PermissionRequest } from "@/types/permission"; import type { QuestionRequest } from "@/types/question"; import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"; -type StreamEvent = { - data: TData; - event?: string; - id?: string; - retry?: number; -}; - -export type RoutedOpencodeEvent = { - directory: string; - payload: Event; -}; // Use relative path by default (works with both dev and nginx proxy server) // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments @@ -147,22 +135,8 @@ class OpencodeService { private client: OpencodeClient; private baseUrl: string; private scopedClients: Map = new Map(); - private sseAbortControllers: Map = new Map(); private currentDirectory: string | undefined = undefined; private directoryContextQueue: Promise = Promise.resolve(); - - private globalSseAbortController: AbortController | null = null; - private globalSseTask: Promise | null = null; - private globalSseIsConnected = false; - private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set(); - private globalSseOpenListeners: Set<() => void> = new Set(); - private globalSseErrorListeners: Set<(error: unknown) => void> = new Set(); - private globalSseQueue: Array = []; - private globalSseBuffer: Array = []; - private globalSseCoalesced: Map = new Map(); - private globalSseStaleDeltas: Set = new Set(); - private globalSseFlushTimer: ReturnType | null = null; - private globalSseLastFlushAt = 0; private listDirectoryInFlight: Map> = new Map(); private listDirectoryCache: Map = new Map(); @@ -177,6 +151,16 @@ class OpencodeService { return this.baseUrl; } + /** Expose the raw SDK client for direct use (e.g., SyncProvider) */ + getSdkClient(): OpencodeClient { + return this.client; + } + + /** Get a scoped SDK client for a specific directory */ + getScopedSdkClient(directory: string): OpencodeClient { + return this.getScopedApiClient(directory); + } + /** * Returns an SDK client scoped to a project directory. * Needed for worktree APIs where backend ignores per-call directory. @@ -756,6 +740,7 @@ class OpencodeService { }, agent: params.agent, variant: params.variant, + ...(params.messageId ? { messageID: params.messageId } : {}), ...(params.format ? { format: params.format } : {}), parts, }), @@ -1213,648 +1198,8 @@ class OpencodeService { } } - private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null { - if (!raw || typeof raw !== 'object') { - return null; - } - - const record = raw as Record; - - const directoryCandidate = - typeof record.directory === 'string' - ? record.directory - : typeof record.properties === 'object' && record.properties !== null - ? ((record.properties as Record).directory as unknown) - : null; - - const normalizedDirectory = - typeof directoryCandidate === 'string' - ? this.normalizeCandidatePath(directoryCandidate) ?? directoryCandidate.trim() - : null; - - if (typeof record.type === 'string') { - return { - directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global', - payload: record as Event, - }; - } - - const nestedPayload = record.payload; - if (nestedPayload && typeof nestedPayload === 'object') { - const nestedRecord = nestedPayload as Record; - if (typeof nestedRecord.type === 'string') { - return { - directory: normalizedDirectory && normalizedDirectory.length > 0 ? normalizedDirectory : 'global', - payload: nestedRecord as Event, - }; - } - } - - return null; - } - - private emitGlobalSseEvent(event: RoutedOpencodeEvent) { - this.enqueueGlobalSseEvent(event); - } - - private notifyGlobalSseOpen() { - for (const handler of this.globalSseOpenListeners) { - try { - handler(); - } catch (error) { - console.warn('[OpencodeClient] Global SSE open handler error:', error); - } - } - } - - private notifyGlobalSseError(error: unknown) { - for (const handler of this.globalSseErrorListeners) { - try { - handler(error); - } catch (listenerError) { - console.warn('[OpencodeClient] Global SSE error handler failed:', listenerError); - } - } - } - - private ensureGlobalSseStarted() { - if (this.globalSseTask) { - return; - } - - const abortController = new AbortController(); - this.globalSseAbortController = abortController; - - this.globalSseTask = this.runGlobalSseLoop(abortController) - .catch((error) => { - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - return; - } - console.error('[OpencodeClient] Global SSE task failed:', error); - }) - .finally(() => { - if (this.globalSseAbortController === abortController) { - this.globalSseAbortController = null; - } - this.globalSseTask = null; - this.globalSseIsConnected = false; - }); - } - - private maybeStopGlobalSse() { - if (this.globalSseListeners.size > 0) { - return; - } - - if (this.globalSseAbortController && !this.globalSseAbortController.signal.aborted) { - this.globalSseAbortController.abort(); - } - this.globalSseAbortController = null; - this.clearGlobalSseQueue(); - } - - private clearGlobalSseQueue() { - if (this.globalSseFlushTimer) { - clearTimeout(this.globalSseFlushTimer); - this.globalSseFlushTimer = null; - } - this.globalSseQueue.length = 0; - this.globalSseBuffer.length = 0; - this.globalSseCoalesced.clear(); - this.globalSseStaleDeltas.clear(); - } - - private getGlobalSseDeltaKey(event: RoutedOpencodeEvent): string | null { - const payload = event.payload as unknown as Record; - const eventType = typeof payload.type === 'string' ? payload.type : null; - if (eventType !== 'message.part.delta') { - return null; - } - - const properties = - typeof payload.properties === 'object' && payload.properties !== null - ? (payload.properties as Record) - : null; - const messageId = typeof properties?.messageID === 'string' - ? properties.messageID - : typeof properties?.messageId === 'string' - ? properties.messageId - : null; - const partId = typeof properties?.partID === 'string' - ? properties.partID - : typeof properties?.partId === 'string' - ? properties.partId - : null; - - if (!messageId || !partId) { - return null; - } - - return `${event.directory}:${messageId}:${partId}`; - } - - private getGlobalSseUpdatedPartKey(event: RoutedOpencodeEvent): string | null { - const payload = event.payload as unknown as Record; - const eventType = typeof payload.type === 'string' ? payload.type : null; - if (eventType !== 'message.part.updated') { - return null; - } - - const properties = - typeof payload.properties === 'object' && payload.properties !== null - ? (payload.properties as Record) - : null; - const part = - properties?.part && typeof properties.part === 'object' - ? (properties.part as Record) - : null; - const messageId = typeof part?.messageID === 'string' - ? part.messageID - : typeof part?.messageId === 'string' - ? part.messageId - : null; - const partId = typeof part?.id === 'string' - ? part.id - : typeof part?.partID === 'string' - ? part.partID - : typeof part?.partId === 'string' - ? part.partId - : null; - - if (!messageId || !partId) { - return null; - } - - return `${event.directory}:${messageId}:${partId}`; - } - - private getGlobalSseCoalesceKey(event: RoutedOpencodeEvent): string | null { - const payload = event.payload as unknown as Record; - const eventType = typeof payload.type === 'string' ? payload.type : null; - if (!eventType) { - return null; - } - - const properties = - typeof payload.properties === 'object' && payload.properties !== null - ? (payload.properties as Record) - : null; - - if (eventType === 'session.status') { - const sessionId = typeof properties?.sessionID === 'string' - ? properties.sessionID - : typeof properties?.sessionId === 'string' - ? properties.sessionId - : null; - if (!sessionId) { - return null; - } - return `session.status:${event.directory}:${sessionId}`; - } - - if (eventType === 'openchamber:session-status') { - const sessionId = typeof properties?.sessionId === 'string' - ? properties.sessionId - : typeof properties?.sessionID === 'string' - ? properties.sessionID - : null; - if (!sessionId) { - return null; - } - return `openchamber:session-status:${sessionId}`; - } - - if (eventType === 'message.part.updated') { - const partKey = this.getGlobalSseUpdatedPartKey(event); - if (!partKey) { - return null; - } - return `message.part.updated:${partKey}`; - } - - return null; - } - - private flushGlobalSseQueue = () => { - if (this.globalSseFlushTimer) { - clearTimeout(this.globalSseFlushTimer); - this.globalSseFlushTimer = null; - } - - if (this.globalSseQueue.length === 0) { - return; - } - - const events = this.globalSseQueue; - const skip = this.globalSseStaleDeltas.size > 0 ? new Set(this.globalSseStaleDeltas) : undefined; - this.globalSseQueue = this.globalSseBuffer; - this.globalSseBuffer = events; - this.globalSseQueue.length = 0; - this.globalSseCoalesced.clear(); - this.globalSseStaleDeltas.clear(); - this.globalSseLastFlushAt = Date.now(); - - for (const event of events) { - if (!event) continue; - if (skip) { - const deltaKey = this.getGlobalSseDeltaKey(event); - if (deltaKey && skip.has(deltaKey)) { - continue; - } - } - for (const listener of this.globalSseListeners) { - try { - listener(event); - } catch (error) { - console.warn('[OpencodeClient] Global SSE listener error:', error); - } - } - } - - this.globalSseBuffer.length = 0; - }; - - private scheduleGlobalSseFlush() { - if (this.globalSseFlushTimer) { - return; - } - const elapsed = Date.now() - this.globalSseLastFlushAt; - const delay = Math.max(0, 16 - elapsed); - this.globalSseFlushTimer = setTimeout(this.flushGlobalSseQueue, delay); - } - - private enqueueGlobalSseEvent(event: RoutedOpencodeEvent) { - const key = this.getGlobalSseCoalesceKey(event); - if (key) { - const existingIndex = this.globalSseCoalesced.get(key); - if (existingIndex !== undefined) { - this.globalSseQueue[existingIndex] = undefined; - const updatedPartKey = this.getGlobalSseUpdatedPartKey(event); - if (updatedPartKey) { - this.globalSseStaleDeltas.add(updatedPartKey); - } - } - this.globalSseCoalesced.set(key, this.globalSseQueue.length); - } - - this.globalSseQueue.push(event); - this.scheduleGlobalSseFlush(); - } - - private async runGlobalSseLoop(abortController: AbortController): Promise { - let attempt = 0; - const RECONNECT_DELAY_MS = 250; - const STREAM_YIELD_MS = 8; - const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - - while (!abortController.signal.aborted) { - try { - const result = await this.client.global.event({ - signal: abortController.signal, - onSseError: (error: unknown) => { - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - return; - } - this.notifyGlobalSseError(error); - }, - }); - - attempt = 0; - this.globalSseIsConnected = true; - if (!abortController.signal.aborted) { - this.notifyGlobalSseOpen(); - } - - let yielded = Date.now(); - - for await (const event of result.stream) { - if (abortController.signal.aborted) { - break; - } - - const directory = typeof event.directory === 'string' && event.directory.length > 0 - ? event.directory - : 'global'; - const routed = this.normalizeRoutedSsePayload({ - directory, - payload: event.payload, - }); - if (!routed) { - continue; - } - - this.emitGlobalSseEvent(routed); - if (Date.now() - yielded >= STREAM_YIELD_MS) { - yielded = Date.now(); - await wait(0); - } - } - - this.globalSseIsConnected = false; - } catch (error: unknown) { - this.globalSseIsConnected = false; - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - return; - } - console.error('[OpencodeClient] Global SSE stream error (will retry):', error); - this.notifyGlobalSseError(error); - } - - if (abortController.signal.aborted) { - break; - } - - attempt += 1; - await wait(Math.min(RECONNECT_DELAY_MS * Math.max(attempt, 1), 2000)); - } - - this.flushGlobalSseQueue(); - } - - subscribeToGlobalEvents( - onEvent: (event: RoutedOpencodeEvent) => void, - onError?: (error: unknown) => void, - onOpen?: () => void, - options?: { directory?: string | null } - ): () => void { - const directoryFilter = this.normalizeCandidatePath(options?.directory ?? null); - const listener = (event: RoutedOpencodeEvent) => { - if (directoryFilter && event.directory !== directoryFilter) { - return; - } - onEvent(event); - }; - - this.globalSseListeners.add(listener); - - if (onOpen) { - this.globalSseOpenListeners.add(onOpen); - if (this.globalSseIsConnected) { - setTimeout(() => { - if (this.globalSseOpenListeners.has(onOpen)) { - try { - onOpen(); - } catch (error) { - console.warn('[OpencodeClient] Global SSE open handler error:', error); - } - } - }, 0); - } - } - - if (onError) { - this.globalSseErrorListeners.add(onError); - } - - this.ensureGlobalSseStarted(); - - return () => { - this.globalSseListeners.delete(listener); - if (onOpen) { - this.globalSseOpenListeners.delete(onOpen); - } - if (onError) { - this.globalSseErrorListeners.delete(onError); - } - this.maybeStopGlobalSse(); - }; - } - - // Event Streaming using SDK SSE (Server-Sent Events) with AsyncGenerator - subscribeToEvents( - onMessage: (event: { type: string; properties?: Record }) => void, - onError?: (error: unknown) => void, - onOpen?: () => void, - directoryOverride?: string | null, - options?: { scope?: 'global' | 'directory'; key?: string } - ): () => void { - const subscriptionKey = options?.key ?? 'default'; - const scope = options?.scope ?? 'directory'; - const existingController = this.sseAbortControllers.get(subscriptionKey); - if (existingController) { - existingController.abort(); - } - - // Create new AbortController for this subscription - const abortController = new AbortController(); - this.sseAbortControllers.set(subscriptionKey, abortController); - - let lastEventId: string | undefined; - - if (scope === 'global') { - let globalUnsub: (() => void) | null = null; - - const attachDirectory = (event: RoutedOpencodeEvent): Event => { - if (event.directory === 'global') { - return event.payload; - } - - const payloadRecord = event.payload as unknown as Record; - const existingProperties = - typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null - ? (payloadRecord.properties as Record) - : {}; - - if (existingProperties.directory === event.directory) { - return event.payload; - } - - return { - ...payloadRecord, - properties: { - ...existingProperties, - directory: event.directory, - }, - } as Event; - }; - - const cleanup = () => { - if (globalUnsub) { - try { - globalUnsub(); - } catch { - // ignore - } - globalUnsub = null; - } - - if (this.sseAbortControllers.get(subscriptionKey) === abortController) { - this.sseAbortControllers.delete(subscriptionKey); - } - }; - - abortController.signal.addEventListener('abort', cleanup, { once: true }); - - globalUnsub = this.subscribeToGlobalEvents( - (event) => { - if (abortController.signal.aborted) { - return; - } - onMessage(attachDirectory(event)); - }, - onError - ? (error) => { - if (!abortController.signal.aborted) { - onError(error); - } - } - : undefined, - onOpen - ? () => { - if (!abortController.signal.aborted) { - onOpen(); - } - } - : undefined, - ); - - return () => { - cleanup(); - abortController.abort(); - }; - } - - const normalizeEventPayload = (payload: unknown): Event | null => { - if (!payload || typeof payload !== 'object') { - return null; - } - - const record = payload as Record; - if (typeof record.type === 'string') { - return record as Event; - } - - const nestedPayload = record.payload; - if (nestedPayload && typeof nestedPayload === 'object') { - const nestedRecord = nestedPayload as Record; - if (typeof nestedRecord.type === 'string') { - if (typeof record.directory === 'string' && record.directory.length > 0) { - const existingProperties = - typeof nestedRecord.properties === 'object' && nestedRecord.properties !== null - ? (nestedRecord.properties as Record) - : null; - const properties = { - ...(existingProperties ?? {}), - directory: record.directory, - }; - return { ...nestedRecord, properties } as Event; - } - return nestedRecord as Event; - } - } - - return null; - }; - - - console.log('[OpencodeClient] Starting SSE subscription...'); - - // Start async generator in background with reconnect on failure - (async () => { - const resolvedDirectory = - typeof directoryOverride === 'string' && directoryOverride.trim().length > 0 - ? directoryOverride.trim() - : this.currentDirectory; - - console.log('[OpencodeClient] Connecting to SSE with directory:', resolvedDirectory ?? 'default'); - - const connect = async (attempt: number): Promise => { - try { - const subscribeParameters = resolvedDirectory ? { directory: resolvedDirectory } : undefined; - const subscribeOptions: { - signal: AbortSignal; - sseDefaultRetryDelay: number; - sseMaxRetryDelay: number; - onSseError?: (error: unknown) => void; - onSseEvent: (event: StreamEvent) => void; - headers?: Record; - } = { - signal: abortController.signal, - sseDefaultRetryDelay: 3000, - sseMaxRetryDelay: 30000, - onSseError: (error: unknown) => { - if (error instanceof Error && error.name === 'AbortError') { - return; - } - console.error('[OpencodeClient] SSE error:', error); - if (onError && !abortController.signal.aborted) { - onError(error); - } - }, - onSseEvent: (event: StreamEvent) => { - if (abortController.signal.aborted) return; - if (event.id && typeof event.id === 'string') { - lastEventId = event.id; - } - const payload = event.data; - const normalized = normalizeEventPayload(payload); - if (normalized) { - onMessage(normalized); - } - }, - }; - - if (lastEventId) { - subscribeOptions.headers = { ...(subscribeOptions.headers || {}), 'Last-Event-ID': lastEventId }; - } - - const result = await this.client.event.subscribe(subscribeParameters, subscribeOptions); - - if (onOpen && !abortController.signal.aborted) { - console.log('[OpencodeClient] SSE connection opened'); - onOpen(); - } - - for await (const _ of result.stream) { - void _; - if (abortController.signal.aborted) { - console.log('[OpencodeClient] SSE stream aborted'); - break; - } - } - } catch (error: unknown) { - if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) { - console.log('[OpencodeClient] SSE stream aborted normally'); - return; - } - console.error('[OpencodeClient] SSE stream error (will retry):', error); - if (onError) { - onError(error); - } - const delay = Math.min(3000 * Math.pow(2, attempt), 30000); - await new Promise((resolve) => setTimeout(resolve, delay)); - if (!abortController.signal.aborted) { - await connect(attempt + 1); - } - return; - } - - if (!abortController.signal.aborted) { - const delay = Math.min(3000 * Math.pow(2, attempt), 30000); - await new Promise((resolve) => setTimeout(resolve, delay)); - await connect(attempt + 1); - } - }; - - try { - await connect(0); - } finally { - console.log('[OpencodeClient] SSE subscription cleanup'); - if (this.sseAbortControllers.get(subscriptionKey) === abortController) { - this.sseAbortControllers.delete(subscriptionKey); - } - } - })(); - - // Return cleanup function - return () => { - if (this.sseAbortControllers.get(subscriptionKey) === abortController) { - this.sseAbortControllers.delete(subscriptionKey); - } - abortController.abort(); - }; - - } + // SSE infrastructure removed — EventPipeline in sync/event-pipeline.ts handles + // all SSE event ingestion via the SDK's global.event() async iterator. // File Operations async readFile(path: string): Promise { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 81975005..dc97187a 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -311,6 +311,11 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setAutoDeleteAfterDays(normalized); } } + if (settings.sessionRetentionAction === 'archive' || settings.sessionRetentionAction === 'delete') { + if (settings.sessionRetentionAction !== store.sessionRetentionAction) { + store.setSessionRetentionAction(settings.sessionRetentionAction); + } + } if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { queueStore.setQueueMode(settings.queueModeEnabled); @@ -522,6 +527,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { result.autoDeleteAfterDays = candidate.autoDeleteAfterDays; } + if (candidate.sessionRetentionAction === 'archive' || candidate.sessionRetentionAction === 'delete') { + result.sessionRetentionAction = candidate.sessionRetentionAction; + } if (typeof candidate.tunnelProvider === 'string') { const provider = candidate.tunnelProvider.trim().toLowerCase(); if (provider.length > 0) { @@ -850,32 +858,57 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { return result; }; -const fetchWebSettings = async (): Promise => { - const runtimeSettings = getRuntimeSettingsAPI(); - if (runtimeSettings) { - try { - const result = await runtimeSettings.load(); - return sanitizeWebSettings(result.settings); - } catch (error) { - console.warn('Failed to load shared settings from runtime settings API:', error); +// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup +let _settingsCache: { value: DesktopSettings | null; at: number } | null = null; +let _settingsInflight: Promise | null = null; +const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst - } +const fetchWebSettings = async (): Promise => { + // Return cached if fresh + if (_settingsCache && Date.now() - _settingsCache.at < SETTINGS_CACHE_TTL) { + return _settingsCache.value; } - try { - const response = await fetch('/api/config/settings', { - method: 'GET', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { + // Dedup concurrent calls + if (_settingsInflight) return _settingsInflight; + + _settingsInflight = (async (): Promise => { + const runtimeSettings = getRuntimeSettingsAPI(); + if (runtimeSettings) { + try { + const result = await runtimeSettings.load(); + const settings = sanitizeWebSettings(result.settings); + _settingsCache = { value: settings, at: Date.now() }; + return settings; + } catch (error) { + console.warn('Failed to load shared settings from runtime settings API:', error); + } + } + + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return null; + } + const data = await response.json().catch(() => null); + const settings = sanitizeWebSettings(data); + _settingsCache = { value: settings, at: Date.now() }; + return settings; + } catch (error) { + console.warn('Failed to load shared settings from server:', error); return null; } - const data = await response.json().catch(() => null); - return sanitizeWebSettings(data); - } catch (error) { - console.warn('Failed to load shared settings from server:', error); - return null; - } + })().finally(() => { _settingsInflight = null; }); + + return _settingsInflight; +}; + +/** Invalidate cached settings (call after a successful PUT) */ +export const invalidateSettingsCache = (): void => { + _settingsCache = null; }; export const syncDesktopSettings = async (): Promise => { @@ -916,12 +949,16 @@ export const syncDesktopSettings = async (): Promise => { } }; -export const updateDesktopSettings = async (changes: Partial): Promise => { - if (typeof window === 'undefined') { - return; - } +// Coalesce rapid updateDesktopSettings calls into a single PUT +let _pendingSettingsChanges: Partial | null = null; +let _settingsFlushTimer: ReturnType | null = null; +const SETTINGS_DEBOUNCE_MS = 200; - // Desktop shell uses the same HTTP settings API as web. +const _flushSettingsUpdate = async (): Promise => { + const changes = _pendingSettingsChanges; + _pendingSettingsChanges = null; + _settingsFlushTimer = null; + if (!changes || Object.keys(changes).length === 0) return; const runtimeSettings = getRuntimeSettingsAPI(); if (runtimeSettings) { @@ -956,12 +993,27 @@ export const updateDesktopSettings = async (changes: Partial): if (updated) { persistToLocalStorage(updated); applyDesktopUiPreferences(updated); + // Invalidate GET cache so next read sees the fresh data + _settingsCache = null; } } catch (error) { console.warn('Failed to update shared settings via API:', error); } }; +export const updateDesktopSettings = async (changes: Partial): Promise => { + if (typeof window === 'undefined') { + return; + } + + _pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes }; + + if (_settingsFlushTimer) { + clearTimeout(_settingsFlushTimer); + } + _settingsFlushTimer = setTimeout(() => void _flushSettingsUpdate(), SETTINGS_DEBOUNCE_MS); +}; + export const initializeAppearancePreferences = async (): Promise => { if (typeof window === 'undefined') { return; diff --git a/packages/ui/src/lib/search/fuzzySearch.ts b/packages/ui/src/lib/search/fuzzySearch.ts new file mode 100644 index 00000000..c047ca64 --- /dev/null +++ b/packages/ui/src/lib/search/fuzzySearch.ts @@ -0,0 +1,129 @@ +import Fuse from "fuse.js"; + +export interface FuzzySearchOptions { + threshold?: number; + distance?: number; + ignoreLocation?: boolean; + preferSubstring?: boolean; +} + +const DEFAULT_FUZZY_OPTIONS: Required = { + threshold: 0.4, + distance: 100, + ignoreLocation: true, + preferSubstring: true, +}; + +export function matchesFuzzyQuery( + target: string, + query: string, + options?: FuzzySearchOptions +): boolean { + if (!query) { + return true; + } + if (!target) { + return false; + } + + const mergedOptions = { ...DEFAULT_FUZZY_OPTIONS, ...options }; + + if (mergedOptions.preferSubstring && target.toLowerCase().includes(query.toLowerCase())) { + return true; + } + + const fuse = new Fuse([target], { + threshold: mergedOptions.threshold, + distance: mergedOptions.distance, + ignoreLocation: mergedOptions.ignoreLocation, + }); + + return fuse.search(query).length > 0; +} + +function getFuzzyMatchMask( + items: T[], + query: string, + getText: (item: T) => string, + options?: FuzzySearchOptions +): boolean[] { + if (!query) { + return items.map(() => true); + } + + const mergedOptions = { ...DEFAULT_FUZZY_OPTIONS, ...options }; + const queryLower = query.toLowerCase(); + const matches = new Array(items.length).fill(false); + const fuzzyCandidateTexts: string[] = []; + const fuzzyCandidateIndices: number[] = []; + + for (let i = 0; i < items.length; i++) { + const target = getText(items[i]); + if (!target) { + continue; + } + + if (mergedOptions.preferSubstring && target.toLowerCase().includes(queryLower)) { + matches[i] = true; + continue; + } + + fuzzyCandidateTexts.push(target); + fuzzyCandidateIndices.push(i); + } + + if (fuzzyCandidateTexts.length === 0) { + return matches; + } + + const fuse = new Fuse(fuzzyCandidateTexts, { + threshold: mergedOptions.threshold, + distance: mergedOptions.distance, + ignoreLocation: mergedOptions.ignoreLocation, + }); + + for (const result of fuse.search(query)) { + matches[fuzzyCandidateIndices[result.refIndex]] = true; + } + + return matches; +} + +export function filterByFuzzyQuery( + items: T[], + query: string, + getText: (item: T) => string, + options?: FuzzySearchOptions +): T[] { + const matches = getFuzzyMatchMask(items, query, getText, options); + const matching: T[] = []; + + for (let i = 0; i < items.length; i++) { + if (matches[i]) { + matching.push(items[i]); + } + } + + return matching; +} + +export function partitionByFuzzyQuery( + items: T[], + query: string, + getText: (item: T) => string, + options?: FuzzySearchOptions +): { matching: T[]; other: T[] } { + const matches = getFuzzyMatchMask(items, query, getText, options); + const matching: T[] = []; + const other: T[] = []; + + for (let i = 0; i < items.length; i++) { + if (matches[i]) { + matching.push(items[i]); + continue; + } + other.push(items[i]); + } + + return { matching, other }; +} diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 61b3a011..d7dce064 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -230,13 +230,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open git panel', description: 'Switch to the git panel', }, - { - id: 'open_timeline', - defaultCombo: 'mod+t', - label: 'Open timeline', - description: 'Open the timeline dialog', - customizable: true, - }, { id: 'open_help', defaultCombo: 'mod+.', diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index 687852d6..97d317b4 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,6 +1,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { isTauriShell } from "@/lib/desktop"; +import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -129,24 +130,10 @@ export function formatDirectoryName(path: string | null | undefined, homeDirecto return name || "/"; } -import Fuse from 'fuse.js'; - /** * Fuzzy search using Fuse.js with typo tolerance. * Returns true if query fuzzy-matches target (e.g. "coude" matches "claude") */ export function fuzzyMatch(target: string, query: string): boolean { - if (!query) return true; - if (!target) return false; - - // Quick exact substring check first - if (target.toLowerCase().includes(query.toLowerCase())) return true; - - const fuse = new Fuse([target], { - threshold: 0.4, // 0 = exact, 1 = match anything - distance: 100, - ignoreLocation: true, - }); - const results = fuse.search(query); - return results.length > 0; + return matchesFuzzyQuery(target, query); } diff --git a/packages/ui/src/lib/voice/realtimeClientTools.ts b/packages/ui/src/lib/voice/realtimeClientTools.ts index 795b332e..7778ddbf 100644 --- a/packages/ui/src/lib/voice/realtimeClientTools.ts +++ b/packages/ui/src/lib/voice/realtimeClientTools.ts @@ -1,7 +1,8 @@ import { z } from "zod"; -import { useSessionStore } from "@/stores/useSessionStore"; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useConfigStore } from "@/stores/useConfigStore"; -import { usePermissionStore } from "@/stores/permissionStore"; +import { getSyncPermissions } from "@/sync/sync-refs"; +import { respondToPermission } from "@/sync/session-actions"; /** * Static client tools for the realtime voice interface. @@ -25,7 +26,7 @@ export const realtimeClientTools = { } // Get current session ID from store - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; if (!sessionId) { console.error("[Voice] No active session"); return "error (no active session)"; @@ -40,7 +41,7 @@ export const realtimeClientTools = { try { console.log("[Voice] Sending message to session:", sessionId); - await useSessionStore + await useSessionUIStore .getState() .sendMessage(parsed.data.message, currentProviderId, currentModelId, currentAgentName ?? undefined); return "sent"; @@ -67,14 +68,14 @@ export const realtimeClientTools = { } // Get current session ID from store - const sessionId = useSessionStore.getState().currentSessionId; + const sessionId = useSessionUIStore.getState().currentSessionId; if (!sessionId) { console.error("[Voice] No active session"); return "error (no active session)"; } // Get pending permissions for this session - const permissions = usePermissionStore.getState().permissions.get(sessionId); + const permissions = getSyncPermissions(sessionId); if (!permissions || permissions.length === 0) { console.error("[Voice] No pending permission requests"); return "error (no pending permission request)"; @@ -92,7 +93,7 @@ export const realtimeClientTools = { // Respond to the permission based on decision const response: "once" | "always" | "reject" = decision === "allow" ? "once" : "reject"; - await usePermissionStore.getState().respondToPermission(sessionId, request.id, response); + await respondToPermission(sessionId, request.id, response); return "done"; } catch (error) { diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index f8a22fef..303e161d 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -5,7 +5,7 @@ */ import { toast } from '@/components/ui'; -import { useSessionStore } from '@/stores/useSessionStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; @@ -132,14 +132,13 @@ const initializeSessionForWorktree = (sessionId: string, metadata: { createdFromBranch?: string; kind?: 'pr' | 'standard'; }) => { - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); const configState = useConfigStore.getState(); sessionStore.initializeNewOpenChamberSession(sessionId, configState.agents); sessionStore.setSessionDirectory(sessionId, metadata.path); sessionStore.setWorktreeMetadata(sessionId, metadata); applyDefaultAgentAndModelSelection(sessionId, configState); useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false }); - void sessionStore.loadSessions().catch(() => undefined); }; @@ -183,7 +182,7 @@ const createInstantWorktreeDraft = async (options?: { // Lock the draft immediately so no React effect can reset it to the project // root while we await the preview / worktree creation below. - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); if (sessionStore.newSessionDraft?.open) { sessionStore.overrideNewSessionDraftTarget({ projectId: projectRef.id, @@ -195,7 +194,7 @@ const createInstantWorktreeDraft = async (options?: { }); } else { sessionStore.openNewSessionDraft({ - projectId: projectRef.id, + selectedProjectId: projectRef.id, directoryOverride: projectRef.path, pendingWorktreeRequestId: pendingRequestId, preserveDirectoryOverride: true, @@ -214,7 +213,7 @@ const createInstantWorktreeDraft = async (options?: { // Refine draft target once we know the actual worktree path from the preview. if (preview?.path) { - useSessionStore.getState().overrideNewSessionDraftTarget({ + useSessionUIStore.getState().overrideNewSessionDraftTarget({ projectId: projectRef.id, directoryOverride: preview.path, pendingWorktreeRequestId: pendingRequestId, @@ -236,7 +235,7 @@ const createInstantWorktreeDraft = async (options?: { }); resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path); - useSessionStore.getState().overrideNewSessionDraftTarget({ + useSessionUIStore.getState().overrideNewSessionDraftTarget({ projectId: projectRef.id, directoryOverride: metadata.path, pendingWorktreeRequestId: null, @@ -246,17 +245,16 @@ const createInstantWorktreeDraft = async (options?: { initialPrompt: options?.initialPrompt, }); useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false }); - void useSessionStore.getState().loadSessions().catch(() => undefined); return metadata.path; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create worktree'; - const requestId = useSessionStore.getState().newSessionDraft.pendingWorktreeRequestId; + const requestId = useSessionUIStore.getState().newSessionDraft.pendingWorktreeRequestId; if (requestId) { rejectPendingDraftWorktreeRequest(requestId, error instanceof Error ? error : new Error(message)); - useSessionStore.getState().resolvePendingDraftWorktreeTarget(requestId, null); + useSessionUIStore.getState().resolvePendingDraftWorktreeTarget(requestId, null); } - useSessionStore.getState().setDraftBootstrapPendingDirectory(null); + useSessionUIStore.getState().setDraftBootstrapPendingDirectory(null); toast.error('Failed to create worktree', { description: message, }); @@ -329,7 +327,6 @@ export async function createWorktreeOnly(): Promise { }); - void useSessionStore.getState().loadSessions().catch(() => undefined); return metadata.path; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create worktree'; @@ -416,7 +413,7 @@ export async function createWorktreeSessionForBranch( }; // Create the session - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { // Clean up the worktree if session creation failed @@ -516,7 +513,7 @@ export async function createWorktreeSessionForNewBranch( kind, }; - const sessionStore = useSessionStore.getState(); + const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { await removeProjectWorktree(projectRef, metadata, { deleteLocalBranch: true }).catch(() => undefined); diff --git a/packages/ui/src/lib/worktrees/branchSearch.ts b/packages/ui/src/lib/worktrees/branchSearch.ts new file mode 100644 index 00000000..3bf564cf --- /dev/null +++ b/packages/ui/src/lib/worktrees/branchSearch.ts @@ -0,0 +1,67 @@ +import { partitionByFuzzyQuery } from "@/lib/search/fuzzySearch"; + +export interface RankedBranchGroups { + matching: Array<{ + label: string; + value: string; + source: 'local' | 'remote'; + }>; + otherLocal: string[]; + otherRemote: string[]; +} + +export function rankBranchesForQuery(args: { + localBranches: string[]; + remoteBranches: string[]; + query: string; +}): RankedBranchGroups { + const { localBranches, remoteBranches, query } = args; + const normalizedQuery = query.trim(); + + if (!normalizedQuery) { + return { + matching: [], + otherLocal: localBranches, + otherRemote: remoteBranches, + }; + } + + const localPartition = partitionByFuzzyQuery(localBranches, normalizedQuery, (branch) => branch); + const remotePartition = partitionByFuzzyQuery(remoteBranches, normalizedQuery, (branch) => branch); + const matching: RankedBranchGroups['matching'] = []; + const otherLocal = localPartition.other; + const otherRemote = remotePartition.other; + + for (const branch of localPartition.matching) { + matching.push({ + label: branch, + value: branch, + source: 'local', + }); + } + + for (const branch of remotePartition.matching) { + matching.push({ + label: branch, + value: `remotes/${branch}`, + source: 'remote', + }); + } + + matching.sort((a, b) => { + const byLabel = a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); + if (byLabel !== 0) { + return byLabel; + } + if (a.source !== b.source) { + return a.source.localeCompare(b.source); + } + return a.value.localeCompare(b.value); + }); + + return { + matching, + otherLocal, + otherRemote, + }; +} diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 6fa7d8a9..d23bc4ea 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -177,34 +177,60 @@ const toCreatePayload = (args: { }; }; +// Cache worktree listings to avoid repeated git worktree list + rev-parse calls +const _worktreeListCache = new Map(); +const _worktreeListInflight = new Map>(); +const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds + export async function listProjectWorktrees(project: ProjectRef): Promise { const projectDirectory = normalizePath(project.path); - const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory); - const normalizedProjectDirectory = normalizePath(projectDirectory); - const worktrees = await git.worktree.list(projectDirectory).catch(() => []); - const results: WorktreeMetadata[] = worktrees - .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) - .map((entry) => { - const worktreePath = normalizePath(entry.path); - const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim(); - const name = (entry.name || '').trim(); - return { - source: 'sdk' as const, - name: name || deriveSdkWorktreeNameFromDirectory(worktreePath), - path: worktreePath, - projectDirectory: metadataProjectDirectory, - branch, - label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath), - }; - }) - .filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory); + // Return cached if fresh + const cached = _worktreeListCache.get(projectDirectory); + if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) { + return cached.value; + } - return results.sort((a, b) => { - const aLabel = (a.label || a.branch || a.path).toLowerCase(); - const bLabel = (b.label || b.branch || b.path).toLowerCase(); - return aLabel.localeCompare(bLabel); + // Dedup in-flight requests + const inflight = _worktreeListInflight.get(projectDirectory); + if (inflight) return inflight; + + const promise = (async (): Promise => { + const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory); + const normalizedProjectDirectory = normalizePath(projectDirectory); + + const worktrees = await git.worktree.list(projectDirectory).catch(() => []); + const results: WorktreeMetadata[] = worktrees + .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) + .map((entry) => { + const worktreePath = normalizePath(entry.path); + const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim(); + const name = (entry.name || '').trim(); + return { + source: 'sdk' as const, + name: name || deriveSdkWorktreeNameFromDirectory(worktreePath), + path: worktreePath, + projectDirectory: metadataProjectDirectory, + branch, + label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath), + }; + }) + .filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory); + + const sorted = results.sort((a, b) => { + const aLabel = (a.label || a.branch || a.path).toLowerCase(); + const bLabel = (b.label || b.branch || b.path).toLowerCase(); + return aLabel.localeCompare(bLabel); + }); + + _worktreeListCache.set(projectDirectory, { value: sorted, at: Date.now() }); + return sorted; + })().finally(() => { + _worktreeListInflight.delete(projectDirectory); }); + + _worktreeListInflight.set(projectDirectory, promise); + return promise; } export type CreateWorktreeArgs = { diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 97445108..4be9544c 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -24,64 +24,15 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI throw new Error('Runtime APIs not provided for legacy UI entrypoint.'); })(); -await syncDesktopSettings(); -await initializeAppearancePreferences(); +await Promise.all([ + syncDesktopSettings(), + initializeAppearancePreferences(), + applyPersistedDirectoryPreferences(), +]); startAppearanceAutoSave(); startModelPrefsAutoSave(); startTypographyWatcher(); -await applyPersistedDirectoryPreferences(); -if (typeof window !== 'undefined') { - (window as { debugContextTokens?: () => void }).debugContextTokens = () => { - const sessionStore = (window as { __zustand_session_store__?: { getState: () => { currentSessionId?: string; messages: Map; sessionContextUsage: Map; getContextUsage: (contextLimit: number, outputLimit: number) => unknown } } }).__zustand_session_store__; - if (!sessionStore) { - return; - } - - const state = sessionStore.getState(); - const currentSessionId = state.currentSessionId; - - if (!currentSessionId) { - return; - } - - const sessionMessages = state.messages.get(currentSessionId) || []; - const assistantMessages = sessionMessages.filter((m: { info: { role: string } }) => m.info.role === 'assistant'); - - if (assistantMessages.length === 0) { - return; - } - - const lastMessage = assistantMessages[assistantMessages.length - 1]; - const tokens = (lastMessage.info as { tokens?: { input?: number; output?: number; reasoning?: number; cache?: { read?: number; write?: number } } }).tokens; - - if (tokens && typeof tokens === 'object') { - - console.debug('Token breakdown:', { - base: (tokens.input || 0) + (tokens.output || 0) + (tokens.reasoning || 0), - cache: tokens.cache ? (tokens.cache.read || 0) + (tokens.cache.write || 0) : 0 - }); - } - - void state.sessionContextUsage.get(currentSessionId); - - const configStore = (window as { __zustand_config_store__?: { getState: () => { getCurrentModel: () => { limit?: { context?: number } } | null } } }).__zustand_config_store__; - if (configStore) { - const currentModel = configStore.getState().getCurrentModel(); - const contextLimit = currentModel?.limit?.context || 0; - const outputLimit = - currentModel && currentModel.limit && typeof currentModel.limit === 'object' - ? Math.max(((currentModel.limit as { output?: number }).output ?? 0), 0) - : 0; - - if (contextLimit > 0) { - - void state.getContextUsage(contextLimit, outputLimit); - } - } - }; - -} const rootElement = document.getElementById('root'); if (!rootElement) { diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index b468621a..09c37383 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -47,8 +47,6 @@ interface ContextActions { saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void; getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined; - - analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: any[], messages: Map) => Promise>; getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map) => ContextUsage | null; @@ -201,171 +199,6 @@ export const useContextStore = create()( return modelMap.get(`${providerId}/${modelId}`); }, - analyzeAndSaveExternalSessionChoices: async (sessionId: string, agents: any[], messages: Map) => { - const { saveAgentModelForSession, saveAgentModelVariantForSession } = get(); - - const agentLastChoices = new Map< - string, - { - providerId: string; - modelId: string; - timestamp: number; - } - >(); - - const extractAgentFromMessage = (messageInfo: any, messageIndex: number): string | null => { - - if ("mode" in messageInfo && messageInfo.mode && typeof messageInfo.mode === "string") { - const modeAgent = agents.find((a) => a.name === messageInfo.mode); - if (modeAgent) { - return messageInfo.mode; - } - } - - if ("agent" in messageInfo && messageInfo.agent && typeof messageInfo.agent === "string") { - const agent = agents.find((a) => a.name === messageInfo.agent); - if (agent) { - return messageInfo.agent; - } - } - - if (messageInfo.providerID && messageInfo.modelID) { - const matchingAgent = agents.find((agent) => agent.model?.providerID === messageInfo.providerID && agent.model?.modelID === messageInfo.modelID); - if (matchingAgent) { - return matchingAgent.name; - } - } - - const { currentAgentContext } = get(); - const contextAgent = currentAgentContext.get(sessionId); - if (contextAgent && agents.find((a) => a.name === contextAgent)) { - return contextAgent; - } - - if (messageIndex > 0 && messageInfo.providerID && messageInfo.modelID) { - - const sessionMessages = messages.get(sessionId) || []; - const assistantMessages = sessionMessages.filter((m) => m.info.role === "assistant").sort((a, b) => a.info.time.created - b.info.time.created); - - for (let i = messageIndex - 1; i >= 0; i--) { - const prevMessage = assistantMessages[i]; - const prevInfo = prevMessage.info as any; - if (prevInfo.providerID === messageInfo.providerID && prevInfo.modelID === messageInfo.modelID) { - - if (prevInfo.mode && typeof prevInfo.mode === "string") { - const prevModeAgent = agents.find((a) => a.name === prevInfo.mode); - if (prevModeAgent) { - return prevInfo.mode; - } - } - - const prevMatchingAgent = agents.find((agent) => agent.model?.providerID === prevInfo.providerID && agent.model?.modelID === prevInfo.modelID); - if (prevMatchingAgent) { - return prevMatchingAgent.name; - } - } - } - } - - if (messageInfo.providerID && messageInfo.modelID) { - const buildAgent = agents.find((a) => a.name === "build"); - if (buildAgent) { - return "build"; - } - } - - return null; - }; - - const sessionMessages = messages.get(sessionId) || []; - - const allMessages = sessionMessages.filter((m: any) => m.info.role === "assistant" || m.info.role === "user").sort((a: any, b: any) => a.info.time.created - b.info.time.created); - const assistantMessages = sessionMessages.filter((m: any) => m.info.role === "assistant").sort((a: any, b: any) => a.info.time.created - b.info.time.created); - - // Track variant from user messages to apply to corresponding assistant response - let pendingVariant: string | undefined = undefined; - let pendingUserModel: { providerID: string; modelID: string } | undefined = undefined; - - for (let messageIndex = 0; messageIndex < allMessages.length; messageIndex++) { - const message = allMessages[messageIndex]; - const { info } = message; - const infoAny = info as any; - - // User messages have variant and model info in different structure - if (infoAny.role === "user") { - const agentName = typeof infoAny.mode === 'string' && infoAny.mode.trim().length > 0 - ? infoAny.mode - : (typeof infoAny.agent === 'string' && infoAny.agent.trim().length > 0 ? infoAny.agent : undefined); - - const userProvider = typeof infoAny.model?.providerID === 'string' && infoAny.model.providerID.trim().length > 0 - ? infoAny.model.providerID - : (typeof infoAny.providerID === 'string' && infoAny.providerID.trim().length > 0 ? infoAny.providerID : undefined); - const userModel = typeof infoAny.model?.modelID === 'string' && infoAny.model.modelID.trim().length > 0 - ? infoAny.model.modelID - : (typeof infoAny.modelID === 'string' && infoAny.modelID.trim().length > 0 ? infoAny.modelID : undefined); - - const userVariant = typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0 - ? infoAny.variant - : undefined; - - if (agentName && userProvider && userModel && agents.find((a) => a.name === agentName)) { - const choice = { - providerId: userProvider, - modelId: userModel, - timestamp: info.time.created, - }; - const existing = agentLastChoices.get(agentName); - if (!existing || choice.timestamp > existing.timestamp) { - agentLastChoices.set(agentName, choice); - } - saveAgentModelVariantForSession(sessionId, agentName, userProvider, userModel, userVariant); - } - - // User message: variant is top-level, model is nested in model.providerID/modelID - pendingVariant = userVariant; - pendingUserModel = infoAny.model?.providerID && infoAny.model?.modelID - ? { providerID: infoAny.model.providerID, modelID: infoAny.model.modelID } - : undefined; - continue; - } - - // Assistant message: providerID/modelID are top-level - if (infoAny.providerID && infoAny.modelID) { - const agentName = extractAgentFromMessage(infoAny, assistantMessages.indexOf(message)); - - if (agentName && agents.find((a) => a.name === agentName)) { - // Apply pending variant from user message if model matches - if (pendingUserModel && - pendingUserModel.providerID === infoAny.providerID && - pendingUserModel.modelID === infoAny.modelID) { - saveAgentModelVariantForSession(sessionId, agentName, infoAny.providerID, infoAny.modelID, pendingVariant); - } - - const choice = { - providerId: infoAny.providerID, - modelId: infoAny.modelID, - timestamp: info.time.created, - }; - - const existing = agentLastChoices.get(agentName); - if (!existing || choice.timestamp > existing.timestamp) { - agentLastChoices.set(agentName, choice); - } - } - } - - // Clear pending variant after processing assistant message - pendingVariant = undefined; - pendingUserModel = undefined; - } - - for (const [agentName, choice] of agentLastChoices) { - saveAgentModelForSession(sessionId, agentName, choice.providerId, choice.modelId); - } - - return agentLastChoices; - }, - getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map) => { if (!sessionId) return null; diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index 59da696e..83ae9c9f 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -1,4 +1,5 @@ import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2"; +import { retry } from "@/sync/retry"; export type GlobalSessionRecord = Session & { project?: { @@ -71,11 +72,14 @@ export async function listGlobalSessionPages( let cursor: number | undefined; while (true) { - const response = await apiClient.experimental.session.list({ - archived: options.archived, - limit: options.pageSize, - ...(cursor ? { cursor } : {}), - }); + const response = await retry( + () => apiClient.experimental.session.list({ + archived: options.archived, + limit: options.pageSize, + ...(cursor ? { cursor } : {}), + }), + { attempts: 3, delay: 500, retryIf: () => true }, + ); const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : []; if (payload.length === 0) { diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index 7c22a80b..5e28dc01 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -9,6 +9,13 @@ export interface QueuedMessage { content: string; attachments?: AttachedFile[]; createdAt: number; + /** Send config captured at queue time — used as-is when auto-sending */ + sendConfig?: { + providerID: string; + modelID: string; + agent?: string; + variant?: string; + }; } interface MessageQueueState { @@ -42,6 +49,7 @@ export const useMessageQueueStore = create()( content: message.content, attachments: message.attachments, createdAt: Date.now(), + sendConfig: message.sendConfig, }; set((state) => { diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts deleted file mode 100644 index 87c04101..00000000 --- a/packages/ui/src/stores/messageStore.ts +++ /dev/null @@ -1,3006 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import type { Message, Part } from "@opencode-ai/sdk/v2"; -import { opencodeClient } from "@/lib/opencode/client"; -import { isExecutionForkMetaText } from "@/lib/messages/executionMeta"; -import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from "@/lib/messages/providerAuthError"; -import type { SessionMemoryState, SessionHistoryMeta, MessageStreamLifecycle, AttachedFile } from "./types/sessionTypes"; -import { MEMORY_LIMITS, getMemoryLimits } from "./types/sessionTypes"; -import { - touchStreamingLifecycle, - removeLifecycleEntries, - clearLifecycleTimersForIds, - clearLifecycleCompletionTimer -} from "./utils/streamingUtils"; -import { extractTextFromPart, normalizeStreamingPart } from "./utils/messageUtils"; -import { filterMessagesByRevertPoint, normalizeMessageInfoForProjection } from "./utils/messageProjectors"; -import { getSafeStorage } from "./utils/safeStorage"; -import { useFileStore } from "./fileStore"; -import { useSessionStore } from "./sessionStore"; -import { useContextStore } from "./contextStore"; -import { useUIStore } from "./useUIStore"; - -// Helper function to clean up pending user message metadata -const cleanupPendingUserMessageMeta = ( - currentPending: Map, - - sessionId: string -): Map => { - const nextPending = new Map(currentPending); - nextPending.delete(sessionId); - return nextPending; -}; - -const COMPACTION_WINDOW_MS = 30_000; - -const timeoutRegistry = new Map>(); -const lastContentRegistry = new Map(); -const streamingCooldownTimers = new Map>(); -const loadMessagesInFlightBySession = new Map>(); -const loadMessagesRequestSeqBySession = new Map(); - -interface QueuedStreamingPart { - sessionId: string; - messageId: string; - part: Part; - role?: string; - currentSessionId?: string; -} - -interface QueuedPartDelta { - sessionId: string; - messageId: string; - partId: string; - field: string; - delta: string; - role?: string; - currentSessionId?: string; -} - -type StreamingPartImmediateHandler = ( - sessionId: string, - messageId: string, - part: Part, - role?: string, - currentSessionId?: string, -) => void; - -const queuedNonTextStreamingPartsByKey = new Map(); -const queuedNonTextStreamingPartOrder: string[] = []; -let nonTextStreamingFlushScheduled = false; -let nonTextStreamingFlushRafId: number | null = null; -let nonTextStreamingFlushTimeoutId: ReturnType | null = null; -const NON_TEXT_STREAMING_QUEUE_HARD_LIMIT = 2500; - -const queuedPartDeltasByKey = new Map(); -const queuedPartDeltaOrder: string[] = []; -let partDeltaFlushScheduled = false; -let partDeltaFlushRafId: number | null = null; -let partDeltaFlushTimeoutId: ReturnType | null = null; -const PART_DELTA_QUEUE_HARD_LIMIT = 3500; -const ENABLE_STREAMING_FRAME_BATCHING = true; -const TOOL_STREAMING_BATCH_DELAY_MS = 120; - -const clearNonTextStreamingFlushSchedule = () => { - if (nonTextStreamingFlushRafId !== null) { - cancelAnimationFrame(nonTextStreamingFlushRafId); - nonTextStreamingFlushRafId = null; - } - if (nonTextStreamingFlushTimeoutId !== null) { - clearTimeout(nonTextStreamingFlushTimeoutId); - nonTextStreamingFlushTimeoutId = null; - } - nonTextStreamingFlushScheduled = false; -}; - -const queuedStreamingPartKey = (entry: QueuedStreamingPart): string => { - const partKey = getPartKey(entry.part) ?? `${entry.part.type ?? "unknown"}`; - const roleKey = typeof entry.role === 'string' ? entry.role : ''; - return `${entry.sessionId}:${entry.messageId}:${roleKey}:${partKey}`; -}; - -const enqueueNonTextStreamingPart = (entry: QueuedStreamingPart) => { - const key = queuedStreamingPartKey(entry); - if (!queuedNonTextStreamingPartsByKey.has(key)) { - queuedNonTextStreamingPartOrder.push(key); - } - queuedNonTextStreamingPartsByKey.set(key, entry); -}; - -const flushQueuedNonTextStreamingParts = ( - immediateHandler: StreamingPartImmediateHandler, - filter?: (entry: QueuedStreamingPart) => boolean, -) => { - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - return; - } - - const batch: QueuedStreamingPart[] = []; - const nextOrder: string[] = []; - - for (const key of queuedNonTextStreamingPartOrder) { - const entry = queuedNonTextStreamingPartsByKey.get(key); - if (!entry) { - continue; - } - if (filter && !filter(entry)) { - nextOrder.push(key); - continue; - } - batch.push(entry); - queuedNonTextStreamingPartsByKey.delete(key); - } - - queuedNonTextStreamingPartOrder.length = 0; - queuedNonTextStreamingPartOrder.push(...nextOrder); - - if (batch.length === 0) { - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - } - return; - } - - for (const entry of batch) { - immediateHandler(entry.sessionId, entry.messageId, entry.part, entry.role, entry.currentSessionId); - } - - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - } -}; - -const flushQueuedNonTextStreamingPartsForSession = ( - immediateHandler: StreamingPartImmediateHandler, - sessionId: string, -) => { - flushQueuedNonTextStreamingParts(immediateHandler, (entry) => entry.sessionId === sessionId); -}; - -const flushQueuedNonTextStreamingPartsForMessage = ( - immediateHandler: StreamingPartImmediateHandler, - sessionId: string, - messageId: string, -) => { - flushQueuedNonTextStreamingParts( - immediateHandler, - (entry) => entry.sessionId === sessionId && entry.messageId === messageId, - ); -}; - -const discardQueuedNonTextStreamingPartsForSession = (sessionId: string): void => { - if (queuedNonTextStreamingPartOrder.length === 0) { - return; - } - - for (let i = queuedNonTextStreamingPartOrder.length - 1; i >= 0; i -= 1) { - const key = queuedNonTextStreamingPartOrder[i]; - const entry = queuedNonTextStreamingPartsByKey.get(key); - if (!entry) { - queuedNonTextStreamingPartOrder.splice(i, 1); - continue; - } - if (entry.sessionId === sessionId) { - queuedNonTextStreamingPartsByKey.delete(key); - queuedNonTextStreamingPartOrder.splice(i, 1); - } - } - - if (queuedNonTextStreamingPartOrder.length === 0) { - clearNonTextStreamingFlushSchedule(); - } -}; - -const scheduleNonTextStreamingFlush = (flush: () => void): void => { - if (nonTextStreamingFlushScheduled) { - return; - } - nonTextStreamingFlushScheduled = true; - nonTextStreamingFlushTimeoutId = setTimeout(() => { - nonTextStreamingFlushTimeoutId = null; - if (!nonTextStreamingFlushScheduled) { - return; - } - flush(); - }, TOOL_STREAMING_BATCH_DELAY_MS); -}; - -const clearPartDeltaFlushSchedule = () => { - if (partDeltaFlushRafId !== null) { - cancelAnimationFrame(partDeltaFlushRafId); - partDeltaFlushRafId = null; - } - if (partDeltaFlushTimeoutId !== null) { - clearTimeout(partDeltaFlushTimeoutId); - partDeltaFlushTimeoutId = null; - } - partDeltaFlushScheduled = false; -}; - -const queuedPartDeltaKey = (entry: QueuedPartDelta): string => { - const roleKey = typeof entry.role === 'string' ? entry.role : ''; - return `${entry.sessionId}:${entry.messageId}:${entry.partId}:${entry.field}:${roleKey}`; -}; - -const enqueuePartDelta = (entry: QueuedPartDelta) => { - const key = queuedPartDeltaKey(entry); - const existing = queuedPartDeltasByKey.get(key); - if (existing) { - existing.delta += entry.delta; - if (!existing.currentSessionId && entry.currentSessionId) { - existing.currentSessionId = entry.currentSessionId; - } - return; - } - - queuedPartDeltasByKey.set(key, { ...entry }); - queuedPartDeltaOrder.push(key); -}; - -const flushQueuedPartDeltas = ( - immediateHandler: ( - sessionId: string, - messageId: string, - partId: string, - field: string, - delta: string, - role?: string, - currentSessionId?: string, - ) => void, - filter?: (entry: QueuedPartDelta) => boolean, -) => { - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - return; - } - - const batch: QueuedPartDelta[] = []; - const nextOrder: string[] = []; - - for (const key of queuedPartDeltaOrder) { - const entry = queuedPartDeltasByKey.get(key); - if (!entry) { - continue; - } - if (filter && !filter(entry)) { - nextOrder.push(key); - continue; - } - batch.push(entry); - queuedPartDeltasByKey.delete(key); - } - - queuedPartDeltaOrder.length = 0; - queuedPartDeltaOrder.push(...nextOrder); - - if (batch.length === 0) { - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - } - return; - } - - for (const entry of batch) { - immediateHandler( - entry.sessionId, - entry.messageId, - entry.partId, - entry.field, - entry.delta, - entry.role, - entry.currentSessionId, - ); - } - - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - } -}; - -const flushQueuedPartDeltasForSession = ( - immediateHandler: ( - sessionId: string, - messageId: string, - partId: string, - field: string, - delta: string, - role?: string, - currentSessionId?: string, - ) => void, - sessionId: string, -) => { - flushQueuedPartDeltas(immediateHandler, (entry) => entry.sessionId === sessionId); -}; - -const flushQueuedPartDeltasForMessage = ( - immediateHandler: ( - sessionId: string, - messageId: string, - partId: string, - field: string, - delta: string, - role?: string, - currentSessionId?: string, - ) => void, - sessionId: string, - messageId: string, -) => { - flushQueuedPartDeltas( - immediateHandler, - (entry) => entry.sessionId === sessionId && entry.messageId === messageId, - ); -}; - -const discardQueuedPartDeltasForSession = (sessionId: string): void => { - if (queuedPartDeltaOrder.length === 0) { - return; - } - - for (let i = queuedPartDeltaOrder.length - 1; i >= 0; i -= 1) { - const key = queuedPartDeltaOrder[i]; - const entry = queuedPartDeltasByKey.get(key); - if (!entry) { - queuedPartDeltaOrder.splice(i, 1); - continue; - } - if (entry.sessionId === sessionId) { - queuedPartDeltasByKey.delete(key); - queuedPartDeltaOrder.splice(i, 1); - } - } - - if (queuedPartDeltaOrder.length === 0) { - clearPartDeltaFlushSchedule(); - } -}; - -const schedulePartDeltaFlush = (flush: () => void): void => { - if (partDeltaFlushScheduled) { - return; - } - partDeltaFlushScheduled = true; - partDeltaFlushTimeoutId = setTimeout(() => { - partDeltaFlushTimeoutId = null; - if (!partDeltaFlushScheduled) { - return; - } - flush(); - }, TOOL_STREAMING_BATCH_DELAY_MS); -}; - -const shouldBatchStreamingPart = (part: Part | undefined): boolean => { - if (!part || typeof part.type !== 'string') { - return false; - } - - if (part.type === 'tool') { - return true; - } - - if (part.type === 'text' || part.type === 'reasoning') { - return useUIStore.getState().chatRenderMode === 'sorted'; - } - - return false; -}; - -const shouldBatchPartDelta = ( - messagesBySession: Map, - sessionId: string, - messageId: string, - partId: string, -): boolean => { - const sessionMessages = messagesBySession.get(sessionId); - if (!sessionMessages || sessionMessages.length === 0) { - return false; - } - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, sessionMessages); - if (messageIndex === -1) { - return false; - } - const targetMessage = sessionMessages[messageIndex]; - const targetPart = targetMessage.parts.find((part) => part?.id === partId); - if (targetPart?.type === 'tool') { - return true; - } - - if (targetPart?.type === 'text' || targetPart?.type === 'reasoning') { - return useUIStore.getState().chatRenderMode === 'sorted'; - } - - return false; -}; - -const RECENT_SEND_EMPTY_GUARD_MS = 15_000; - -const MIN_SORTABLE_LENGTH = 10; - -const extractSortableId = (id: unknown): string | null => { - if (typeof id !== "string") { - return null; - } - const trimmed = id.trim(); - if (!trimmed) { - return null; - } - const underscoreIndex = trimmed.indexOf("_"); - const candidate = underscoreIndex >= 0 ? trimmed.slice(underscoreIndex + 1) : trimmed; - if (!candidate || candidate.length < MIN_SORTABLE_LENGTH) { - return null; - } - return candidate; -}; - -const countLoadedTurns = (messages: Array<{ info: { role?: string; clientRole?: string | null } }>): number => { - let count = 0; - for (const message of messages) { - const role = message.info.clientRole ?? message.info.role; - if (role === 'user') { - count += 1; - } - } - return count; -}; - -const compareMessageEntriesChronologically = ( - a: { info?: { id?: string; time?: { created?: number } } }, - b: { info?: { id?: string; time?: { created?: number } } }, -): number => { - const aId = typeof a?.info?.id === "string" ? a.info.id : ""; - const bId = typeof b?.info?.id === "string" ? b.info.id : ""; - - if (aId && bId) { - const aSortable = extractSortableId(aId); - const bSortable = extractSortableId(bId); - if (aSortable && bSortable && aSortable.length === bSortable.length && aSortable !== bSortable) { - return aSortable < bSortable ? -1 : 1; - } - if (aId !== bId) { - return aId.localeCompare(bId); - } - } - - const aCreated = typeof a?.info?.time?.created === "number" ? a.info.time.created : 0; - const bCreated = typeof b?.info?.time?.created === "number" ? b.info.time.created : 0; - return aCreated - bCreated; -}; - -const streamDebugEnabled = (): boolean => { - if (typeof window === "undefined") return false; - try { - return window.localStorage.getItem("openchamber_stream_debug") === "1"; - } catch { - return false; - } -}; - -const toFileUrl = (inputPath: string): string => { - const normalized = inputPath.replace(/\\/g, "/").trim(); - if (normalized.startsWith("file://")) { - return normalized; - } - const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`; - return `file://${encodeURI(withLeadingSlash)}`; -}; - -const getPartKey = (part: Part | undefined): string | undefined => { - if (!part) { - return undefined; - } - if (typeof part.id === "string" && part.id.length > 0) { - return part.id; - } - if (part.type) { - const reason = (part as Record).reason; - const toolName = typeof (part as Record).tool === "string" - ? ((part as Record).tool as string) - : ""; - const directCallId = (part as Record).callID; - const nestedTool = (part as Record).tool; - const nestedCallId = - nestedTool && typeof nestedTool === "object" - ? (nestedTool as Record).callID - : undefined; - const callId = directCallId ?? nestedCallId; - return `${part.type}-${toolName}-${reason ?? ""}-${callId ?? ""}`; - } - return undefined; -}; - -const findMatchingPartIndex = (parts: Part[], incoming: Part): number => { - if (!Array.isArray(parts) || parts.length === 0) { - return -1; - } - - if (typeof incoming.id === "string" && incoming.id.length > 0) { - const byId = parts.findIndex((part) => part?.id === incoming.id); - if (byId !== -1) { - return byId; - } - - return -1; - } - - const incomingKey = getPartKey(incoming); - if (!incomingKey) { - return -1; - } - - return parts.findIndex((part) => getPartKey(part) === incomingKey); -}; - -const ignoredAssistantMessageIds = new Set(); - -const mergeDuplicateMessage = ( - existing: { info: any; parts: Part[] }, - incoming: { info: any; parts: Part[] } -): { info: any; parts: Part[] } => { - return { - ...incoming, - info: { - ...existing.info, - ...incoming.info, - }, - parts: Array.isArray(incoming.parts) ? incoming.parts : [], - }; -}; - -const dedupeMessagesById = (messages: { info: any; parts: Part[] }[]) => { - const deduped: { info: any; parts: Part[] }[] = []; - const indexById = new Map(); - - for (const message of messages) { - const messageId = typeof message?.info?.id === "string" ? message.info.id : null; - if (!messageId) { - deduped.push(message); - continue; - } - const existingIndex = indexById.get(messageId); - if (existingIndex === undefined) { - indexById.set(messageId, deduped.length); - deduped.push(message); - continue; - } - deduped[existingIndex] = mergeDuplicateMessage(deduped[existingIndex], message); - } - - return deduped; -}; - -const setStreamingIdForSession = (source: Map, sessionId: string, messageId: string | null) => { - const existing = source.get(sessionId); - if (existing === messageId) { - return source; - } - const next = new Map(source); - if (messageId) { - next.set(sessionId, messageId); - } else { - next.delete(sessionId); - } - return next; -}; - -const upsertMessageSessionIndex = (source: Map, messageId: string, sessionId: string) => { - const existing = source.get(messageId); - if (existing === sessionId) { - return source; - } - const next = new Map(source); - next.set(messageId, sessionId); - return next; -}; - -type StoredMessage = { info: any; parts: Part[] }; - -const sessionMessagePositionCache = new Map>(); - -const buildSessionMessagePositionIndex = (messages: StoredMessage[]): Map => { - const index = new Map(); - for (let position = 0; position < messages.length; position += 1) { - const id = (messages[position]?.info as { id?: unknown })?.id; - if (typeof id === 'string' && id.length > 0) { - index.set(id, position); - } - } - return index; -}; - -const primeSessionMessagePositionIndex = (sessionId: string, messages: StoredMessage[]) => { - sessionMessagePositionCache.set(sessionId, buildSessionMessagePositionIndex(messages)); -}; - -const updateSessionMessagePositionEntry = (sessionId: string, messageId: string, position: number) => { - let sessionIndex = sessionMessagePositionCache.get(sessionId); - if (!sessionIndex) { - sessionIndex = new Map(); - sessionMessagePositionCache.set(sessionId, sessionIndex); - } - sessionIndex.set(messageId, position); -}; - -const resolveSessionMessagePosition = (sessionId: string, messageId: string, messages: StoredMessage[]): number => { - const sessionIndex = sessionMessagePositionCache.get(sessionId); - const cachedPosition = sessionIndex?.get(messageId); - if ( - typeof cachedPosition === 'number' - && cachedPosition >= 0 - && cachedPosition < messages.length - && (messages[cachedPosition]?.info as { id?: unknown })?.id === messageId - ) { - return cachedPosition; - } - - const resolved = messages.findIndex((message) => message.info.id === messageId); - if (resolved !== -1) { - updateSessionMessagePositionEntry(sessionId, messageId, resolved); - } else if (sessionIndex) { - sessionIndex.delete(messageId); - } - - return resolved; -}; - -const removeMessageSessionIndexEntries = (source: Map, ids: Iterable) => { - const next = new Map(source); - let mutated = false; - for (const id of ids) { - if (next.delete(id)) { - mutated = true; - } - } - return mutated ? next : source; -}; - -const collectActiveMessageIdsForSession = (state: MessageState, sessionId: string): Set => { - const ids = new Set(); - const latest = state.streamingMessageIds.get(sessionId); - if (latest) { - ids.add(latest); - } - state.messageStreamStates.forEach((_lifecycle, messageId) => { - if (state.messageSessionIndex.get(messageId) === sessionId) { - ids.add(messageId); - } - }); - return ids; -}; - -const isMessageStreamingInSession = (state: MessageState, sessionId: string, messageId: string) => { - if (state.streamingMessageIds.get(sessionId) === messageId) { - return true; - } - return state.messageSessionIndex.get(messageId) === sessionId && state.messageStreamStates.has(messageId); -}; - -const resolveSessionDirectory = async (sessionId: string | null | undefined): Promise => { - if (!sessionId) { - return undefined; - } - - try { - const sessionStore = useSessionStore.getState(); - const directory = sessionStore.getDirectoryForSession(sessionId); - return directory ?? undefined; - } catch (error) { - console.warn('Failed to resolve session directory override:', error); - return undefined; - } -}; - -const getSessionRevertMessageId = (sessionId: string | null | undefined): string | null => { - if (!sessionId) return null; - try { - const sessionStore = useSessionStore.getState(); - const session = sessionStore.sessions.find((entry) => entry.id === sessionId) as { revert?: { messageID?: string } } | undefined; - return session?.revert?.messageID ?? null; - } catch { - return null; - } -}; - -const executeWithSessionDirectory = async (sessionId: string | null | undefined, operation: () => Promise): Promise => { - const directoryOverride = await resolveSessionDirectory(sessionId); - if (directoryOverride) { - return opencodeClient.withDirectory(directoryOverride, operation); - } - return operation(); -}; - -interface SessionAbortRecord { - timestamp: number; - acknowledged: boolean; -} - -interface MessageState { - messages: Map; - sessionMemoryState: Map; - sessionHistoryMeta: Map; - messageStreamStates: Map; - messageSessionIndex: Map; - streamingMessageIds: Map; - abortControllers: Map; - lastUsedProvider: { providerID: string; modelID: string } | null; - isSyncing: boolean; - pendingAssistantParts: Map; - sessionCompactionUntil: Map; - sessionAbortFlags: Map; - pendingUserMessageMetaBySession: Map; - -} - -interface MessageActions { - loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell', format?: { type: 'json_schema'; schema: Record; retryCount?: number }) => Promise; - abortCurrentOperation: (currentSessionId?: string) => Promise; - _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; - addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; - _applyPartDeltaImmediate: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => void; - applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => void; - forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source?: "timeout" | "cooldown") => void; - completeStreamingMessage: (sessionId: string, messageId: string) => void; - markMessageStreamSettled: (messageId: string) => void; - updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => void; - syncMessages: ( - sessionId: string, - messages: { info: Message; parts: Part[] }[], - options?: { replace?: boolean } - ) => void; - updateViewportAnchor: (sessionId: string, anchor: number) => void; - loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise; - getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null; - updateSessionCompaction: (sessionId: string, compactingTimestamp: number | null | undefined) => void; - acknowledgeSessionAbort: (sessionId: string) => void; -} - -type MessageStore = MessageState & MessageActions; - -export const useMessageStore = create()( - devtools( - persist( - (set, get) => ({ - - messages: new Map(), - sessionMemoryState: new Map(), - sessionHistoryMeta: new Map(), - messageStreamStates: new Map(), - messageSessionIndex: new Map(), - streamingMessageIds: new Map(), - abortControllers: new Map(), - lastUsedProvider: null, - isSyncing: false, - pendingAssistantParts: new Map(), - sessionCompactionUntil: new Map(), - sessionAbortFlags: new Map(), - pendingUserMessageMetaBySession: new Map(), - - loadMessages: async (sessionId: string, limit?: number) => { - const existingRequest = loadMessagesInFlightBySession.get(sessionId); - if (existingRequest) { - return existingRequest; - } - - const requestSeq = (loadMessagesRequestSeqBySession.get(sessionId) ?? 0) + 1; - loadMessagesRequestSeqBySession.set(sessionId, requestSeq); - const isLatestRequest = () => loadMessagesRequestSeqBySession.get(sessionId) === requestSeq; - - const task = (async () => { - const memLimits = getMemoryLimits(); - const noLimit = limit === Infinity; - const previousMemoryState = get().sessionMemoryState.get(sessionId); - const previousHistoryMeta = get().sessionHistoryMeta.get(sessionId); - if (previousHistoryMeta?.loading) { - return; - } - - // OpenCode parity: history window is driven by meta.limit. - const baseLimit = previousHistoryMeta?.limit ?? memLimits.HISTORICAL_MESSAGES; - const requestedLimit = - typeof limit === 'number' && Number.isFinite(limit) - ? limit - : baseLimit; - // Never proactively shrink loaded history window on resync. - const targetLimit = Math.max(baseLimit, requestedLimit); - - set((snapshot) => { - if (!isLatestRequest()) { - return snapshot; - } - const nextHistoryMeta = new Map(snapshot.sessionHistoryMeta); - const currentMeta = nextHistoryMeta.get(sessionId); - nextHistoryMeta.set(sessionId, { - limit: currentMeta?.limit ?? baseLimit, - complete: currentMeta?.complete ?? false, - loading: true, - }); - return { sessionHistoryMeta: nextHistoryMeta }; - }); - - // Don't pass Infinity to API - use undefined for "fetch all". - // Use targetLimit directly and infer "has more" when payload fills the window, - // matching OpenCode behavior and avoiding hidden "load older" on exact-limit responses. - try { - const fetchLimit = noLimit ? undefined : targetLimit; - const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit)); - if (!isLatestRequest()) { - return; - } - - // Filter out reverted messages first - const revertMessageId = getSessionRevertMessageId(sessionId); - const messagesWithoutReverted = filterMessagesByRevertPoint<{ info: Message; parts: Part[] }>( - allMessages as { info: Message; parts: Part[] }[], - revertMessageId, - ); - const orderedMessages = [...messagesWithoutReverted].sort(compareMessageEntriesChronologically); - - // If server fills the requested window, assume there may be more above. - const hasMoreAbove = typeof fetchLimit === 'number' - ? orderedMessages.length >= targetLimit - : false; - - const messagesToKeep = orderedMessages.slice(-targetLimit); - - set((state) => { - if (!isLatestRequest()) { - return state; - } - - const previousMessages = state.messages.get(sessionId) || []; - const normalizedMessages = messagesToKeep.map((message) => { - const infoWithMarker = normalizeMessageInfoForProjection(message.info as Message) as any; - - const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => { - if (part?.type === 'text') { - const raw = (part as any).text ?? (part as any).content ?? ''; - if (isExecutionForkMetaText(raw)) { - return { ...part, synthetic: true } as Part; - } - } - return part; - }); - return { - ...message, - info: infoWithMarker, - parts: serverParts, - }; - }); - - const mergedMessages = dedupeMessagesById(normalizedMessages); - const currentMemoryState = state.sessionMemoryState.get(sessionId) ?? previousMemoryState; - const hasStreamingMessage = Boolean(state.streamingMessageIds.get(sessionId)); - const sentRecently = - typeof currentMemoryState?.lastUserMessageAt === 'number' - && Date.now() - currentMemoryState.lastUserMessageAt < RECENT_SEND_EMPTY_GUARD_MS; - - const shouldPreserveExistingSnapshot = - mergedMessages.length === 0 - && previousMessages.length > 0 - && (hasStreamingMessage || currentMemoryState?.isStreaming === true || sentRecently); - - if (shouldPreserveExistingSnapshot) { - const newMemoryState = new Map(state.sessionMemoryState); - const existingMemory = newMemoryState.get(sessionId) ?? previousMemoryState ?? { - viewportAnchor: 0, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - }; - newMemoryState.set(sessionId, { - ...existingMemory, - lastAccessedAt: Date.now(), - historyLoading: false, - historyLimit: targetLimit, - }); - - const newHistoryMeta = new Map(state.sessionHistoryMeta); - const currentMeta = newHistoryMeta.get(sessionId); - newHistoryMeta.set(sessionId, { - limit: targetLimit, - complete: currentMeta?.complete ?? false, - loading: false, - }); - - return { - sessionMemoryState: newMemoryState, - sessionHistoryMeta: newHistoryMeta, - }; - } - - const loadedTurnCount = countLoadedTurns(mergedMessages); - const previousIds = new Set(previousMessages.map((msg) => msg.info.id)); - const nextIds = new Set(mergedMessages.map((msg) => msg.info.id)); - const removedIds: string[] = []; - previousIds.forEach((id) => { - if (!nextIds.has(id)) { - removedIds.push(id); - } - }); - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, mergedMessages); - primeSessionMessagePositionIndex(sessionId, mergedMessages); - - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...previousMemoryState, - viewportAnchor: mergedMessages.length - 1, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - totalAvailableMessages: previousMemoryState?.totalAvailableMessages, - loadedTurnCount, - hasMoreAbove, - hasMoreTurnsAbove: hasMoreAbove, - historyLoading: false, - historyComplete: !hasMoreAbove, - historyLimit: targetLimit, - streamingCooldownUntil: undefined, - }); - - const newHistoryMeta = new Map(state.sessionHistoryMeta); - newHistoryMeta.set(sessionId, { - limit: targetLimit, - complete: !hasMoreAbove, - loading: false, - }); - - const result: Record = { - messages: newMessages, - sessionMemoryState: newMemoryState, - sessionHistoryMeta: newHistoryMeta, - }; - - clearLifecycleTimersForIds(removedIds); - const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); - if (updatedLifecycle !== state.messageStreamStates) { - result.messageStreamStates = updatedLifecycle; - } - - if (removedIds.length > 0) { - const currentStreaming = state.streamingMessageIds.get(sessionId); - if (currentStreaming && removedIds.includes(currentStreaming)) { - result.streamingMessageIds = setStreamingIdForSession( - result.streamingMessageIds ?? state.streamingMessageIds, - sessionId, - null - ); - } - } - - if (removedIds.length > 0) { - const nextIndex = removeMessageSessionIndexEntries( - result.messageSessionIndex ?? state.messageSessionIndex, - removedIds - ); - if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { - result.messageSessionIndex = nextIndex; - } - } - - if (removedIds.length > 0) { - const nextPendingParts = new Map(state.pendingAssistantParts); - let pendingChanged = false; - removedIds.forEach((id) => { - if (nextPendingParts.delete(id)) { - pendingChanged = true; - } - }); - if (pendingChanged) { - result.pendingAssistantParts = nextPendingParts; - } - } - - const targetIndex = result.messageSessionIndex ?? state.messageSessionIndex; - let indexAccumulator = targetIndex; - mergedMessages.forEach((message) => { - const id = (message?.info as { id?: unknown })?.id; - if (typeof id === "string" && id.length > 0) { - indexAccumulator = upsertMessageSessionIndex(indexAccumulator, id, sessionId); - } - }); - if (indexAccumulator !== targetIndex) { - result.messageSessionIndex = indexAccumulator; - } - - return result; - }); - } finally { - set((snapshot) => { - if (!isLatestRequest()) { - return snapshot; - } - const currentMeta = snapshot.sessionHistoryMeta.get(sessionId); - if (!currentMeta?.loading) { - return snapshot; - } - const nextHistoryMeta = new Map(snapshot.sessionHistoryMeta); - nextHistoryMeta.set(sessionId, { - ...currentMeta, - loading: false, - }); - return { sessionHistoryMeta: nextHistoryMeta }; - }); - } - })(); - - loadMessagesInFlightBySession.set(sessionId, task); - try { - await task; - } finally { - if (loadMessagesInFlightBySession.get(sessionId) === task) { - loadMessagesInFlightBySession.delete(sessionId); - } - } - }, - - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal', format?: { type: 'json_schema'; schema: Record; retryCount?: number }) => { - if (!currentSessionId) { - throw new Error("No session selected"); - } - - const sessionId = currentSessionId; - - if (get().sessionAbortFlags.has(sessionId)) { - set((state) => { - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.delete(sessionId); - return { sessionAbortFlags: nextAbortFlags }; - }); - } - - await executeWithSessionDirectory(sessionId, async () => { - try { - const trimmedContent = content.trimStart(); - const firstTokenLooksLikeAbsolutePath = (() => { - if (!trimmedContent.startsWith('/')) return false; - const firstWhitespaceIndex = trimmedContent.search(/\s/); - const firstToken = firstWhitespaceIndex === -1 - ? trimmedContent - : trimmedContent.slice(0, firstWhitespaceIndex); - if (firstToken.length <= 1) return false; - const tokenWithoutLeadingSlash = firstToken.slice(1); - if (!tokenWithoutLeadingSlash.includes('/')) return false; - return true; - })(); - const commandPayload = (() => { - if (inputMode === 'shell') return null; - if (!trimmedContent.startsWith("/")) return null; - if (firstTokenLooksLikeAbsolutePath) return null; - const firstLineEnd = trimmedContent.indexOf("\n"); - const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd); - const [commandToken, ...firstLineArgs] = firstLine.split(" "); - const command = commandToken.slice(1).trim(); - if (command.toLowerCase() === "shell") return null; - if (!command) return null; - const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1); - const argsFromFirstLine = firstLineArgs.join(" ").trim(); - const args = restOfInput - ? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput) - : argsFromFirstLine; - return { - command, - arguments: args, - }; - })(); - const shellPayload = (() => { - if (inputMode !== 'shell') return null; - const command = content.trim(); - if (!command.trim()) return null; - return { command }; - })(); - const slashShellPayload = (() => { - if (!trimmedContent.startsWith("/")) return null; - if (firstTokenLooksLikeAbsolutePath) return null; - const firstLineEnd = trimmedContent.indexOf("\n"); - const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd); - const [commandToken, ...firstLineArgs] = firstLine.split(" "); - const commandName = commandToken.slice(1).trim().toLowerCase(); - if (commandName !== "shell") return null; - const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1); - const argsFromFirstLine = firstLineArgs.join(" ").trim(); - const command = restOfInput - ? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput) - : argsFromFirstLine; - if (!command.trim()) return null; - return { command }; - })(); - - set({ - lastUsedProvider: { providerID, modelID }, - }); - - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId) || { - viewportAnchor: 0, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - }; - - const existingTimer = streamingCooldownTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - streamingCooldownTimers.delete(sessionId); - } - - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...memoryState, - isStreaming: true, - streamStartTime: Date.now(), - streamingCooldownUntil: undefined, - }); - return { sessionMemoryState: newMemoryState }; - }); - - try { - const controller = new AbortController(); - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.set(sessionId, controller); - return { abortControllers: nextControllers }; - }); - - const filePayloads = (attachments ?? []).map((file) => ({ - type: "file" as const, - mime: file.mimeType, - filename: file.filename, - url: - file.source === "server" && - file.serverPath && - (file.mimeType === "text/plain" || file.mimeType === "application/x-directory") - ? toFileUrl(file.serverPath) - : file.dataUrl, - })); - - set((state) => { - const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); - nextUserMeta.set(sessionId, { - mode: typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined, - providerID, - modelID, - variant: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined, - }); - return { pendingUserMessageMetaBySession: nextUserMeta }; - }); - - // Convert additional parts to SDK format - const additionalPartsPayload = additionalParts?.map((part) => ({ - text: part.text, - synthetic: part.synthetic, - files: part.attachments?.map((file) => ({ - type: "file" as const, - mime: file.mimeType, - filename: file.filename, - url: - file.source === "server" && - file.serverPath && - (file.mimeType === "text/plain" || file.mimeType === "application/x-directory") - ? toFileUrl(file.serverPath) - : file.dataUrl, - })), - })); - - const apiClient = opencodeClient.getApiClient(); - const directory = opencodeClient.getDirectory(); - - if (shellPayload || slashShellPayload) { - await apiClient.session.shell({ - sessionID: sessionId, - ...(directory ? { directory } : {}), - ...(agent ? { agent } : {}), - model: { - providerID, - modelID, - }, - command: (shellPayload ?? slashShellPayload)!.command, - }); - } else if (commandPayload && commandPayload.command.toLowerCase() === 'compact') { - await apiClient.session.summarize({ - sessionID: sessionId, - ...(directory ? { directory } : {}), - providerID, - modelID, - }); - } else if (commandPayload) { - await opencodeClient.sendCommand({ - id: sessionId, - providerID, - modelID, - command: commandPayload.command, - arguments: commandPayload.arguments, - agent, - variant, - files: filePayloads.length > 0 ? filePayloads : undefined, - }); - } else { - if (format) { - console.info('[git-generation][browser] dispatch structured sendMessage', { - sessionId, - providerID, - modelID, - agent, - variant, - directory, - formatType: format.type, - }); - } - await opencodeClient.sendMessage({ - id: sessionId, - providerID, - modelID, - text: content, - agent, - variant, - ...(format ? { format } : {}), - files: filePayloads.length > 0 ? filePayloads : undefined, - additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined, - agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, - }); - } - - if (filePayloads.length > 0) { - try { - useFileStore.getState().clearAttachedFiles(); - } catch (clearError) { - console.error("Failed to clear attached files after send", clearError); - } - } - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - return { abortControllers: nextControllers }; - }); - } catch (error: any) { - let errorMessage = "Network error while sending message. The message may still be processing."; - - if (error.name === "AbortError") { - errorMessage = "Request timed out. The message may still be processing."; - } else if (error.message?.includes("504") || error.message?.includes("Gateway")) { - errorMessage = "Gateway timeout - your message is being processed. Please wait for response."; - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - return { abortControllers: nextControllers }; - }); - return; - } else if (isLikelyProviderAuthFailure(error.message)) { - errorMessage = PROVIDER_AUTH_FAILURE_MESSAGE; - } else if (error.message) { - errorMessage = error.message; - } - - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); - nextUserMeta.delete(sessionId); - return { abortControllers: nextControllers, pendingUserMessageMetaBySession: nextUserMeta }; - }); - - throw new Error(errorMessage); - } - } catch (error: any) { - let errorMessage = "Network error while sending message. The message may still be processing."; - - if (error.name === "AbortError") { - errorMessage = "Request timed out. The message may still be processing."; - } else if (error.response?.status === 401) { - errorMessage = "Session not found or unauthorized. Please refresh the page."; - } else if (error.response?.status === 502) { - errorMessage = "OpenCode is restarting. Please wait a moment and try again."; - } else if (error.message?.includes("504") || error.message?.includes("Gateway")) { - errorMessage = "Gateway timeout - your message is being processed. Please wait for response."; - } else if (isLikelyProviderAuthFailure(error.message)) { - errorMessage = PROVIDER_AUTH_FAILURE_MESSAGE; - } else if (error.message) { - errorMessage = error.message; - } - - set((state) => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(sessionId); - const nextUserMeta = new Map(state.pendingUserMessageMetaBySession); - nextUserMeta.delete(sessionId); - return { abortControllers: nextControllers, pendingUserMessageMetaBySession: nextUserMeta }; - }); - - throw new Error(errorMessage); - } - }); - }, - - abortCurrentOperation: async (currentSessionId?: string) => { - if (!currentSessionId) { - return; - } - - discardQueuedNonTextStreamingPartsForSession(currentSessionId); - discardQueuedPartDeltasForSession(currentSessionId); - - const stateSnapshot = get(); - const { abortControllers, messages: storeMessages } = stateSnapshot; - - const controller = abortControllers.get(currentSessionId); - controller?.abort(); - - const activeIds = collectActiveMessageIdsForSession(stateSnapshot, currentSessionId); - - if (activeIds.size === 0) { - const sessionMessages = currentSessionId ? storeMessages.get(currentSessionId) ?? [] : []; - let fallbackAssistantId: string | null = null; - for (let index = sessionMessages.length - 1; index >= 0; index -= 1) { - const message = sessionMessages[index]; - if (!message || message.info.role !== 'assistant') { - continue; - } - - if (!fallbackAssistantId) { - fallbackAssistantId = message.info.id; - } - - const hasWorkingPart = (message.parts ?? []).some((part) => { - return part.type === 'reasoning' || part.type === 'tool' || part.type === 'step-start'; - }); - if (hasWorkingPart) { - activeIds.add(message.info.id); - break; - } - } - - if (activeIds.size === 0 && fallbackAssistantId) { - activeIds.add(fallbackAssistantId); - } - } - - for (const id of activeIds) { - const timeout = timeoutRegistry.get(id); - if (timeout) { - clearTimeout(timeout); - timeoutRegistry.delete(id); - lastContentRegistry.delete(id); - } - } - - if (activeIds.size > 0) { - clearLifecycleTimersForIds(activeIds); - } - - const abortTimestamp = Date.now(); - - set((state) => { - const updatedStates = removeLifecycleEntries(state.messageStreamStates, activeIds); - - const sessionMessages = state.messages.get(currentSessionId) ?? []; - let messagesChanged = false; - let updatedMessages = state.messages; - - if (sessionMessages.length > 0 && activeIds.size > 0) { - const updatedSessionMessages = sessionMessages.map((message) => { - if (!activeIds.has(message.info.id) && activeIds.size > 0) { - return message; - } - - const updatedParts = (message.parts ?? []).map((part) => { - if (part.type === 'reasoning') { - const reasoningPart = part as any; - const time = { ...(reasoningPart.time ?? {}) }; - if (typeof time.end !== 'number') { - time.end = abortTimestamp; - } - return { - ...reasoningPart, - time, - } as Part; - } - - if (part.type === 'tool') { - const toolPart = part as any; - const stateData = { ...(toolPart.state ?? {}) }; - if (stateData.status === 'running' || stateData.status === 'pending') { - stateData.status = 'aborted'; - } - return { - ...toolPart, - state: stateData, - } as Part; - } - - if (part.type === 'step-start') { - const stepPart = part as any; - return { - ...stepPart, - type: 'step-finish', - aborted: true, - } as Part; - } - - return part; - }); - - messagesChanged = true; - return { - ...message, - info: { - ...message.info, - abortedAt: abortTimestamp, - streaming: false, - status: 'aborted', - }, - parts: updatedParts, - }; - }); - - if (messagesChanged) { - updatedMessages = new Map(state.messages); - updatedMessages.set(currentSessionId, updatedSessionMessages); - } - } - const memoryState = state.sessionMemoryState.get(currentSessionId); - let nextMemoryState = state.sessionMemoryState; - if (memoryState) { - const updatedMemory = new Map(state.sessionMemoryState); - updatedMemory.set(currentSessionId, { - ...memoryState, - isStreaming: false, - streamStartTime: undefined, - isZombie: false, - }); - nextMemoryState = updatedMemory; - } - - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.set(currentSessionId, { - timestamp: abortTimestamp, - acknowledged: false, - }); - - return { - messageStreamStates: updatedStates, - sessionMemoryState: nextMemoryState, - sessionAbortFlags: nextAbortFlags, - abortControllers: (() => { - const nextControllers = new Map(state.abortControllers); - nextControllers.delete(currentSessionId); - return nextControllers; - })(), - streamingMessageIds: setStreamingIdForSession(state.streamingMessageIds, currentSessionId, null), - ...(messagesChanged ? { messages: updatedMessages } : {}), - }; - }); - - void opencodeClient.abortSession(currentSessionId).catch((error) => { - console.warn('Abort request failed:', error); - }); - }, - - _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => { - const stateSnapshot = get(); - if (ignoredAssistantMessageIds.has(messageId)) { - return; - } - - const existingMessagesSnapshot = stateSnapshot.messages.get(sessionId) || []; - const existingMessageSnapshotIndex = resolveSessionMessagePosition(sessionId, messageId, existingMessagesSnapshot); - const existingMessageSnapshot = existingMessageSnapshotIndex >= 0 - ? existingMessagesSnapshot[existingMessageSnapshotIndex] - : undefined; - - const actualRole = (() => { - if (role === 'user') return 'user'; - if (existingMessageSnapshot?.info.role === 'user') return 'user'; - return role || existingMessageSnapshot?.info.role || 'assistant'; - })(); - - const memoryStateSnapshot = get().sessionMemoryState.get(sessionId); - if (memoryStateSnapshot?.streamStartTime) { - const streamDuration = Date.now() - memoryStateSnapshot.streamStartTime; - if (streamDuration > MEMORY_LIMITS.ZOMBIE_TIMEOUT) { - if (!memoryStateSnapshot.isZombie) { - set((state) => { - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...memoryStateSnapshot, - isZombie: true, - }); - return { sessionMemoryState: newMemoryState }; - }); - } - - setTimeout(() => { - const store = get(); - store.completeStreamingMessage(sessionId, messageId); - }, 0); - (window as any).__messageTracker?.(messageId, 'skipped_zombie_stream'); - return; - } - } - - set((state) => { - const sessionMessages = state.messages.get(sessionId) || []; - const messagesArray = [...sessionMessages]; - const updates: any = {}; - - const indexedSessions = upsertMessageSessionIndex(state.messageSessionIndex, messageId, sessionId); - if (indexedSessions !== state.messageSessionIndex) { - updates.messageSessionIndex = indexedSessions; - } - - const finalizeAbortState = (result: Partial): Partial => { - const shouldClearAbortFlag = - (actualRole === 'assistant' || actualRole === 'user') && - state.sessionAbortFlags.has(sessionId); - if (!shouldClearAbortFlag) { - return result; - } - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.delete(sessionId); - return { - ...result, - sessionAbortFlags: nextAbortFlags, - }; - }; - - const maintainTimeouts = (text: string) => { - const value = text || ''; - const lastContent = lastContentRegistry.get(messageId); - - if (value && lastContent === value) { - const currentState = get(); - if (isMessageStreamingInSession(currentState, sessionId, messageId)) { - const existingTimeout = timeoutRegistry.get(messageId); - if (existingTimeout) { - clearTimeout(existingTimeout); - timeoutRegistry.delete(messageId); - } - setTimeout(() => { - const store = get(); - if (typeof store.forceCompleteMessage === "function") { - store.forceCompleteMessage(sessionId, messageId, "timeout"); - } - store.completeStreamingMessage(sessionId, messageId); - }, 100); - } - } - - lastContentRegistry.set(messageId, value); - - const existingTimeout = timeoutRegistry.get(messageId); - if (existingTimeout) { - clearTimeout(existingTimeout); - } - const newTimeout = setTimeout(() => { - const store = get(); - if (typeof store.forceCompleteMessage === "function") { - store.forceCompleteMessage(sessionId, messageId, "timeout"); - } - if (isMessageStreamingInSession(store, sessionId, messageId)) { - store.completeStreamingMessage(sessionId, messageId); - } - timeoutRegistry.delete(messageId); - lastContentRegistry.delete(messageId); - }, 8000); - timeoutRegistry.set(messageId, newTimeout); - }; - - const isBackgroundSession = sessionId !== currentSessionId; - const memoryState = state.sessionMemoryState.get(sessionId); - if (isBackgroundSession && memoryState?.isStreaming) { - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { - ...memoryState, - backgroundMessageCount: (memoryState.backgroundMessageCount || 0) + 1, - }); - updates.sessionMemoryState = newMemoryState; - } - - if (actualRole === 'assistant') { - const baseMemoryMap = updates.sessionMemoryState ?? state.sessionMemoryState; - const currentMemoryState = baseMemoryMap.get(sessionId); - if (currentMemoryState) { - const now = Date.now(); - const nextMemoryState = new Map(baseMemoryMap); - nextMemoryState.set(sessionId, { - ...currentMemoryState, - isStreaming: true, - streamStartTime: currentMemoryState.streamStartTime ?? now, - lastAccessedAt: now, - isZombie: false, - }); - updates.sessionMemoryState = nextMemoryState; - } - } - - const incomingText = extractTextFromPart(part); - if (isExecutionForkMetaText(incomingText)) { - (part as any).synthetic = true; - } - if (streamDebugEnabled() && actualRole === "assistant") { - try { - console.info("[STREAM-TRACE] part", { - messageId, - partId: (part as any)?.id, - role: actualRole, - type: (part as any)?.type || "text", - textLen: incomingText.length, - snapshotParts: existingMessagesSnapshot.length, - }); - } catch { /* ignored */ } - } - - const previousStreamingMap = updates.streamingMessageIds ?? state.streamingMessageIds; - if (actualRole === 'assistant') { - const nextStreamingMap = setStreamingIdForSession(previousStreamingMap, sessionId, messageId); - if (nextStreamingMap !== previousStreamingMap) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set_latest'); - } - } - - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, messagesArray); - - if (messageIndex !== -1 && actualRole === 'user') { - const existingMessage = messagesArray[messageIndex]; - const existingPartIndex = findMatchingPartIndex(existingMessage.parts, part); - - if ((part as any).synthetic === true) { - const incomingText = extractTextFromPart(part).trim(); - const shouldKeep = - incomingText.startsWith('User has requested to enter plan mode') || - incomingText.startsWith('The plan at ') || - incomingText.startsWith('The following tool was executed by the user'); - if (!shouldKeep) { - (window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part'); - return state; - } - } - - const normalizedPart = normalizeStreamingPart( - part, - existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined - ); - (window as any).__messageTracker?.(messageId, `user_part_type:${(normalizedPart as any).type || 'unknown'}`); - - const updatedMessage = { ...existingMessage }; - if (existingPartIndex !== -1) { - updatedMessage.parts = updatedMessage.parts.map((p, idx) => - idx === existingPartIndex ? normalizedPart : p - ); - } else { - updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; - } - - const updatedMessages = [...messagesArray]; - updatedMessages[messageIndex] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - - if (actualRole === 'assistant' && messageIndex !== -1) { - - const existingMessage = messagesArray[messageIndex]; - const existingPartIndex = findMatchingPartIndex(existingMessage.parts, part); - - const normalizedPart = normalizeStreamingPart( - part, - existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined - ); - (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); - - const updatedMessage = { ...existingMessage }; - if (existingPartIndex !== -1) { - updatedMessage.parts = updatedMessage.parts.map((p, idx) => - idx === existingPartIndex ? normalizedPart : p - ); - } else { - updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; - } - - const updatedMessages = [...messagesArray]; - updatedMessages[messageIndex] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set'); - } - - if ((normalizedPart as any).type === 'text') { - maintainTimeouts((normalizedPart as any).text || ''); - } else { - maintainTimeouts(''); - } - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - - if (messageIndex === -1) { - - if (actualRole === 'user') { - - if ((part as any).synthetic === true) { - const incomingText = extractTextFromPart(part).trim(); - const shouldKeep = - incomingText.startsWith('User has requested to enter plan mode') || - incomingText.startsWith('The plan at ') || - incomingText.startsWith('The following tool was executed by the user'); - if (!shouldKeep) { - (window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part'); - return state; - } - } - - const normalizedPart = normalizeStreamingPart(part); - (window as any).__messageTracker?.(messageId, `new_user_part_type:${(normalizedPart as any).type || 'unknown'}`); - - const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); - const contextStore = useContextStore.getState(); - const sessionAgent = - pendingMeta?.mode ?? - contextStore.getSessionAgentSelection(sessionId) ?? - contextStore.getCurrentAgent(sessionId); - const agentMode = typeof sessionAgent === 'string' && sessionAgent.trim().length > 0 - ? sessionAgent.trim() - : undefined; - const providerID = pendingMeta?.providerID ?? (state.lastUsedProvider?.providerID || undefined); - const modelID = pendingMeta?.modelID ?? (state.lastUsedProvider?.modelID || undefined); - - if (pendingMeta) { - updates.pendingUserMessageMetaBySession = cleanupPendingUserMessageMeta(state.pendingUserMessageMetaBySession, sessionId); - } - - const newUserMessage = { - info: { - id: messageId, - sessionID: sessionId, - role: 'user' as const, - clientRole: 'user', - userMessageMarker: true, - ...(agentMode ? { mode: agentMode } : {}), - ...(providerID ? { providerID } : {}), - ...(modelID ? { modelID } : {}), - time: { - created: Date.now(), - }, - }, - parts: [normalizedPart], - }; - - const updatedMessages = [...messagesArray, newUserMessage]; - - updatedMessages.sort((a, b) => { - const aTime = (a.info as any)?.time?.created || 0; - const bTime = (b.info as any)?.time?.created || 0; - return aTime - bTime; - }); - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - primeSessionMessagePositionIndex(sessionId, updatedMessages); - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - - if ((part as any)?.type === 'text') { - const textIncoming = extractTextFromPart(part).trim(); - if (textIncoming.length > 0) { - const latestUser = [...messagesArray] - .reverse() - .find((m) => m.info.role === 'user'); - if (latestUser) { - const latestUserText = latestUser.parts.map((p) => extractTextFromPart(p)).join('').trim(); - if (latestUserText.length > 0 && latestUserText === textIncoming) { - // Cap ignoredAssistantMessageIds size — it's only relevant for active streaming - if (ignoredAssistantMessageIds.size > 1000) { - ignoredAssistantMessageIds.clear(); - } - ignoredAssistantMessageIds.add(messageId); - (window as any).__messageTracker?.(messageId, 'ignored_assistant_echo'); - return state; - } - } - } - } - - const pendingEntry = state.pendingAssistantParts.get(messageId); - const pendingParts = pendingEntry ? [...pendingEntry.parts] : []; - const pendingIndex = findMatchingPartIndex(pendingParts, part); - const existingPendingPart = pendingIndex !== -1 ? pendingParts[pendingIndex] : undefined; - const normalizedPart = normalizeStreamingPart(part, existingPendingPart); - (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); - - if ((normalizedPart as any).type === 'text') { - maintainTimeouts((normalizedPart as any).text || ''); - } else { - maintainTimeouts(''); - } - - if (pendingIndex !== -1) { - const normalizedRecord = normalizedPart as Record; - if ( - normalizedRecord.type === 'tool' && - typeof existingPendingPart?.id === 'string' && - existingPendingPart.id.length > 0 - ) { - normalizedRecord.id = existingPendingPart.id; - } - pendingParts[pendingIndex] = normalizedRecord as Part; - } else { - pendingParts.push(normalizedPart); - } - - const newPending = new Map(state.pendingAssistantParts); - newPending.set(messageId, { sessionId, parts: pendingParts }); - - const providerID = state.lastUsedProvider?.providerID || ""; - const modelID = state.lastUsedProvider?.modelID || ""; - const now = Date.now(); - const cwd = opencodeClient.getDirectory() ?? "/"; - const contextStore = useContextStore.getState(); - const sessionAgent = contextStore.getSessionAgentSelection(sessionId) - ?? contextStore.getCurrentAgent(sessionId); - const agentMode = typeof sessionAgent === "string" && sessionAgent.trim().length > 0 - ? sessionAgent.trim() - : undefined; - - const placeholderInfo = (actualRole === "user" - ? { - id: messageId, - sessionID: sessionId, - role: "user", - time: { created: now }, - agent: agentMode || "default", - model: { providerID, modelID }, - clientRole: actualRole, - animationSettled: undefined, - streaming: undefined, - } - : { - id: messageId, - sessionID: sessionId, - role: "assistant", - time: { created: now }, - parentID: messageId, - modelID, - providerID, - mode: agentMode || "default", - path: { cwd, root: cwd }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - clientRole: actualRole, - animationSettled: false, - streaming: true, - }) as unknown as Message; - - const placeholderMessage = { - info: placeholderInfo, - parts: pendingParts, - }; - - const nextMessages = [...messagesArray, placeholderMessage]; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, nextMessages); - updateSessionMessagePositionEntry(sessionId, messageId, nextMessages.length - 1); - - if (actualRole === 'assistant') { - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - - const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set'); - } - } - - return finalizeAbortState({ - messages: newMessages, - pendingAssistantParts: newPending, - ...updates, - }); - } else { - - const existingMessage = messagesArray[messageIndex]; - const existingPartIndex = findMatchingPartIndex(existingMessage.parts, part); - const existingPart = existingPartIndex !== -1 ? existingMessage.parts[existingPartIndex] : undefined; - - const normalizedPart = normalizeStreamingPart( - part, - existingPart - ); - if ( - (normalizedPart as Record).type === 'tool' && - typeof existingPart?.id === 'string' && - existingPart.id.length > 0 - ) { - (normalizedPart as Record).id = existingPart.id; - } - (window as any).__messageTracker?.(messageId, `part_type:${(normalizedPart as any).type || 'unknown'}`); - - const updatedMessage = { ...existingMessage }; - if (existingPartIndex !== -1) { - updatedMessage.parts = updatedMessage.parts.map((p, idx) => - idx === existingPartIndex ? normalizedPart : p - ); - } else { - updatedMessage.parts = [...updatedMessage.parts, normalizedPart]; - } - - const updatedMessages = [...messagesArray]; - updatedMessages[messageIndex] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, updatedMessages); - - if (updatedMessage.info.role === "assistant") { - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - const nextStreamingMap = setStreamingIdForSession(updates.streamingMessageIds ?? state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== (updates.streamingMessageIds ?? state.streamingMessageIds)) { - updates.streamingMessageIds = nextStreamingMap; - (window as any).__messageTracker?.(messageId, 'streamingId_set'); - } - } - - if ((normalizedPart as any).type === 'text') { - maintainTimeouts((normalizedPart as any).text || ''); - } else { - maintainTimeouts(''); - } - - return finalizeAbortState({ messages: newMessages, ...updates }); - } - }); - }, - - addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => { - if (!ENABLE_STREAMING_FRAME_BATCHING) { - get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId); - return; - } - - if (!shouldBatchStreamingPart(part)) { - get()._addStreamingPartImmediate(sessionId, messageId, part, role, currentSessionId); - return; - } - - enqueueNonTextStreamingPart({ - sessionId, - messageId, - part, - role, - currentSessionId, - }); - - const flushQueuedParts = () => { - flushQueuedNonTextStreamingParts(get()._addStreamingPartImmediate); - }; - - if (queuedNonTextStreamingPartOrder.length >= NON_TEXT_STREAMING_QUEUE_HARD_LIMIT) { - flushQueuedParts(); - return; - } - - scheduleNonTextStreamingFlush(flushQueuedParts); - }, - - _applyPartDeltaImmediate: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => { - set((state) => { - const sessionMessages = state.messages.get(sessionId) || []; - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, sessionMessages); - if (messageIndex === -1) { - return state; - } - - const targetMessage = sessionMessages[messageIndex]; - const partIndex = targetMessage.parts.findIndex((part) => part?.id === partId); - if (partIndex === -1) { - return state; - } - - const existingPart = targetMessage.parts[partIndex] as Record; - const existingField = existingPart[field]; - - const nextFieldValue = `${typeof existingField === 'string' ? existingField : ''}${delta}`; - const nextPart: Record = { - ...existingPart, - [field]: nextFieldValue, - }; - - const updatedParts = [...targetMessage.parts]; - updatedParts[partIndex] = nextPart as Part; - - const updatedMessage = { - ...targetMessage, - parts: updatedParts, - }; - - const updatedSessionMessages = [...sessionMessages]; - updatedSessionMessages[messageIndex] = updatedMessage; - - const nextMessages = new Map(state.messages); - nextMessages.set(sessionId, updatedSessionMessages); - - const actualRole = (() => { - if (role === 'user') return 'user'; - if (updatedMessage.info.role === 'user') return 'user'; - return role || updatedMessage.info.role || 'assistant'; - })(); - - const updates: Partial = { - messages: nextMessages, - }; - - if (streamDebugEnabled() && actualRole === 'assistant') { - try { - const previousText = extractTextFromPart(existingPart as Part); - const nextText = extractTextFromPart(nextPart as Part); - console.info('[STREAM-TRACE] delta_apply', { - messageId, - partId, - field, - deltaLen: delta.length, - prevFieldLen: typeof existingField === 'string' ? existingField.length : 0, - nextFieldLen: nextFieldValue.length, - prevTextLen: previousText.length, - nextTextLen: nextText.length, - partType: typeof existingPart.type === 'string' ? existingPart.type : 'unknown', - }); - } catch { - // ignore debug log failures - } - } - - if (actualRole === 'assistant') { - updates.messageStreamStates = touchStreamingLifecycle(state.messageStreamStates, messageId); - const effectiveCurrent = currentSessionId || sessionId; - const nextStreamingMap = setStreamingIdForSession(state.streamingMessageIds, sessionId, messageId); - if (nextStreamingMap !== state.streamingMessageIds) { - updates.streamingMessageIds = nextStreamingMap; - } - - if (effectiveCurrent !== sessionId) { - const memoryState = state.sessionMemoryState.get(sessionId); - if (memoryState) { - const now = Date.now(); - const nextMemoryState = new Map(state.sessionMemoryState); - nextMemoryState.set(sessionId, { - ...memoryState, - isStreaming: true, - streamStartTime: memoryState.streamStartTime ?? now, - lastAccessedAt: now, - isZombie: false, - }); - updates.sessionMemoryState = nextMemoryState; - } - } - } - - return updates; - }); - }, - - applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string, currentSessionId?: string) => { - if (!ENABLE_STREAMING_FRAME_BATCHING) { - get()._applyPartDeltaImmediate(sessionId, messageId, partId, field, delta, role, currentSessionId); - return; - } - - if (!shouldBatchPartDelta(get().messages, sessionId, messageId, partId)) { - get()._applyPartDeltaImmediate(sessionId, messageId, partId, field, delta, role, currentSessionId); - return; - } - - enqueuePartDelta({ - sessionId, - messageId, - partId, - field, - delta, - role, - currentSessionId, - }); - - const flushQueuedDeltas = () => { - flushQueuedPartDeltas(get()._applyPartDeltaImmediate); - }; - - if (queuedPartDeltaOrder.length >= PART_DELTA_QUEUE_HARD_LIMIT) { - flushQueuedDeltas(); - return; - } - - schedulePartDeltaFlush(flushQueuedDeltas); - }, - - forceCompleteMessage: (sessionId: string | null | undefined, messageId: string, source: "timeout" | "cooldown" = "timeout") => { - const resolveSessionId = (state: MessageState): string | null => { - if (sessionId) { - return sessionId; - } - const indexedSession = state.messageSessionIndex.get(messageId); - if (indexedSession) { - return indexedSession; - } - for (const [candidateId, sessionMessages] of state.messages.entries()) { - if (sessionMessages.some((msg) => msg.info.id === messageId)) { - return candidateId; - } - } - return null; - }; - - set((state) => { - const targetSessionId = resolveSessionId(state); - if (!targetSessionId) { - return state; - } - - const sessionMessages = state.messages.get(targetSessionId) ?? []; - const messageIndex = resolveSessionMessagePosition(targetSessionId, messageId, sessionMessages); - if (messageIndex === -1) { - return state; - } - - const message = sessionMessages[messageIndex]; - if (!message) { - return state; - } - - const now = Date.now(); - const existingInfo = message.info as any; - const existingCompleted = typeof existingInfo?.time?.completed === "number" && existingInfo.time.completed > 0; - - let infoChanged = false; - const updatedInfo: Record = { ...existingInfo }; - - if (!existingCompleted) { - updatedInfo.time = { - ...(existingInfo.time ?? {}), - completed: now, - }; - infoChanged = true; - } - - if (updatedInfo.status !== "completed") { - updatedInfo.status = "completed"; - infoChanged = true; - } - - if (updatedInfo.streaming) { - updatedInfo.streaming = false; - infoChanged = true; - } - - let partsChanged = false; - const updatedParts = message.parts.map((part) => { - if (!part) { - return part; - } - - if (part.type === "tool") { - const existingState = (part as any).state; - if (!existingState) { - return part; - } - - const status = existingState.status; - const needsStatusUpdate = status === "running" || status === "pending" || status === "started"; - const needsEndTimestamp = !existingState.time || typeof existingState.time?.end !== "number"; - - if (needsStatusUpdate || needsEndTimestamp) { - const nextState: Record = { ...existingState }; - if (needsStatusUpdate) { - nextState.status = "completed"; - } - if (needsEndTimestamp) { - nextState.time = { - ...(existingState.time ?? {}), - end: now, - }; - } - partsChanged = true; - return { - ...part, - state: nextState, - } as Part; - } - return part; - } - - if (part.type === "reasoning") { - const reasoningTime = (part as any).time; - if (!reasoningTime || typeof reasoningTime.end !== "number") { - partsChanged = true; - return { - ...part, - time: { - ...(reasoningTime ?? {}), - end: now, - }, - } as Part; - } - return part; - } - - if (part.type === "text") { - const textTime = (part as any).time; - if (textTime && typeof textTime.end !== "number") { - partsChanged = true; - return { - ...part, - time: { - ...textTime, - end: now, - }, - } as Part; - } - return part; - } - - return part; - }); - - if (!infoChanged && !partsChanged) { - return state; - } - - (window as any).__messageTracker?.(messageId, `force_complete:${source}`); - - const updatedMessage = { - ...message, - info: updatedInfo as Message, - parts: partsChanged ? updatedParts : message.parts, - }; - - const nextSessionMessages = [...sessionMessages]; - nextSessionMessages[messageIndex] = updatedMessage; - - const nextMessages = new Map(state.messages); - nextMessages.set(targetSessionId, nextSessionMessages); - - return { messages: nextMessages }; - }); - }, - - markMessageStreamSettled: (messageId: string) => { - set((state) => { - - clearLifecycleCompletionTimer(messageId); - const next = new Map(state.messageStreamStates); - next.delete(messageId); - - let updatedMessages = state.messages; - let messagesModified = false; - const indexedSessionId = state.messageSessionIndex.get(messageId); - const sessionIdCandidates = indexedSessionId - ? [ - indexedSessionId, - ...Array.from(state.messages.keys()).filter((sessionId) => sessionId !== indexedSessionId), - ] - : Array.from(state.messages.keys()); - - for (const sessionId of sessionIdCandidates) { - const sessionMessages = state.messages.get(sessionId); - if (!sessionMessages) { - continue; - } - const idx = resolveSessionMessagePosition(sessionId, messageId, sessionMessages); - if (idx === -1) { - continue; - } - - const message = sessionMessages[idx]; - if ((message.info as any)?.animationSettled) { - break; - } - - const updatedMessage = { - ...message, - info: { - ...message.info, - animationSettled: true, - }, - }; - - const sessionArray = [...sessionMessages]; - sessionArray[idx] = updatedMessage; - - const newMessages = new Map(state.messages); - newMessages.set(sessionId, sessionArray); - updatedMessages = newMessages; - messagesModified = true; - break; - } - - const updates: Partial & { messageStreamStates: Map } = { - messageStreamStates: next, - ...(messagesModified ? { messages: updatedMessages } : {}), - } as any; - - return updates; - }); - clearLifecycleTimersForIds([messageId]); - }, - - updateMessageInfo: (sessionId: string, messageId: string, messageInfo: any) => { - set((state) => { - const sessionMessages = state.messages.get(sessionId) ?? []; - const normalizedSessionMessages = [...sessionMessages]; - - const messageIndex = resolveSessionMessagePosition(sessionId, messageId, normalizedSessionMessages); - const pendingEntry = state.pendingAssistantParts.get(messageId); - - const ensureClientRole = (info: any) => { - if (!info) { - return info; - } - return normalizeMessageInfoForProjection(info as Message) as any; - }; - - if (messageIndex === -1) { - if (process.env.NODE_ENV === 'development') { - console.info("[MESSAGE-DEBUG] updateMessageInfo: messageIndex === -1", { - sessionId, - messageId, - messageInfo, - existingCount: normalizedSessionMessages.length, - }); - } - - if (normalizedSessionMessages.length > 0) { - const firstMessage = normalizedSessionMessages[0]; - const firstInfo = firstMessage?.info as any; - const firstCreated = typeof firstInfo?.time?.created === 'number' ? firstInfo.time.created : null; - const firstId = typeof firstInfo?.id === 'string' ? firstInfo.id : null; - - const incomingInfoToCompare = messageInfo as any; - const incomingCreated = typeof incomingInfoToCompare?.time?.created === 'number' - ? incomingInfoToCompare.time.created - : null; - const incomingId = typeof incomingInfoToCompare?.id === 'string' ? incomingInfoToCompare.id : messageId; - - let isOlderThanViewport = false; - if (incomingCreated !== null && firstCreated !== null) { - isOlderThanViewport = incomingCreated < firstCreated; - } - if (!isOlderThanViewport && incomingId && firstId) { - isOlderThanViewport = incomingId.localeCompare(firstId) < 0; - } - - if (isOlderThanViewport) { - (window as any).__messageTracker?.(messageId, 'skipped_evicted_message_update'); - return state; - } - } - - const incomingInfo = ensureClientRole(messageInfo); - - if (incomingInfo && incomingInfo.role === 'user') { - const pendingParts = pendingEntry?.parts ?? []; - const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); - const newUserMessage = { - info: { - ...incomingInfo, - userMessageMarker: true, - clientRole: 'user', - ...(pendingMeta?.mode ? { mode: pendingMeta.mode } : {}), - ...(pendingMeta?.providerID ? { providerID: pendingMeta.providerID } : {}), - ...(pendingMeta?.modelID ? { modelID: pendingMeta.modelID } : {}), - } as Message, - parts: pendingParts.length > 0 ? [...pendingParts] : [], - }; - - const newMessages = new Map(state.messages); - - const appended = [...normalizedSessionMessages, newUserMessage]; - - appended.sort((a, b) => { - const aTime = (a.info as any)?.time?.created || 0; - const bTime = (b.info as any)?.time?.created || 0; - return aTime - bTime; - }); - newMessages.set(sessionId, appended); - primeSessionMessagePositionIndex(sessionId, appended); - - const updates: Partial = { - messages: newMessages, - ...(pendingMeta - ? { - pendingUserMessageMetaBySession: cleanupPendingUserMessageMeta(state.pendingUserMessageMetaBySession, sessionId), - } - : {}), - }; - - const nextIndex = upsertMessageSessionIndex( - updates.messageSessionIndex ?? state.messageSessionIndex, - messageId, - sessionId - ); - if (nextIndex !== (updates.messageSessionIndex ?? state.messageSessionIndex)) { - updates.messageSessionIndex = nextIndex; - } - - if (pendingEntry) { - const newPending = new Map(state.pendingAssistantParts); - newPending.delete(messageId); - updates.pendingAssistantParts = newPending; - } - - return updates; - } - - if (!incomingInfo || incomingInfo.role !== 'assistant') { - return state; - } - - const pendingParts = pendingEntry?.parts ?? []; - - const newMessage = { - info: { - ...incomingInfo, - animationSettled: (incomingInfo as any)?.animationSettled ?? false, - } as Message, - parts: pendingParts.length > 0 ? [...pendingParts] : [], - }; - - const newMessages = new Map(state.messages); - - const appended = [...normalizedSessionMessages, newMessage]; - newMessages.set(sessionId, appended); - updateSessionMessagePositionEntry(sessionId, messageId, appended.length - 1); - - const updates: Partial = { - messages: newMessages, - }; - - const nextIndex = upsertMessageSessionIndex( - updates.messageSessionIndex ?? state.messageSessionIndex, - messageId, - sessionId - ); - if (nextIndex !== (updates.messageSessionIndex ?? state.messageSessionIndex)) { - updates.messageSessionIndex = nextIndex; - } - - if (pendingEntry) { - const newPending = new Map(state.pendingAssistantParts); - newPending.delete(messageId); - updates.pendingAssistantParts = newPending; - } - - return updates; - } - - const existingMessage = normalizedSessionMessages[messageIndex]; - - const existingInfo = existingMessage.info as any; - const isUserMessage = - existingInfo.userMessageMarker === true || - existingInfo.clientRole === 'user' || - existingInfo.role === 'user'; - - if (isUserMessage) { - - const updatedInfo = { - ...existingMessage.info, - ...messageInfo, - - role: 'user', - clientRole: 'user', - userMessageMarker: true, - - providerID: existingInfo.providerID || undefined, - modelID: existingInfo.modelID || undefined, - } as any; - - const pendingMeta = state.pendingUserMessageMetaBySession.get(sessionId); - if (pendingMeta && !updatedInfo.mode && pendingMeta.mode) { - updatedInfo.mode = pendingMeta.mode; - } - - const updatedMessage = { - ...existingMessage, - info: updatedInfo - }; - - const newMessages = new Map(state.messages); - const updatedSessionMessages = [...normalizedSessionMessages]; - updatedSessionMessages[messageIndex] = updatedMessage; - newMessages.set(sessionId, updatedSessionMessages); - - if (pendingMeta) { - const nextPending = new Map(state.pendingUserMessageMetaBySession); - nextPending.delete(sessionId); - return { messages: newMessages, pendingUserMessageMetaBySession: nextPending }; - } - - return { messages: newMessages }; - } - - - const updatedInfo = { - ...existingMessage.info, - ...messageInfo, - } as any; - - if (messageInfo.role && messageInfo.role !== existingMessage.info.role) { - updatedInfo.role = existingMessage.info.role; - } - - updatedInfo.clientRole = updatedInfo.clientRole ?? existingMessage.info.clientRole ?? existingMessage.info.role; - if (updatedInfo.clientRole === "user") { - updatedInfo.userMessageMarker = true; - } - - const updatedMessage = { - ...existingMessage, - info: updatedInfo, - parts: existingMessage.parts, - }; - - const newMessages = new Map(state.messages); - const updatedSessionMessages = [...normalizedSessionMessages]; - updatedSessionMessages[messageIndex] = updatedMessage; - newMessages.set(sessionId, updatedSessionMessages); - - const updates: Partial = { - messages: newMessages, - }; - - if (pendingEntry) { - const newPending = new Map(state.pendingAssistantParts); - newPending.delete(messageId); - updates.pendingAssistantParts = newPending; - } - - return updates; - }); - - // Trigger completion when info.finish is present for assistant messages - const infoFinish = (messageInfo as { finish?: string })?.finish; - const messageRole = (messageInfo as { role?: string })?.role; - if (typeof infoFinish === 'string' && messageRole !== 'user') { - setTimeout(() => { - const store = get(); - store.completeStreamingMessage(sessionId, messageId); - }, 0); - } - }, - - completeStreamingMessage: (sessionId: string, messageId: string) => { - flushQueuedNonTextStreamingPartsForMessage(get()._addStreamingPartImmediate, sessionId, messageId); - flushQueuedPartDeltasForMessage(get()._applyPartDeltaImmediate, sessionId, messageId); - - const state = get(); - - (window as any).__messageTracker?.( - messageId, - `completion_called_current:${state.streamingMessageIds.get(sessionId) ?? 'none'}` - ); - - if (typeof state.forceCompleteMessage === "function") { - state.forceCompleteMessage(sessionId, messageId, "cooldown"); - } - - const shouldClearStreamingId = state.streamingMessageIds.get(sessionId) === messageId; - if (shouldClearStreamingId) { - (window as any).__messageTracker?.(messageId, 'streamingId_cleared'); - } else { - (window as any).__messageTracker?.(messageId, 'streamingId_NOT_cleared_different_id'); - } - - const updates: Record = {}; - if (shouldClearStreamingId) { - updates.streamingMessageIds = setStreamingIdForSession(state.streamingMessageIds, sessionId, null); - updates.abortControllers = (() => { - const next = new Map(state.abortControllers); - next.delete(sessionId); - return next; - })(); - } - - if (state.messageStreamStates.has(messageId)) { - const next = new Map(state.messageStreamStates); - next.delete(messageId); - updates.messageStreamStates = next; - } - - if (Object.keys(updates).length > 0) { - set(updates); - } - - if (state.pendingAssistantParts.has(messageId)) { - set((currentState) => { - if (!currentState.pendingAssistantParts.has(messageId)) { - return currentState; - } - const nextPending = new Map(currentState.pendingAssistantParts); - nextPending.delete(messageId); - return { pendingAssistantParts: nextPending }; - }); - } - - clearLifecycleTimersForIds([messageId]); - - let startedCooldown = false; - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId); - if (!memoryState || !memoryState.isStreaming) return state; - - const newMemoryState = new Map(state.sessionMemoryState); - const now = Date.now(); - const updatedMemory: SessionMemoryState = { - ...memoryState, - isStreaming: false, - streamStartTime: undefined, - isZombie: false, - lastAccessedAt: now, - streamingCooldownUntil: now + 2000, - }; - newMemoryState.set(sessionId, updatedMemory); - startedCooldown = true; - return { sessionMemoryState: newMemoryState }; - }); - - if (startedCooldown) { - const existingTimer = streamingCooldownTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - streamingCooldownTimers.delete(sessionId); - } - - const timeoutId = setTimeout(() => { - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId); - if (!memoryState) return state; - - if (memoryState.isStreaming) { - return state; - } - - const nextMemoryState = new Map(state.sessionMemoryState); - const { streamingCooldownUntil: _streamingCooldownUntil, ...rest } = memoryState; - void _streamingCooldownUntil; - nextMemoryState.set(sessionId, rest as SessionMemoryState); - return { sessionMemoryState: nextMemoryState }; - }); - streamingCooldownTimers.delete(sessionId); - }, 2000); - - streamingCooldownTimers.set(sessionId, timeoutId); - } - }, - - syncMessages: ( - sessionId: string, - messages: { info: Message; parts: Part[] }[], - options?: { replace?: boolean } - ) => { - flushQueuedNonTextStreamingPartsForSession(get()._addStreamingPartImmediate, sessionId); - flushQueuedPartDeltasForSession(get()._applyPartDeltaImmediate, sessionId); - - // Filter out reverted messages first - const revertMessageId = getSessionRevertMessageId(sessionId); - const messagesWithoutReverted = filterMessagesByRevertPoint(messages, revertMessageId); - - const messagesFiltered = messagesWithoutReverted; - const shouldReplace = options?.replace === true; - - set((state) => { - const newMessages = new Map(state.messages); - const previousMessages = state.messages.get(sessionId) || []; - const normalizedIncomingMessages = messagesFiltered.map((message) => { - const infoWithMarker = { - ...normalizeMessageInfoForProjection(message.info as Message), - animationSettled: - message.info.role === "assistant" - ? (message.info as any)?.animationSettled ?? true - : (message.info as any)?.animationSettled, - } as any; - - const serverParts = (Array.isArray(message.parts) ? message.parts : []).map((part) => { - if (part?.type === 'text') { - const raw = (part as any).text ?? (part as any).content ?? ''; - if (isExecutionForkMetaText(raw)) { - return { ...part, synthetic: true } as Part; - } - } - return part; - }); - return { - ...message, - info: infoWithMarker, - parts: serverParts, - }; - }); - - const incomingIds = new Set( - normalizedIncomingMessages - .map((message) => (message?.info as { id?: unknown })?.id) - .filter((id): id is string => typeof id === 'string' && id.length > 0) - ); - - const existingOnlyMessages = shouldReplace - ? [] - : previousMessages.filter((message) => { - const id = (message?.info as { id?: unknown })?.id; - return typeof id === 'string' && id.length > 0 ? !incomingIds.has(id) : true; - }); - - const mergedMessages = dedupeMessagesById([ - ...existingOnlyMessages, - ...normalizedIncomingMessages, - ]).sort(compareMessageEntriesChronologically); - - const previousIds = new Set(previousMessages.map((msg) => msg.info.id)); - const nextIds = new Set(mergedMessages.map((msg) => msg.info.id)); - const removedIds: string[] = []; - previousIds.forEach((id) => { - if (!nextIds.has(id)) { - removedIds.push(id); - } - }); - - newMessages.set(sessionId, mergedMessages); - primeSessionMessagePositionIndex(sessionId, mergedMessages); - - const result: Record = { - messages: newMessages, - isSyncing: true, - }; - - clearLifecycleTimersForIds(removedIds); - const updatedLifecycle = removeLifecycleEntries(state.messageStreamStates, removedIds); - if (updatedLifecycle !== state.messageStreamStates) { - result.messageStreamStates = updatedLifecycle; - } - - if (removedIds.length > 0) { - const currentStreaming = state.streamingMessageIds.get(sessionId); - if (currentStreaming && removedIds.includes(currentStreaming)) { - result.streamingMessageIds = setStreamingIdForSession( - result.streamingMessageIds ?? state.streamingMessageIds, - sessionId, - null - ); - } - } - - if (removedIds.length > 0) { - const nextIndex = removeMessageSessionIndexEntries( - result.messageSessionIndex ?? state.messageSessionIndex, - removedIds - ); - if (nextIndex !== (result.messageSessionIndex ?? state.messageSessionIndex)) { - result.messageSessionIndex = nextIndex; - } - } - - if (removedIds.length > 0) { - const nextPendingParts = new Map(state.pendingAssistantParts); - let pendingChanged = false; - removedIds.forEach((id) => { - if (nextPendingParts.delete(id)) { - pendingChanged = true; - } - }); - if (pendingChanged) { - result.pendingAssistantParts = nextPendingParts; - } - } - - const targetIndex = result.messageSessionIndex ?? state.messageSessionIndex; - let indexAccumulator = targetIndex; - mergedMessages.forEach((message) => { - const id = (message?.info as { id?: unknown })?.id; - if (typeof id === "string" && id.length > 0) { - indexAccumulator = upsertMessageSessionIndex(indexAccumulator, id, sessionId); - } - }); - if (indexAccumulator !== targetIndex) { - result.messageSessionIndex = indexAccumulator; - } - - return result; - }); - - setTimeout(() => { - set({ isSyncing: false }); - }, 100); - }, - - updateSessionCompaction: (sessionId: string, compactingTimestamp: number | null | undefined) => { - set((state) => { - const nextCompaction = new Map(state.sessionCompactionUntil); - - if (!compactingTimestamp || compactingTimestamp <= 0) { - if (!nextCompaction.has(sessionId)) { - return state; - } - nextCompaction.delete(sessionId); - return { sessionCompactionUntil: nextCompaction }; - } - - const deadline = compactingTimestamp + COMPACTION_WINDOW_MS; - const existingDeadline = nextCompaction.get(sessionId); - if (existingDeadline === deadline) { - return state; - } - - nextCompaction.set(sessionId, deadline); - return { sessionCompactionUntil: nextCompaction }; - }); - }, - - acknowledgeSessionAbort: (sessionId: string) => { - if (!sessionId) { - return; - } - - set((state) => { - const record = state.sessionAbortFlags.get(sessionId); - if (!record || record.acknowledged) { - return state; - } - - const nextAbortFlags = new Map(state.sessionAbortFlags); - nextAbortFlags.set(sessionId, { ...record, acknowledged: true }); - return { sessionAbortFlags: nextAbortFlags } as Partial; - }); - }, - - updateViewportAnchor: (sessionId: string, anchor: number) => { - set((state) => { - const memoryState = state.sessionMemoryState.get(sessionId) || { - viewportAnchor: 0, - isStreaming: false, - lastAccessedAt: Date.now(), - backgroundMessageCount: 0, - }; - - if (memoryState.viewportAnchor === anchor) { - return state; - } - - const newMemoryState = new Map(state.sessionMemoryState); - newMemoryState.set(sessionId, { ...memoryState, viewportAnchor: anchor }); - return { sessionMemoryState: newMemoryState }; - }); - }, - - loadMoreMessages: async (sessionId: string, direction: "up" | "down" = "up") => { - const state = get(); - const currentMessages = state.messages.get(sessionId); - const memoryState = state.sessionMemoryState.get(sessionId); - const historyMeta = state.sessionHistoryMeta.get(sessionId); - - if (!currentMessages || !memoryState) { - return; - } - - if (historyMeta?.loading) { - return; - } - - if (historyMeta?.complete) { - return; - } - - const memLimits = getMemoryLimits(); - const baseLimit = historyMeta?.limit ?? memLimits.HISTORICAL_MESSAGES; - const desiredLimit = direction === "up" ? baseLimit + memLimits.HISTORY_CHUNK : baseLimit; - - if (desiredLimit <= baseLimit) { - return; - } - - await get().loadMessages(sessionId, desiredLimit); - }, - - getLastMessageModel: (sessionId: string) => { - const { messages } = get(); - const sessionMessages = messages.get(sessionId); - - if (!sessionMessages || sessionMessages.length === 0) { - return null; - } - - for (let i = sessionMessages.length - 1; i >= 0; i--) { - const message = sessionMessages[i]; - if (message.info.role === "assistant" && "providerID" in message.info && "modelID" in message.info) { - return { - providerID: (message.info as any).providerID, - modelID: (message.info as any).modelID, - }; - } - } - - return null; - }, - }), - { - name: "message-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state: MessageStore) => ({ - lastUsedProvider: state.lastUsedProvider, - sessionMemoryState: Array.from(state.sessionMemoryState.entries()).map(([sessionId, memory]) => [ - sessionId, - { - viewportAnchor: memory.viewportAnchor, - lastAccessedAt: memory.lastAccessedAt, - totalAvailableMessages: memory.totalAvailableMessages, - loadedTurnCount: memory.loadedTurnCount, - hasMoreAbove: memory.hasMoreAbove, - hasMoreTurnsAbove: memory.hasMoreTurnsAbove, - historyLoading: memory.historyLoading, - historyComplete: memory.historyComplete, - historyLimit: memory.historyLimit, - }, - ]), - sessionAbortFlags: Array.from(state.sessionAbortFlags.entries()).map(([sessionId, record]) => [ - sessionId, - { timestamp: record.timestamp, acknowledged: record.acknowledged }, - ]), - }), - merge: (persistedState: any, currentState: MessageStore): MessageStore => { - if (!persistedState) { - return currentState; - } - - let restoredMemoryState = currentState.sessionMemoryState; - if (Array.isArray(persistedState.sessionMemoryState)) { - restoredMemoryState = new Map( - persistedState.sessionMemoryState.map((entry: [string, SessionMemoryState]) => { - const [id, memory] = entry; - // Never trust persisted history flags — they must be - // recomputed from a fresh API fetch on session open. - return [id, { - ...memory, - isStreaming: false, - backgroundMessageCount: typeof memory.backgroundMessageCount === 'number' - ? memory.backgroundMessageCount - : 0, - hasMoreAbove: undefined, - hasMoreTurnsAbove: undefined, - historyComplete: undefined, - historyLimit: undefined, - historyLoading: false, - }] as [string, SessionMemoryState]; - }) - ); - } - - let restoredAbortFlags = currentState.sessionAbortFlags; - if (Array.isArray(persistedState.sessionAbortFlags)) { - restoredAbortFlags = new Map(persistedState.sessionAbortFlags); - } - - return { - ...currentState, - lastUsedProvider: persistedState.lastUsedProvider ?? currentState.lastUsedProvider, - sessionMemoryState: restoredMemoryState, - sessionAbortFlags: restoredAbortFlags, - }; - }, - } - ), - { - name: "message-store", - } - ) -); diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index afef7809..a3eed141 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -1,65 +1,28 @@ import { create } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { opencodeClient } from "@/lib/opencode/client"; import type { Session } from "@opencode-ai/sdk/v2/client"; -import type { PermissionRequest, PermissionResponse } from "@/types/permission"; import { + autoRespondsPermission, normalizeDirectory, + sessionAcceptKey, type PermissionAutoAcceptMap, } from "./utils/permissionAutoAccept"; import { getSafeStorage } from "./utils/safeStorage"; -import { useMessageStore } from "./messageStore"; -import { useSessionStore } from "./sessionStore"; +import { getAllSyncSessions } from "@/sync/sync-refs"; +import { opencodeClient } from "@/lib/opencode/client"; +import { useSessionUIStore } from "@/sync/session-ui-store"; interface PermissionState { - permissions: Map; autoAccept: PermissionAutoAcceptMap; } interface PermissionActions { - addPermission: (permission: PermissionRequest) => void; - respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise; - dismissPermission: (sessionId: string, requestId: string) => void; isSessionAutoAccepting: (sessionId: string) => boolean; setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise; } type PermissionStore = PermissionState & PermissionActions; -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const sanitizePermissionEntries = (value: unknown): Array<[string, PermissionRequest[]]> => { - if (!Array.isArray(value)) { - return []; - } - const entries: Array<[string, PermissionRequest[]]> = []; - value.forEach((entry) => { - if (!Array.isArray(entry) || entry.length !== 2) { - return; - } - const [sessionId, permissions] = entry; - if (typeof sessionId !== "string" || !Array.isArray(permissions)) { - return; - } - entries.push([sessionId, permissions as PermissionRequest[]]); - }); - return entries; -}; - -const executeWithPermissionDirectory = async (sessionId: string, operation: () => Promise): Promise => { - try { - const sessionStore = useSessionStore.getState(); - const directory = sessionStore.getDirectoryForSession(sessionId); - if (directory) { - return opencodeClient.withDirectory(directory, operation); - } - } catch (error) { - console.warn('Failed to resolve session directory for permission handling:', error); - } - return operation(); -}; - const resolveLineage = (sessionID: string, sessions: Session[]): string[] => { const map = new Map(); for (const session of sessions) { @@ -82,148 +45,40 @@ const autoRespondsPermissionBySession = ( sessions: Session[], sessionID: string, ): boolean => { - for (const id of resolveLineage(sessionID, sessions)) { - if (id in autoAccept) { - return autoAccept[id] === true; + const targetSession = sessions.find((session) => session.id === sessionID); + const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionID); + const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null); + if (!directory) { + for (const id of resolveLineage(sessionID, sessions)) { + if (id in autoAccept) { + return autoAccept[id] === true; + } } - } - return false; -}; - -const shouldAutoRespond = (permission: PermissionRequest, autoAccept: PermissionAutoAcceptMap): boolean => { - if (!permission?.sessionID) { return false; } - const sessionStore = useSessionStore.getState(); - const sessions = sessionStore.sessions; - return autoRespondsPermissionBySession( + return autoRespondsPermission({ autoAccept, sessions, - permission.sessionID, - ); + sessionID, + directory, + }); }; -const collectPermissionDirectories = (fallbackDirectory?: string | null): string[] => { - const sessionStore = useSessionStore.getState(); - const dirs = new Set(); - const fallback = normalizeDirectory(fallbackDirectory); - if (fallback) { - dirs.add(fallback); - } - - const currentDirectory = normalizeDirectory(opencodeClient.getDirectory()); - if (currentDirectory) { - dirs.add(currentDirectory); - } - - for (const session of sessionStore.sessions) { - const normalized = normalizeDirectory((session as { directory?: string | null }).directory); - if (normalized) { - dirs.add(normalized); - } - } - - return Array.from(dirs); -}; - -const reconcilePendingAutoAccept = async ( - autoAccept: PermissionAutoAcceptMap, - fallbackDirectory?: string | null, -) => { - const directories = collectPermissionDirectories(fallbackDirectory); - if (directories.length === 0) { - return; - } - - const pending = await opencodeClient.listPendingPermissions({ directories }); - if (pending.length === 0) { - return; - } - - for (const request of pending) { - if (!request?.sessionID || !request?.id) { - continue; - } - if (!shouldAutoRespond(request, autoAccept)) { - continue; - } - try { - await executeWithPermissionDirectory(request.sessionID, () => opencodeClient.replyToPermission(request.id, 'once')); - } catch { - // ignored - } - } -}; +const getStorage = () => createJSONStorage(() => getSafeStorage()); export const usePermissionStore = create()( devtools( persist( (set, get) => ({ - - permissions: new Map(), autoAccept: {}, - addPermission: (permission: PermissionRequest) => { - const sessionId = permission.sessionID; - if (!sessionId) { - return; - } - - const existing = get().permissions.get(sessionId); - if (existing?.some((entry) => entry.id === permission.id)) { - return; - } - - if (shouldAutoRespond(permission, get().autoAccept)) { - get().respondToPermission(sessionId, permission.id, 'once').catch(() => { - - }); - return; - } - - set((state) => { - const sessionPermissions = state.permissions.get(sessionId) || []; - const newPermissions = new Map(state.permissions); - newPermissions.set(sessionId, [...sessionPermissions, permission]); - return { permissions: newPermissions }; - }); - }, - - respondToPermission: async (sessionId: string, requestId: string, response: PermissionResponse) => { - await executeWithPermissionDirectory(sessionId, () => opencodeClient.replyToPermission(requestId, response)); - - if (response === 'reject') { - const messageStore = useMessageStore.getState(); - - await messageStore.abortCurrentOperation(sessionId); - } - - set((state) => { - const sessionPermissions = state.permissions.get(sessionId) || []; - const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId); - const newPermissions = new Map(state.permissions); - newPermissions.set(sessionId, updatedPermissions); - return { permissions: newPermissions }; - }); - }, - - dismissPermission: (sessionId: string, requestId: string) => { - set((state) => { - const sessionPermissions = state.permissions.get(sessionId) || []; - const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId); - const newPermissions = new Map(state.permissions); - newPermissions.set(sessionId, updatedPermissions); - return { permissions: newPermissions }; - }); - }, - isSessionAutoAccepting: (sessionId: string) => { if (!sessionId) { return false; } - const sessions = useSessionStore.getState().sessions; + const sessions = getAllSyncSessions(); return autoRespondsPermissionBySession(get().autoAccept, sessions, sessionId); }, @@ -232,49 +87,59 @@ export const usePermissionStore = create()( return; } - set((state) => ({ - autoAccept: { - ...state.autoAccept, - [sessionId]: enabled, - }, - })); + const sessions = getAllSyncSessions(); + const targetSession = sessions.find((session) => session.id === sessionId); + const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId); + const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null); + const key = directory ? sessionAcceptKey(sessionId, directory) : sessionId; - if (!enabled) { + set((state) => { + const autoAccept = { ...state.autoAccept }; + if (directory) { + delete autoAccept[sessionId]; + } + autoAccept[key] = enabled; + return { autoAccept }; + }); + + if (!enabled || !directory) { return; } - const sessionDirectory = useSessionStore.getState().getDirectoryForSession(sessionId); - void reconcilePendingAutoAccept(get().autoAccept, sessionDirectory); + + const pending = await opencodeClient.listPendingPermissions({ directories: [directory] }); + const client = opencodeClient.getScopedSdkClient(directory); + const sessionLineage = new Set(resolveLineage(sessionId, sessions)); + await Promise.all( + pending + .filter((permission) => sessionLineage.has(permission.sessionID)) + .map((permission) => client.permission.reply({ requestID: permission.id, reply: "once" }).catch(() => undefined)), + ); }, }), { name: "permission-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state) => ({ - permissions: Array.from(state.permissions.entries()), - autoAccept: state.autoAccept, - }), + storage: getStorage(), + partialize: (state) => ({ autoAccept: state.autoAccept }), merge: (persistedState, currentState) => { - if (!isRecord(persistedState)) { - return currentState; - } - const entries = sanitizePermissionEntries(persistedState.permissions); - const autoAccept = isRecord(persistedState.autoAccept) - ? Object.fromEntries( - Object.entries(persistedState.autoAccept).filter((entry): entry is [string, boolean] => { - return typeof entry[0] === "string" && typeof entry[1] === "boolean"; - }), - ) - : {}; - return { + const merged = { ...currentState, - permissions: new Map(entries), - autoAccept, + ...(persistedState as Partial), + }; + + const nextAutoAccept = Object.fromEntries( + Object.entries(merged.autoAccept || {}).map(([sessionId, enabled]) => [ + sessionId, + Boolean(enabled), + ]), + ); + + return { + ...merged, + autoAccept: nextAutoAccept, }; }, } ), - { - name: "permission-store", - } + { name: "permission-store" } ) ); diff --git a/packages/ui/src/stores/questionStore.ts b/packages/ui/src/stores/questionStore.ts deleted file mode 100644 index 5d39b2e7..00000000 --- a/packages/ui/src/stores/questionStore.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { opencodeClient } from "@/lib/opencode/client"; -import type { QuestionRequest } from "@/types/question"; -import { getSafeStorage } from "./utils/safeStorage"; -import { useSessionStore } from "./sessionStore"; - -interface QuestionState { - questions: Map; -} - -interface QuestionActions { - addQuestion: (question: QuestionRequest) => void; - dismissQuestion: (sessionId: string, requestId: string) => void; - respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => Promise; - rejectQuestion: (sessionId: string, requestId: string) => Promise; -} - -type QuestionStore = QuestionState & QuestionActions; - -const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; - -const sanitizeQuestionEntries = (value: unknown): Array<[string, QuestionRequest[]]> => { - if (!Array.isArray(value)) { - return []; - } - const entries: Array<[string, QuestionRequest[]]> = []; - value.forEach((entry) => { - if (!Array.isArray(entry) || entry.length !== 2) { - return; - } - const [sessionId, questions] = entry; - if (typeof sessionId !== "string" || !Array.isArray(questions)) { - return; - } - entries.push([sessionId, questions as QuestionRequest[]]); - }); - return entries; -}; - -const executeWithQuestionDirectory = async (sessionId: string, operation: () => Promise): Promise => { - try { - const sessionStore = useSessionStore.getState(); - const directory = sessionStore.getDirectoryForSession(sessionId); - if (directory) { - return opencodeClient.withDirectory(directory, operation); - } - } catch (error) { - console.warn("Failed to resolve session directory for question handling:", error); - } - return operation(); -}; - -export const useQuestionStore = create()( - devtools( - persist( - (set, get) => ({ - questions: new Map(), - - addQuestion: (question: QuestionRequest) => { - const sessionId = question.sessionID; - if (!sessionId) { - return; - } - - const existing = get().questions.get(sessionId); - if (existing?.some((entry) => entry.id === question.id)) { - return; - } - - set((state) => { - const sessionQuestions = state.questions.get(sessionId) || []; - const next = new Map(state.questions); - next.set(sessionId, [...sessionQuestions, question]); - return { questions: next }; - }); - }, - - dismissQuestion: (sessionId: string, requestId: string) => { - if (!sessionId || !requestId) { - return; - } - - set((state) => { - const sessionQuestions = state.questions.get(sessionId) || []; - const updated = sessionQuestions.filter((q) => q.id !== requestId); - const next = new Map(state.questions); - next.set(sessionId, updated); - return { questions: next }; - }); - }, - - respondToQuestion: async (sessionId: string, requestId: string, answers: string[] | string[][]) => { - await executeWithQuestionDirectory(sessionId, () => opencodeClient.replyToQuestion(requestId, answers)); - get().dismissQuestion(sessionId, requestId); - }, - - rejectQuestion: async (sessionId: string, requestId: string) => { - await executeWithQuestionDirectory(sessionId, () => opencodeClient.rejectQuestion(requestId)); - get().dismissQuestion(sessionId, requestId); - }, - }), - { - name: "question-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state) => ({ - questions: Array.from(state.questions.entries()), - }), - merge: (persistedState, currentState) => { - if (!isRecord(persistedState)) { - return currentState; - } - const entries = sanitizeQuestionEntries(persistedState.questions); - return { - ...currentState, - questions: new Map(entries), - }; - }, - } - ), - { name: "question-store" } - ) -); diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts deleted file mode 100644 index d543dae4..00000000 --- a/packages/ui/src/stores/sessionStore.ts +++ /dev/null @@ -1,1750 +0,0 @@ -import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import type { Session } from "@opencode-ai/sdk/v2"; -import { opencodeClient } from "@/lib/opencode/client"; -import { getSafeStorage } from "./utils/safeStorage"; -import type { WorktreeMetadata } from "@/types/worktree"; -import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus"; -import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager"; -import { useDirectoryStore } from "./useDirectoryStore"; -import { useProjectsStore } from "./useProjectsStore"; -import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus"; -import type { ProjectEntry } from "@/lib/api/types"; -import { checkIsGitRepository } from "@/lib/gitApi"; -import { streamDebugEnabled } from "@/stores/utils/streamDebug"; -import { isMissingGlobalSessionsEndpointError, readNextCursor, type GlobalSessionRecord } from "./globalSessions"; - -interface SessionState { - sessions: Session[]; - archivedSessions: Session[]; - sessionsByDirectory: Map; - currentSessionId: string | null; - lastLoadedDirectory: string | null; - isLoading: boolean; - error: string | null; - webUICreatedSessions: Set; - worktreeMetadata: Map; - availableWorktrees: WorktreeMetadata[]; - availableWorktreesByProject: Map; -} - -interface SessionActions { - loadSessions: () => Promise; - createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise; - deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise; - deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>; - archiveSession: (id: string) => Promise; - archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>; - updateSessionTitle: (id: string, title: string) => Promise; - shareSession: (id: string) => Promise; - unshareSession: (id: string) => Promise; - setCurrentSession: (id: string | null) => void; - clearError: () => void; - getSessionsByDirectory: (directory: string) => Session[]; - getDirectoryForSession: (sessionId: string) => string | null; - applySessionMetadata: (sessionId: string, metadata: Partial) => void; - isOpenChamberCreatedSession: (sessionId: string) => boolean; - markSessionAsOpenChamberCreated: (sessionId: string) => void; - initializeNewOpenChamberSession: (sessionId: string, agents: Record[]) => void; - setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => void; - getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | undefined; - setSessionDirectory: (sessionId: string, directory: string | null) => void; - updateSession: (session: Session) => void; - removeSessionFromStore: (sessionId: string) => void; -} - -type SessionStore = SessionState & SessionActions; - -const safeStorage = getSafeStorage(); -const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory"; -type SessionSelectionMap = Record; - -const readSessionSelectionMap = (): SessionSelectionMap => { - try { - const raw = safeStorage.getItem(SESSION_SELECTION_STORAGE_KEY); - if (!raw) { - return {}; - } - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") { - return {}; - } - return Object.entries(parsed as Record).reduce((acc, [directory, sessionId]) => { - if (typeof directory === "string" && typeof sessionId === "string" && directory.length > 0 && sessionId.length > 0) { - acc[directory] = sessionId; - } - return acc; - }, {}); - } catch { - return {}; - } -}; - -let sessionSelectionCache: SessionSelectionMap | null = null; -let loadSessionsRequestSeq = 0; -let loadSessionsInFlight: Promise | null = null; -let loadSessionsQueued = false; -let persistSelectionTimer: ReturnType | undefined; -let pendingSelectionMap: SessionSelectionMap | null = null; - -type ProjectSessionResult = { - projectId: string; - projectPath: string | null; - sessions: Session[]; - discoveredWorktrees: WorktreeMetadata[]; - validPaths: Set; -}; - -type ProjectSessionCacheEntry = { - cachedAt: number; - result: ProjectSessionResult; -}; - -type ProjectRepoCacheEntry = { - cachedAt: number; - isGitRepo: boolean; -}; - -const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000; -const projectSessionCache = new Map(); -const projectRepoStatusCache = new Map(); - -const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => { - const key = normalizePath(projectPath) ?? projectPath; - projectSessionCache.set(key, { cachedAt: Date.now(), result }); -}; - -const pruneProjectCaches = (validProjectPaths: Iterable) => { - const valid = new Set(); - for (const path of validProjectPaths) { - const normalized = normalizePath(path) ?? path; - if (normalized) { - valid.add(normalized); - } - } - - for (const key of projectSessionCache.keys()) { - if (!valid.has(key)) { - projectSessionCache.delete(key); - } - } - for (const key of projectRepoStatusCache.keys()) { - if (!valid.has(key)) { - projectRepoStatusCache.delete(key); - } - } -}; - -const getProjectRepoStatus = async (projectPath: string): Promise => { - const key = normalizePath(projectPath) ?? projectPath; - const cached = projectRepoStatusCache.get(key); - if (cached && Date.now() - cached.cachedAt <= PROJECT_REPO_STATUS_CACHE_TTL_MS) { - return cached.isGitRepo; - } - - const isGitRepo = await checkIsGitRepository(key).catch(() => false); - projectRepoStatusCache.set(key, { cachedAt: Date.now(), isGitRepo }); - return isGitRepo; -}; - -const getSessionSelectionMap = (): SessionSelectionMap => { - if (!sessionSelectionCache) { - sessionSelectionCache = readSessionSelectionMap(); - } - return sessionSelectionCache; -}; - -const persistSessionSelectionMap = (map: SessionSelectionMap) => { - sessionSelectionCache = map; - pendingSelectionMap = map; - clearTimeout(persistSelectionTimer); - persistSelectionTimer = setTimeout(() => { - try { - safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(map)); - pendingSelectionMap = null; - } catch { /* ignored */ } - }, 300); -}; - -if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - if (pendingSelectionMap !== null) { - clearTimeout(persistSelectionTimer); - try { - safeStorage.setItem(SESSION_SELECTION_STORAGE_KEY, JSON.stringify(pendingSelectionMap)); - } catch { /* ignored */ } - pendingSelectionMap = null; - } - }); -} - -const getStoredSessionForDirectory = (directory: string | null | undefined): string | null => { - if (!directory) { - return null; - } - const map = getSessionSelectionMap(); - const selection = map[directory]; - return typeof selection === "string" ? selection : null; -}; - -const storeSessionForDirectory = (directory: string | null | undefined, sessionId: string | null) => { - if (!directory) { - return; - } - const map = { ...getSessionSelectionMap() }; - if (sessionId) { - map[directory] = sessionId; - } else { - delete map[directory]; - } - persistSessionSelectionMap(map); -}; - -const clearInvalidSessionSelection = (directory: string | null | undefined, validIds: Iterable) => { - if (!directory) { - return; - } - const storedSelection = getStoredSessionForDirectory(directory); - if (!storedSelection) { - return; - } - const validSet = new Set(validIds); - if (!validSet.has(storedSelection)) { - const map = { ...getSessionSelectionMap() }; - delete map[directory]; - persistSessionSelectionMap(map); - } -}; - -const archiveSessionWorktree = async ( - metadata: WorktreeMetadata, - options?: { deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string } -) => { - const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined)); - - const projects = useProjectsStore.getState().projects; - const normalizedProject = normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory; - const projectEntry = projects.find((project) => normalizePath(project.path) === normalizedProject); - - const projectRef = { - id: projectEntry?.id ?? `path:${normalizedProject}`, - path: normalizedProject, - }; - - await removeProjectWorktree( - projectRef, - status ? ({ ...metadata, status } as WorktreeMetadata) : metadata, - { - deleteRemoteBranch: options?.deleteRemoteBranch, - deleteLocalBranch: options?.deleteLocalBranch, - remoteName: options?.remoteName, - } - ); -}; - -const deleteSessionOnServer = async (sessionId: string, directory?: string | null): Promise => { - const apiClient = opencodeClient.getApiClient(); - const normalizedDirectory = normalizePath(directory ?? null); - const response = await apiClient.session.delete({ - sessionID: sessionId, - ...(normalizedDirectory ? { directory: normalizedDirectory } : {}), - }); - return Boolean(response.data); -}; - -const setSessionArchivedOnServer = async ( - sessionId: string, - archivedAt: number, - directory?: string | null, -): Promise => { - const apiClient = opencodeClient.getApiClient(); - const normalizedDirectory = normalizePath(directory ?? null); - const response = await apiClient.session.update({ - sessionID: sessionId, - ...(normalizedDirectory ? { directory: normalizedDirectory } : {}), - time: { archived: archivedAt }, - }); - return response.data ?? null; -}; - -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const replaced = trimmed - .replace(/\\/g, "/") - .replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`) - .replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`); - if (replaced === "/") { - return "/"; - } - return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; -}; - -const readVSCodeWorkspaceDirectory = (): string | null => { - if (typeof window === "undefined") { - return null; - } - const config = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__; - const workspaceFolder = typeof config?.workspaceFolder === "string" ? config.workspaceFolder : null; - return normalizePath(workspaceFolder); -}; - -const isVSCodeRuntime = (): boolean => { - if (typeof window === "undefined") return false; - const runtime = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }) - .__OPENCHAMBER_RUNTIME_APIS__?.runtime; - return Boolean(runtime?.isVSCode); -}; - -const vscodeDebugLog = (...args: unknown[]) => { - if (!streamDebugEnabled()) return; - if (!isVSCodeRuntime()) return; - console.log("[OpenChamber][VSCode][sessions]", ...args); -}; - -const dedupeSessionsById = (sessions: Session[]): Session[] => { - const map = new Map(); - - sessions.forEach((session) => { - if (!session || typeof session.id !== "string" || session.id.length === 0) { - return; - } - - const existing = map.get(session.id); - if (!existing) { - map.set(session.id, session); - return; - } - - const existingUpdated = (existing as { time?: { updated?: number | null } }).time?.updated ?? 0; - const candidateUpdated = (session as { time?: { updated?: number | null } }).time?.updated ?? 0; - if (candidateUpdated > existingUpdated) { - map.set(session.id, session); - } - }); - - return Array.from(map.values()); -}; - -const buildSessionsByDirectory = (sessions: Session[]): Map => { - const map = new Map(); - - sessions.forEach((session) => { - const directory = normalizePath((session as { directory?: string | null }).directory ?? null); - if (!directory) { - return; - } - - const existing = map.get(directory); - if (existing) { - existing.push(session); - } else { - map.set(directory, [session]); - } - }); - - for (const [key, value] of map.entries()) { - map.set(key, dedupeSessionsById(value)); - } - - return map; -}; - -const getSessionDirectory = (sessions: Session[], sessionId: string): string | null => { - const target = sessions.find((session) => session.id === sessionId); - if (!target) { - return null; - } - return normalizePath((target as { directory?: string | null }).directory ?? null); -}; - -const hydrateSessionWorktreeMetadata = async ( - sessions: Session[], - projectDirectory: string | null, - existingMetadata: Map, - preloadedWorktrees?: WorktreeMetadata[] -): Promise | null> => { - const normalizedProject = normalizePath(projectDirectory); - if (!normalizedProject || sessions.length === 0) { - return null; - } - - const sessionsWithDirectory = sessions - .map((session) => ({ id: session.id, directory: normalizePath((session as { directory?: string }).directory) })) - .filter((entry): entry is { id: string; directory: string } => Boolean(entry.directory)); - - if (sessionsWithDirectory.length === 0) { - return null; - } - - let worktreeEntries: WorktreeMetadata[]; - if (Array.isArray(preloadedWorktrees)) { - worktreeEntries = preloadedWorktrees; - } else { - try { - worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject }); - } catch (error) { - console.debug("Failed to hydrate worktree metadata from worktree list:", error); - return null; - } - } - - if (!Array.isArray(worktreeEntries) || worktreeEntries.length === 0) { - let mutated = false; - const next = new Map(existingMetadata); - sessionsWithDirectory.forEach(({ id }) => { - if (next.delete(id)) { - mutated = true; - } - }); - return mutated ? next : null; - } - - const worktreeMapByPath = new Map(); - worktreeEntries.forEach((metadata) => { - const normalizedPath = normalizePath(metadata.path) ?? metadata.path; - - if (normalizedPath === normalizedProject) { - return; - } - - worktreeMapByPath.set(normalizedPath, metadata); - }); - - let mutated = false; - const next = new Map(existingMetadata); - - const mergeHydratedMetadata = ( - hydrated: WorktreeMetadata, - previous?: WorktreeMetadata - ): WorktreeMetadata => { - if (!previous) { - return hydrated; - } - return { - ...previous, - ...hydrated, - branch: hydrated.branch || previous.branch, - label: hydrated.label || previous.label, - name: hydrated.name || previous.name, - projectDirectory: hydrated.projectDirectory || previous.projectDirectory, - createdFromBranch: hydrated.createdFromBranch || previous.createdFromBranch, - kind: hydrated.kind || previous.kind, - status: hydrated.status || previous.status, - }; - }; - - sessionsWithDirectory.forEach(({ id, directory }) => { - const metadata = worktreeMapByPath.get(directory); - if (!metadata) { - if (next.delete(id)) { - mutated = true; - } - return; - } - - const previous = next.get(id); - const merged = mergeHydratedMetadata(metadata, previous); - if ( - !previous || - previous.path !== merged.path || - previous.branch !== merged.branch || - previous.label !== merged.label || - previous.name !== merged.name || - previous.projectDirectory !== merged.projectDirectory || - previous.createdFromBranch !== merged.createdFromBranch || - previous.kind !== merged.kind || - previous.source !== merged.source - ) { - next.set(id, merged); - mutated = true; - } - }); - - return mutated ? next : null; -}; - -export const useSessionStore = create()( - devtools( - persist( - (set, get) => ({ - - sessions: [], - archivedSessions: [], - sessionsByDirectory: new Map(), - currentSessionId: null, - lastLoadedDirectory: null, - isLoading: false, - error: null, - webUICreatedSessions: new Set(), - worktreeMetadata: new Map(), - availableWorktrees: [], - availableWorktreesByProject: new Map(), - - loadSessions: async () => { - if (loadSessionsInFlight) { - loadSessionsQueued = true; - return loadSessionsInFlight; - } - - const task = (async () => { - const requestSeq = ++loadSessionsRequestSeq; - const isLatestRequest = () => requestSeq === loadSessionsRequestSeq; - set({ isLoading: true, error: null }); - try { - const directoryStore = useDirectoryStore.getState(); - const projectsStore = useProjectsStore.getState(); - const apiClient = opencodeClient.getApiClient(); - const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory(); - const includeDescendants = Boolean(vscodeWorkspaceDirectory); - - vscodeDebugLog("loadSessions:start", { - workspace: vscodeWorkspaceDirectory, - currentDirectory: directoryStore.currentDirectory, - clientDirectory: opencodeClient.getDirectory(), - projectsCount: projectsStore.projects.length, - activeProjectId: projectsStore.activeProjectId, - }); - - const normalizedFallback = normalizePath(directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null); - const activeProject = projectsStore.projects.find((project) => project.id === projectsStore.activeProjectId) ?? null; - const activeProjectRoot = normalizePath(activeProject?.path ?? null); - - const legacyRoot = activeProjectRoot ?? normalizedFallback ?? null; - - const projectEntries: Array> = projectsStore.projects.length > 0 - ? projectsStore.projects - : (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []); - - const resolveSessionDirectory = (session: Session): string | null => { - const direct = normalizePath((session as { directory?: string | null }).directory ?? null); - if (direct) { - return direct; - } - const projectWorktree = normalizePath((session as GlobalSessionRecord).project?.worktree ?? null); - return projectWorktree; - }; - - const matchesProjectDirectory = (sessionDirectory: string | null, projectDirectory: string): boolean => { - if (!sessionDirectory) { - return false; - } - if (sessionDirectory === projectDirectory) { - return true; - } - return includeDescendants && sessionDirectory.startsWith(`${projectDirectory}/`); - }; - - const applyProjectResults = async (projectResults: ProjectSessionResult[], archivedSessions: Session[]) => { - const sessionsByDirectory = new Map(); - projectResults.forEach((result) => { - if (!result.projectPath) { - return; - } - - result.validPaths.forEach((directory) => { - const directoryKey = normalizePath(directory) ?? directory; - const directorySessions = result.sessions.filter((session) => { - const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey; - return dir === directoryKey; - }); - sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions)); - }); - }); - - const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat()); - const stateSnapshot = get(); - - let nextWorktreeMetadata = stateSnapshot.worktreeMetadata; - for (const result of projectResults) { - if (!result.projectPath) { - continue; - } - try { - const hydratedMetadata = await hydrateSessionWorktreeMetadata( - result.sessions, - result.projectPath, - nextWorktreeMetadata, - result.discoveredWorktrees - ); - if (hydratedMetadata) { - nextWorktreeMetadata = hydratedMetadata; - } - } catch (metadataError) { - console.debug("Failed to refresh worktree metadata during session load:", metadataError); - } - } - - const worktreesByProject = new Map(); - projectResults.forEach((result) => { - if (result.projectPath) { - worktreesByProject.set(result.projectPath, result.discoveredWorktrees); - } - }); - - const allValidPaths = new Set(); - projectResults.forEach((result) => { - result.validPaths.forEach((value) => { - const key = normalizePath(value) ?? value; - if (key) { - allValidPaths.add(key); - } - }); - }); - - const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null; - const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate) - ? activeDirectoryCandidate - : (activeProjectRoot ?? activeDirectoryCandidate); - - const activeDirectorySessions = activeDirectory - ? sessionsByDirectory.get(activeDirectory) ?? [] - : mergedSessions; - - const validSessionIds = new Set(mergedSessions.map((session) => session.id)); - - // Keep directory-scoped stored selections tidy. - for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) { - clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id)); - } - - const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null); - - let nextCurrentId = stateSnapshot.currentSessionId; - const currentSessionInActiveDirectory = Boolean( - nextCurrentId && activeDirectorySessions.some((session) => session.id === nextCurrentId) - ); - if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || (directoryChanged && !currentSessionInActiveDirectory)) { - nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null; - } - - if (activeDirectory) { - const storedSelection = getStoredSessionForDirectory(activeDirectory); - if (storedSelection && validSessionIds.has(storedSelection)) { - nextCurrentId = storedSelection; - } - } - - const resolvedDirectoryForCurrent = (() => { - if (!nextCurrentId) { - return activeDirectory ?? null; - } - const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path; - if (metadataPath) { - return normalizePath(metadataPath) ?? metadataPath; - } - const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId); - if (sessionDir) { - return sessionDir; - } - return activeDirectory ?? null; - })(); - - if (!isLatestRequest()) { - return; - } - - try { - opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined); - } catch (error) { - console.warn("Failed to sync OpenCode directory after session load:", error); - } - - const activeWorktrees = activeProjectRoot - ? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? [] - : []; - - set({ - sessions: mergedSessions, - archivedSessions, - sessionsByDirectory, - currentSessionId: nextCurrentId, - lastLoadedDirectory: activeDirectory ?? null, - isLoading: false, - worktreeMetadata: nextWorktreeMetadata, - availableWorktrees: activeWorktrees, - availableWorktreesByProject: worktreesByProject, - }); - - if (activeDirectory) { - storeSessionForDirectory(activeDirectory, nextCurrentId); - } - if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) { - storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId); - } - }; - - if (projectEntries.length === 0) { - if (!isLatestRequest()) { - return; - } - set({ - sessions: [], - archivedSessions: [], - sessionsByDirectory: new Map(), - currentSessionId: null, - lastLoadedDirectory: null, - isLoading: false, - worktreeMetadata: new Map(), - availableWorktrees: [], - availableWorktreesByProject: new Map(), - }); - return; - } - - pruneProjectCaches(projectEntries.map((entry) => entry.path)); - - const buildProjectResults = async (sourceSessions: Session[]): Promise => { - return Promise.all( - projectEntries.map(async (project: Pick) => { - const normalizedProject = normalizePath(project.path); - if (!normalizedProject) { - return { - projectId: project.id, - projectPath: null, - sessions: [], - discoveredWorktrees: [], - validPaths: new Set(), - }; - } - - const isGitRepo = await getProjectRepoStatus(normalizedProject); - let discoveredWorktrees: WorktreeMetadata[] = []; - const validPaths = new Set([normalizedProject]); - if (isGitRepo) { - discoveredWorktrees = await listProjectWorktrees({ - id: project.id, - path: normalizedProject, - }).catch(() => []); - discoveredWorktrees.forEach((meta) => { - if (meta?.path) { - validPaths.add(normalizePath(meta.path) ?? meta.path); - } - }); - } - - const mergedSessions = dedupeSessionsById( - sourceSessions.filter((session) => { - const sessionDirectory = resolveSessionDirectory(session); - if (!sessionDirectory) { - return false; - } - for (const projectPath of validPaths) { - if (matchesProjectDirectory(sessionDirectory, projectPath)) { - return true; - } - } - return false; - }), - ); - - const result: ProjectSessionResult = { - projectId: project.id, - projectPath: normalizedProject, - sessions: mergedSessions, - discoveredWorktrees, - validPaths, - }; - setProjectSessionCache(normalizedProject, result); - return result; - }), - ); - }; - - try { - const pageSize = 500; - const previousArchivedSessions = dedupeSessionsById(get().archivedSessions); - const firstPage = await apiClient.experimental.session.list({ limit: pageSize, archived: false }); - let liveSessions = dedupeSessionsById(Array.isArray(firstPage.data) ? firstPage.data as Session[] : []); - let archivedSessions: Session[] = []; - let hasLoadedArchivedSessions = false; - - const apply = async () => { - if (!isLatestRequest()) { - return; - } - const projectResults = await buildProjectResults(liveSessions); - const archivedForRender = hasLoadedArchivedSessions - ? dedupeSessionsById(archivedSessions) - : previousArchivedSessions; - await applyProjectResults(projectResults, archivedForRender); - }; - - await apply(); - - const backgroundLoad = async () => { - let cursor = readNextCursor(firstPage) ?? undefined; - while (cursor && isLatestRequest()) { - const response = await apiClient.experimental.session.list({ - limit: pageSize, - cursor, - archived: false, - }); - const page = Array.isArray(response.data) ? response.data as Session[] : []; - if (page.length === 0) { - break; - } - liveSessions = dedupeSessionsById([...liveSessions, ...page]); - await apply(); - cursor = readNextCursor(response) ?? undefined; - } - - let archivedCursor: number | undefined; - while (isLatestRequest()) { - const response = await apiClient.experimental.session.list({ - limit: pageSize, - archived: true, - ...(archivedCursor ? { cursor: archivedCursor } : {}), - }); - const page = Array.isArray(response.data) - ? (response.data as Session[]).filter((session) => Boolean(session.time?.archived)) - : []; - if (page.length > 0) { - hasLoadedArchivedSessions = true; - archivedSessions = dedupeSessionsById([...archivedSessions, ...page]); - await apply(); - } - const next = readNextCursor(response); - if (!next) { - break; - } - archivedCursor = next; - } - - if (!hasLoadedArchivedSessions && isLatestRequest()) { - hasLoadedArchivedSessions = true; - archivedSessions = []; - await apply(); - } - }; - - void backgroundLoad().catch((error) => { - console.debug("Failed to load additional global sessions:", error); - }); - - return; - } catch (error) { - if (!isMissingGlobalSessionsEndpointError(error)) { - throw error; - } - console.debug("Global session endpoint unavailable, using legacy loader"); - } - - const fallbackResponse = await apiClient.session.list(undefined); - const fallbackSessions = dedupeSessionsById(Array.isArray(fallbackResponse.data) ? fallbackResponse.data : []); - const fallbackProjectResults = await buildProjectResults(fallbackSessions); - await applyProjectResults(fallbackProjectResults, []); - } catch (error) { - if (!isLatestRequest()) { - return; - } - set({ - error: error instanceof Error ? error.message : "Failed to load sessions", - isLoading: false, - }); - } - })(); - - loadSessionsInFlight = task; - try { - await task; - } finally { - if (loadSessionsInFlight === task) { - loadSessionsInFlight = null; - } - if (loadSessionsQueued) { - loadSessionsQueued = false; - void get().loadSessions(); - } - } - }, - - createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => { - set({ error: null }); - const directoryStore = useDirectoryStore.getState(); - const fallbackDirectory = normalizePath(directoryStore.currentDirectory); - const vscodeWorkspaceDirectory = readVSCodeWorkspaceDirectory(); - const targetDirectory = vscodeWorkspaceDirectory ?? normalizePath(directoryOverride ?? opencodeClient.getDirectory() ?? fallbackDirectory); - vscodeDebugLog("createSession:start", { title, parentID, targetDirectory, vscodeWorkspaceDirectory }); - - const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; - const previousState = get(); - const existingIds = new Set(previousState.sessions.map((s) => s.id)); - const optimisticSession: Session = { - id: tempId, - title: title || "New session", - parentID: parentID ?? undefined, - directory: targetDirectory ?? null, - projectID: (previousState.sessions[0] as { projectID?: string })?.projectID ?? "", - version: "0.0.0", - time: { - created: Date.now(), - updated: Date.now(), - }, - summary: undefined, - share: undefined, - } as Session; - - set((state) => { - const nextSessions = [optimisticSession, ...state.sessions]; - const nextByDirectory = new Map(state.sessionsByDirectory); - if (targetDirectory) { - const existing = nextByDirectory.get(targetDirectory) ?? []; - nextByDirectory.set(targetDirectory, dedupeSessionsById([optimisticSession, ...existing])); - } - - return { - sessions: nextSessions, - sessionsByDirectory: nextByDirectory, - currentSessionId: tempId, - webUICreatedSessions: new Set([...state.webUICreatedSessions, tempId]), - isLoading: false, - }; - }); - - if (targetDirectory) { - try { - opencodeClient.setDirectory(targetDirectory); - } catch (error) { - console.warn("Failed to sync OpenCode directory after session creation:", error); - } - } - - const replaceOptimistic = (real: Session) => { - const normalizedTarget = targetDirectory ?? null; - const normalizedReal: Session = (normalizedTarget - ? ({ ...real, directory: normalizedTarget } as Session) - : real); - set((state) => { - const updatedSessions = state.sessions.map((item) => (item.id === tempId ? normalizedReal : item)); - - const nextByDirectory = new Map(state.sessionsByDirectory); - if (targetDirectory) { - const existing = nextByDirectory.get(targetDirectory) ?? []; - const replaced = existing.map((item) => (item.id === tempId ? normalizedReal : item)); - nextByDirectory.set(targetDirectory, dedupeSessionsById(replaced)); - } - - return { - sessions: updatedSessions, - sessionsByDirectory: buildSessionsByDirectory(updatedSessions), - currentSessionId: normalizedReal.id, - webUICreatedSessions: new Set([ - ...Array.from(state.webUICreatedSessions).filter((id) => id !== tempId), - normalizedReal.id, - ]), - }; - }); - storeSessionForDirectory(targetDirectory ?? null, normalizedReal.id); - }; - - const pollForSession = async (): Promise => { - const apiClient = opencodeClient.getApiClient(); - const attempts = 20; - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - const response = await apiClient.session.list( - targetDirectory ? { directory: targetDirectory } : undefined - ); - const list = Array.isArray(response.data) ? response.data : []; - const candidate = list.find((entry) => { - if (existingIds.has(entry.id)) return false; - if (title && entry.title && entry.title !== title) return false; - return true; - }); - if (candidate) { - return candidate as Session; - } - } catch (pollError) { - console.debug("Session poll attempt failed:", pollError); - } - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - return null; - }; - - try { - const createRequest = () => opencodeClient.createSession({ title, parentID: parentID ?? undefined }); - let session: Session | null = null; - - try { - session = targetDirectory - ? await opencodeClient.withDirectory(targetDirectory, createRequest) - : await createRequest(); - } catch (creationError) { - console.warn("Direct session create failed or timed out, falling back to polling:", creationError); - } - - if (!session) { - session = await pollForSession(); - } - - if (session) { - replaceOptimistic(session); - return session; - } - - set((state) => ({ - sessions: state.sessions.filter((s) => s.id !== tempId), - currentSessionId: previousState.currentSessionId, - webUICreatedSessions: new Set( - Array.from(state.webUICreatedSessions).filter((id) => id !== tempId) - ), - isLoading: false, - error: "Failed to create session", - })); - return null; - } catch (error) { - - set((state) => ({ - sessions: state.sessions.filter((s) => s.id !== tempId), - currentSessionId: previousState.currentSessionId, - webUICreatedSessions: new Set( - Array.from(state.webUICreatedSessions).filter((id) => id !== tempId) - ), - isLoading: false, - error: error instanceof Error ? error.message : "Failed to create session", - })); - return null; - } - }, - - deleteSession: async (id: string, options) => { - set({ isLoading: true, error: null }); - const metadata = get().worktreeMetadata.get(id); - const metadataPath = typeof metadata?.path === 'string' ? metadata.path : null; - const metadataProjectDirectory = typeof metadata?.projectDirectory === 'string' ? metadata.projectDirectory : null; - const snapshot = get(); - const sessionDirectory = getSessionDirectory([...snapshot.sessions, ...snapshot.archivedSessions], id); - const requestDirectory = normalizePath(metadataProjectDirectory) - ?? normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - - let archiveSucceeded = false; - try { - const success = await deleteSessionOnServer(id, requestDirectory); - if (!success) { - set({ - isLoading: false, - error: "Failed to delete session", - }); - return false; - } - - if (metadata && options?.archiveWorktree) { - try { - await archiveSessionWorktree(metadata, { - deleteRemoteBranch: options?.deleteRemoteBranch, - deleteLocalBranch: options?.deleteLocalBranch, - remoteName: options?.remoteName, - }); - archiveSucceeded = true; - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete worktree"; - set({ error: message }); - } - } - - let nextCurrentId: string | null = null; - set((state) => { - const filteredSessions = state.sessions.filter((s) => s.id !== id); - const filteredArchivedSessions = state.archivedSessions.filter((s) => s.id !== id); - nextCurrentId = state.currentSessionId === id ? null : state.currentSessionId; - const nextMetadata = new Map(state.worktreeMetadata); - nextMetadata.delete(id); - const shouldRemoveWorktreeFromLists = Boolean(metadataPath && options?.archiveWorktree && archiveSucceeded); - const nextAvailableWorktrees = shouldRemoveWorktreeFromLists - ? state.availableWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath)) - : state.availableWorktrees; - const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); - if (shouldRemoveWorktreeFromLists && metadataProjectDirectory) { - const projectKey = normalizePath(metadataProjectDirectory) ?? metadataProjectDirectory; - const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? []; - nextAvailableWorktreesByProject.set( - projectKey, - projectWorktrees.filter((entry) => normalizePath(entry.path) !== normalizePath(metadataPath)) - ); - } - return { - sessions: filteredSessions, - archivedSessions: filteredArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(filteredSessions), - currentSessionId: nextCurrentId, - isLoading: false, - worktreeMetadata: nextMetadata, - availableWorktrees: nextAvailableWorktrees, - availableWorktreesByProject: nextAvailableWorktreesByProject, - }; - }); - - const directoryToStore = normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - storeSessionForDirectory(directoryToStore, nextCurrentId); - - return true; - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete session"; - set({ - error: message, - isLoading: false, - }); - return false; - } - }, - - deleteSessions: async ( - ids: string[], - options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean } - ) => { - const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); - if (uniqueIds.length === 0) { - return { deletedIds: [], failedIds: [] }; - } - - const silent = options?.silent === true; - if (!silent) { - set({ isLoading: true, error: null }); - } - const deletedIds: string[] = []; - const failedIds: string[] = []; - const worktreesToArchive = new Map(); - const archivedWorktreePaths = new Set(); - - for (const id of uniqueIds) { - try { - const metadata = get().worktreeMetadata.get(id); - const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id); - const requestDirectory = normalizePath(metadata?.projectDirectory ?? null) - ?? normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - - if (metadata && options?.archiveWorktree) { - const key = normalizePath(metadata.path) ?? metadata.path; - if (!archivedWorktreePaths.has(key)) { - archivedWorktreePaths.add(key); - worktreesToArchive.set(key, metadata); - } - } - - const success = await deleteSessionOnServer(id, requestDirectory); - if (success) { - deletedIds.push(id); - } else { - failedIds.push(id); - } - } catch { - failedIds.push(id); - } - } - - const archivedWorktrees: Array<{ path: string; projectDirectory: string }> = []; - const archiveFailures: string[] = []; - - if (options?.archiveWorktree && worktreesToArchive.size > 0) { - for (const metadata of worktreesToArchive.values()) { - try { - await archiveSessionWorktree(metadata, { - deleteRemoteBranch: options?.deleteRemoteBranch, - deleteLocalBranch: options?.deleteLocalBranch, - remoteName: options?.remoteName, - }); - archivedWorktrees.push({ path: metadata.path, projectDirectory: metadata.projectDirectory }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete worktree"; - archiveFailures.push(message); - } - } - } - - if (archiveFailures.length > 0) { - set({ error: archiveFailures[0] }); - } - - const directoryStore = useDirectoryStore.getState(); - archivedWorktrees.forEach(({ path, projectDirectory }) => { - if (directoryStore.currentDirectory === path) { - directoryStore.setDirectory(projectDirectory, { showOverlay: false }); - } - }); - - const deletedSet = new Set(deletedIds); - const errorMessage = failedIds.length > 0 - ? (failedIds.length === uniqueIds.length ? "Failed to delete sessions" : "Failed to delete some sessions") - : null; - let nextCurrentId: string | null = null; - - set((state) => { - const filteredSessions = state.sessions.filter((session) => !deletedSet.has(session.id)); - const filteredArchivedSessions = state.archivedSessions.filter((session) => !deletedSet.has(session.id)); - if (state.currentSessionId && deletedSet.has(state.currentSessionId)) { - nextCurrentId = null; - } else { - nextCurrentId = state.currentSessionId; - } - - const nextMetadata = new Map(state.worktreeMetadata); - for (const removedId of deletedSet) { - nextMetadata.delete(removedId); - } - - const removedPaths = new Set( - archivedWorktrees - .map((entry) => normalizePath(entry.path)) - .filter((p): p is string => Boolean(p)) - ); - const nextAvailableWorktrees = removedPaths.size > 0 - ? state.availableWorktrees.filter((entry) => !removedPaths.has(normalizePath(entry.path) ?? entry.path)) - : state.availableWorktrees; - - const nextAvailableWorktreesByProject = new Map(state.availableWorktreesByProject); - if (archivedWorktrees.length > 0) { - const removedPathsByProject = archivedWorktrees.reduce>>((accumulator, entry) => { - const projectKey = normalizePath(entry.projectDirectory) ?? entry.projectDirectory; - const pathKey = normalizePath(entry.path) ?? entry.path; - if (!accumulator.has(projectKey)) { - accumulator.set(projectKey, new Set()); - } - accumulator.get(projectKey)?.add(pathKey); - return accumulator; - }, new Map()); - - removedPathsByProject.forEach((paths, projectKey) => { - const projectWorktrees = nextAvailableWorktreesByProject.get(projectKey) ?? []; - const filtered = projectWorktrees.filter( - (entry) => !paths.has(normalizePath(entry.path) ?? entry.path) - ); - nextAvailableWorktreesByProject.set(projectKey, filtered); - }); - } - - return { - sessions: filteredSessions, - archivedSessions: filteredArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(filteredSessions), - currentSessionId: nextCurrentId, - ...(silent ? {} : { isLoading: false, error: errorMessage }), - worktreeMetadata: nextMetadata, - availableWorktrees: nextAvailableWorktrees, - availableWorktreesByProject: nextAvailableWorktreesByProject, - }; - }); - - const directory = opencodeClient.getDirectory() ?? null; - storeSessionForDirectory(directory, nextCurrentId); - - return { deletedIds, failedIds }; - }, - - archiveSession: async (id: string) => { - const { archivedIds, failedIds } = await get().archiveSessions([id]); - return archivedIds.length === 1 && failedIds.length === 0; - }, - - archiveSessions: async (ids: string[], options?: { silent?: boolean }) => { - const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))); - if (uniqueIds.length === 0) { - return { archivedIds: [], failedIds: [] }; - } - - const silent = options?.silent === true; - if (!silent) { - set({ isLoading: true, error: null }); - } - - const archivedIds: string[] = []; - const failedIds: string[] = []; - - for (const id of uniqueIds) { - try { - const metadata = get().worktreeMetadata.get(id); - const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id); - const requestDirectory = normalizePath(metadata?.projectDirectory ?? null) - ?? normalizePath(sessionDirectory) - ?? normalizePath(opencodeClient.getDirectory() ?? null) - ?? null; - const archived = await setSessionArchivedOnServer(id, Date.now(), requestDirectory); - if (!archived) { - failedIds.push(id); - continue; - } - archivedIds.push(id); - } catch { - failedIds.push(id); - } - } - - const archivedSet = new Set(archivedIds); - let nextCurrentId: string | null = null; - const errorMessage = failedIds.length > 0 - ? (failedIds.length === uniqueIds.length ? "Failed to archive sessions" : "Failed to archive some sessions") - : null; - - set((state) => { - if (archivedSet.size === 0) { - return silent ? state : { ...state, isLoading: false, error: errorMessage }; - } - - const archivedRows = state.sessions.filter((session) => archivedSet.has(session.id)).map((session) => ({ - ...session, - time: { - ...session.time, - archived: Date.now(), - }, - } as Session)); - - const remaining = state.sessions.filter((session) => !archivedSet.has(session.id)); - const nextArchivedSessions = dedupeSessionsById([...archivedRows, ...state.archivedSessions]); - - if (state.currentSessionId && archivedSet.has(state.currentSessionId)) { - nextCurrentId = remaining[0]?.id ?? null; - } else { - nextCurrentId = state.currentSessionId; - } - - return { - sessions: remaining, - archivedSessions: nextArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(remaining), - currentSessionId: nextCurrentId, - ...(silent ? {} : { isLoading: false, error: errorMessage }), - }; - }); - - if (!silent && archivedSet.size === 0) { - set({ isLoading: false, error: errorMessage }); - } - - return { archivedIds, failedIds }; - }, - - updateSessionTitle: async (id: string, title: string) => { - try { - const sessionDirectory = getSessionDirectory(get().sessions, id); - const metadata = get().worktreeMetadata.get(id); - const updateRequest = () => opencodeClient.updateSession(id, title); - const overrideDirectory = metadata?.path ?? sessionDirectory; - const updatedSession = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, updateRequest) - : await updateRequest(); - set((state) => { - const sessions = state.sessions.map((s) => (s.id === id ? updatedSession : s)); - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : "Failed to update session title", - }); - } - }, - - shareSession: async (id: string) => { - try { - const sessionDirectory = getSessionDirectory(get().sessions, id); - const apiClient = opencodeClient.getApiClient(); - const metadata = get().worktreeMetadata.get(id); - const overrideDirectory = metadata?.path ?? sessionDirectory; - const shareRequest = async () => { - const directory = sessionDirectory ?? opencodeClient.getDirectory(); - return apiClient.session.share({ - sessionID: id, - ...(directory ? { directory } : {}) - }); - }; - const response = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, shareRequest) - : await shareRequest(); - - if (response.data) { - set((state) => { - const sessions = state.sessions.map((s) => (s.id === id ? response.data : s)); - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - return response.data; - } - return null; - } catch (error) { - set({ - error: error instanceof Error ? error.message : "Failed to share session", - }); - return null; - } - }, - - unshareSession: async (id: string) => { - try { - const sessionDirectory = getSessionDirectory(get().sessions, id); - const apiClient = opencodeClient.getApiClient(); - const metadata = get().worktreeMetadata.get(id); - const overrideDirectory = metadata?.path ?? sessionDirectory; - const unshareRequest = async () => { - const directory = sessionDirectory ?? opencodeClient.getDirectory(); - return apiClient.session.unshare({ - sessionID: id, - ...(directory ? { directory } : {}) - }); - }; - const response = overrideDirectory - ? await opencodeClient.withDirectory(overrideDirectory, unshareRequest) - : await unshareRequest(); - - if (response.data) { - set((state) => { - const sessions = state.sessions.map((s) => (s.id === id ? response.data : s)); - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - return response.data; - } - return null; - } catch (error) { - set({ - error: error instanceof Error ? error.message : "Failed to unshare session", - }); - return null; - } - }, - - setCurrentSession: (id: string | null) => { - const prevSessionId = get().currentSessionId; - set({ currentSessionId: id, error: null }); - - // Notify server of view state changes - // This enables server-side needs_attention tracking - if (prevSessionId && prevSessionId !== id) { - // Leaving previous session - fetch(`/api/sessions/${prevSessionId}/unview`, { method: 'POST' }) - .catch(() => { /* ignore */ }); - } - if (id) { - // Entering new session - fetch(`/api/sessions/${id}/view`, { method: 'POST' }) - .catch(() => { /* ignore */ }); - } - - // Trigger immediate poll to get latest attention states - // This prevents stale state when switching sessions - triggerSessionStatusPoll(); - - const directory = opencodeClient.getDirectory() ?? null; - storeSessionForDirectory(directory, id); - }, - - clearError: () => { - set({ error: null }); - }, - - getSessionsByDirectory: (directory: string) => { - const normalized = normalizePath(directory) ?? directory; - const { sessionsByDirectory, sessions } = get(); - - const direct = sessionsByDirectory.get(normalized); - if (direct) { - return direct; - } - - return sessions.filter((session) => { - const dir = normalizePath((session as { directory?: string | null }).directory ?? null); - return (dir ?? normalized) === normalized; - }); - }, - - getDirectoryForSession: (sessionId: string) => { - if (!sessionId) { - return null; - } - - const metadata = get().worktreeMetadata.get(sessionId); - if (metadata?.path) { - return normalizePath(metadata.path) ?? metadata.path; - } - - const entry = get().sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined; - const directory = normalizePath(entry?.directory ?? null); - return directory; - }, - - applySessionMetadata: (sessionId, metadata) => { - if (!sessionId || !metadata) { - return; - } - - set((state) => { - const index = state.sessions.findIndex((session) => session.id === sessionId); - if (index === -1) { - return state; - } - - const existingSession = state.sessions[index]; - const mergedTime = metadata.time - ? { ...existingSession.time, ...metadata.time } - : existingSession.time; - const mergedSummary = - metadata.summary === undefined - ? existingSession.summary - : metadata.summary || undefined; - const mergedShare = - metadata.share === undefined - ? existingSession.share - : metadata.share || undefined; - - const mergedSession: Session = { - ...existingSession, - ...metadata, - time: mergedTime, - summary: mergedSummary, - share: mergedShare, - }; - - const hasChanged = - mergedSession.title !== existingSession.title || - mergedSession.parentID !== existingSession.parentID || - mergedSession.directory !== existingSession.directory || - mergedSession.version !== existingSession.version || - mergedSession.projectID !== existingSession.projectID || - (mergedTime !== existingSession.time && JSON.stringify(mergedTime) !== JSON.stringify(existingSession.time)) || - (mergedSummary !== existingSession.summary && JSON.stringify(mergedSummary ?? null) !== JSON.stringify(existingSession.summary ?? null)) || - (mergedShare !== existingSession.share && JSON.stringify(mergedShare ?? null) !== JSON.stringify(existingSession.share ?? null)); - - const sessions = [...state.sessions]; - sessions[index] = hasChanged ? mergedSession : existingSession; - - return { - sessions, - sessionsByDirectory: buildSessionsByDirectory(sessions), - }; - }); - }, - - isOpenChamberCreatedSession: (sessionId: string) => { - const { webUICreatedSessions } = get(); - return webUICreatedSessions.has(sessionId); - }, - - markSessionAsOpenChamberCreated: (sessionId: string) => { - set((state) => { - const newOpenChamberCreatedSessions = new Set(state.webUICreatedSessions); - newOpenChamberCreatedSessions.add(sessionId); - return { - webUICreatedSessions: newOpenChamberCreatedSessions, - }; - }); - }, - - initializeNewOpenChamberSession: (sessionId: string) => { - const { markSessionAsOpenChamberCreated } = get(); - - markSessionAsOpenChamberCreated(sessionId); - - }, - - setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => { - if (!sessionId) { - return; - } - set((state) => { - const next = new Map(state.worktreeMetadata); - if (metadata) { - next.set(sessionId, metadata); - } else { - next.delete(sessionId); - } - return { worktreeMetadata: next }; - }); - }, - - getWorktreeMetadata: (sessionId: string) => { - if (!sessionId) { - return undefined; - } - return get().worktreeMetadata.get(sessionId); - }, - - setSessionDirectory: (sessionId: string, directory: string | null) => { - if (!sessionId) { - return; - } - - const currentSessions = get().sessions; - const targetIndex = currentSessions.findIndex((session) => session.id === sessionId); - if (targetIndex === -1) { - return; - } - - const existingSession = currentSessions[targetIndex]; - const previousDirectory = existingSession.directory ?? null; - const normalizedDirectory = directory ?? undefined; - - if (previousDirectory === (normalizedDirectory ?? null)) { - return; - } - - set((state) => { - const sessions = [...state.sessions]; - const updatedSession = { ...sessions[targetIndex] } as Record; - if (normalizedDirectory !== undefined) { - updatedSession.directory = normalizedDirectory; - } else { - delete updatedSession.directory; - } - sessions[targetIndex] = updatedSession as Session; - return { sessions, sessionsByDirectory: buildSessionsByDirectory(sessions) }; - }); - - if (previousDirectory) { - storeSessionForDirectory(previousDirectory, null); - } - if (directory) { - storeSessionForDirectory(directory, sessionId); - } - - }, - - updateSession: (session: Session) => { - set((state) => { - const index = state.sessions.findIndex((s) => s.id === session.id); - const archivedIndex = state.archivedSessions.findIndex((s) => s.id === session.id); - const isArchived = Boolean(session.time?.archived); - - const nextSessions = index === -1 - ? (isArchived ? state.sessions : [session, ...state.sessions]) - : state.sessions.map((s) => (s.id === session.id ? session : s)); - - const nextArchivedSessions = archivedIndex === -1 - ? (isArchived ? [session, ...state.archivedSessions] : state.archivedSessions) - : state.archivedSessions.map((s) => (s.id === session.id ? session : s)); - - const deduped = dedupeSessionsById(nextSessions.filter((item) => !item.time?.archived)); - const dedupedArchived = dedupeSessionsById(nextArchivedSessions.filter((item) => Boolean(item.time?.archived))); - - return { - sessions: deduped, - archivedSessions: dedupedArchived, - sessionsByDirectory: buildSessionsByDirectory(deduped), - }; - }); - }, - - removeSessionFromStore: (sessionId: string) => { - if (!sessionId) { - return; - } - - set((state) => { - const target = [...state.sessions, ...state.archivedSessions] - .find((session) => session.id === sessionId) as { directory?: string | null } | undefined; - const directory = normalizePath(target?.directory ?? null); - - const filteredSessions = state.sessions.filter((session) => session.id !== sessionId); - const filteredArchivedSessions = state.archivedSessions.filter((session) => session.id !== sessionId); - if (filteredSessions.length === state.sessions.length && filteredArchivedSessions.length === state.archivedSessions.length) { - return state; - } - - const nextMetadata = new Map(state.worktreeMetadata); - nextMetadata.delete(sessionId); - - const nextCurrentId = state.currentSessionId === sessionId ? null : state.currentSessionId; - - if (directory) { - storeSessionForDirectory(directory, null); - } - - return { - sessions: filteredSessions, - archivedSessions: filteredArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(filteredSessions), - currentSessionId: nextCurrentId, - worktreeMetadata: nextMetadata, - }; - }); - }, - }), - { - name: "session-store", - storage: createJSONStorage(() => getSafeStorage()), - partialize: (state) => ({ - currentSessionId: state.currentSessionId, - sessions: state.sessions, - archivedSessions: state.archivedSessions, - lastLoadedDirectory: state.lastLoadedDirectory, - webUICreatedSessions: Array.from(state.webUICreatedSessions), - worktreeMetadata: Array.from(state.worktreeMetadata.entries()), - availableWorktrees: state.availableWorktrees, - availableWorktreesByProject: Array.from(state.availableWorktreesByProject.entries()), - }), - merge: (persistedState, currentState) => { - const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - - if (!isRecord(persistedState)) { - return currentState; - } - - const persistedSessions = Array.isArray(persistedState.sessions) - ? (persistedState.sessions as Session[]) - : currentState.sessions; - const persistedArchivedSessions = Array.isArray(persistedState.archivedSessions) - ? (persistedState.archivedSessions as Session[]) - : currentState.archivedSessions; - - const persistedCurrentSessionId = - typeof persistedState.currentSessionId === "string" || persistedState.currentSessionId === null - ? (persistedState.currentSessionId as string | null) - : currentState.currentSessionId; - - const webUiSessionsArray = Array.isArray(persistedState.webUICreatedSessions) - ? (persistedState.webUICreatedSessions as string[]) - : []; - - const persistedWorktreeEntries = Array.isArray(persistedState.worktreeMetadata) - ? (persistedState.worktreeMetadata as Array<[string, WorktreeMetadata]>) - : []; - - const persistedAvailableWorktrees = Array.isArray(persistedState.availableWorktrees) - ? (persistedState.availableWorktrees as WorktreeMetadata[]) - : currentState.availableWorktrees; - - const persistedWorktreesByProjectEntries = Array.isArray(persistedState.availableWorktreesByProject) - ? (persistedState.availableWorktreesByProject as Array<[string, WorktreeMetadata[]]>) - : []; - const persistedWorktreesByProject = new Map(persistedWorktreesByProjectEntries); - - const lastLoadedDirectory = - typeof persistedState.lastLoadedDirectory === "string" - ? persistedState.lastLoadedDirectory - : currentState.lastLoadedDirectory ?? null; - - const mergedSessions = dedupeSessionsById(persistedSessions); - const mergedArchivedSessions = dedupeSessionsById(persistedArchivedSessions); - - const mergedResult = { - ...currentState, - ...persistedState, - sessions: mergedSessions, - archivedSessions: mergedArchivedSessions, - sessionsByDirectory: buildSessionsByDirectory(mergedSessions), - currentSessionId: persistedCurrentSessionId, - webUICreatedSessions: new Set(webUiSessionsArray), - worktreeMetadata: new Map(persistedWorktreeEntries), - availableWorktrees: persistedAvailableWorktrees, - availableWorktreesByProject: persistedWorktreesByProject.size > 0 - ? persistedWorktreesByProject - : currentState.availableWorktreesByProject, - lastLoadedDirectory, - }; - return mergedResult; - }, - } - ), - { - name: "session-store", - } - ) -); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 9f5b13bd..d4716cde 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -187,15 +187,7 @@ export interface SessionStore { { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number; confirmedAt?: number } >; - // Server-authoritative session attention state - // Tracks which sessions need user attention based on server-side logic - sessionAttentionStates: Map; + // sessionAttentionStates removed — replaced by notification-store userSummaryTitles: Map; @@ -263,7 +255,7 @@ export interface SessionStore { clearError: () => void; getSessionsByDirectory: (directory: string) => Session[]; getDirectoryForSession: (sessionId: string) => string | null; - getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null; + getLastUserChoice: (sessionId: string) => { agent?: string; providerID?: string; modelID?: string; variant?: string } | null; getCurrentAgent: (sessionId: string) => string | undefined; syncMessages: ( sessionId: string, @@ -291,8 +283,6 @@ export interface SessionStore { saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void; getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined; - - analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => Promise>; isOpenChamberCreatedSession: (sessionId: string) => boolean; diff --git a/packages/ui/src/stores/useAgentGroupsStore.ts b/packages/ui/src/stores/useAgentGroupsStore.ts index eeca5eea..31188324 100644 --- a/packages/ui/src/stores/useAgentGroupsStore.ts +++ b/packages/ui/src/stores/useAgentGroupsStore.ts @@ -1,103 +1,16 @@ import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; -import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; +import { listProjectWorktrees, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useDirectoryStore } from './useDirectoryStore'; import { useProjectsStore } from './useProjectsStore'; -import { useSessionStore } from './useSessionStore'; +import { deleteSessionInDirectory } from '@/sync/session-actions'; +import { retry } from '@/sync/retry'; import type { WorktreeMetadata } from '@/types/worktree'; import type { Session } from '@opencode-ai/sdk/v2'; - -const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => { - const projectsState = useProjectsStore.getState(); - const activeProjectId = projectsState.activeProjectId; - const activeProjectPath = activeProjectId - ? projectsState.projects.find((project) => project.id === activeProjectId)?.path - : undefined; - - if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) { - return activeProjectPath; - } - - return currentDirectory ? normalize(currentDirectory) : null; -}; - -/** - * Agent group session parsed from OpenCode session titles. - * Session titles follow pattern: `groupSlug/provider/model` or `groupSlug/provider/model/index` - * Model can contain `/` for creator/model format (e.g., `anthropic/claude-opus-4-5`) - * - * Examples: - * - `feature/opencode/claude-sonnet-4-5` → group="feature", provider="opencode", model="claude-sonnet-4-5" - * - `feature/opencode/claude-sonnet-4-1/2` → group="feature", provider="opencode", model="claude-sonnet-4-1", index=2 - * - `feature/openrouter/anthropic/claude-opus-4-5` → group="feature", provider="openrouter", model="anthropic/claude-opus-4-5" - */ -export interface AgentGroupSession { - /** OpenCode session ID */ - id: string; - /** Full worktree path (from session.directory) */ - path: string; - /** Provider ID extracted from title */ - providerId: string; - /** Model ID extracted from title (may contain / for creator/model format) */ - modelId: string; - /** Instance number for duplicate model selections (default: 1) */ - instanceNumber: number; - /** Branch name associated with this worktree */ - branch: string; - /** Display label for the model */ - displayLabel: string; - /** Full worktree metadata */ - worktreeMetadata?: WorktreeMetadata; -} - -export interface AgentGroup { - /** Group name (e.g., "agent-manager-2", "contributing") */ - name: string; - /** Sessions within this group (one per model instance) */ - sessions: AgentGroupSession[]; - /** Timestamp of last activity (most recent session update) */ - lastActive: number; - /** Total session count */ - sessionCount: number; -} - -interface AgentGroupsState { - /** All discovered agent groups from session titles */ - groups: AgentGroup[]; - /** Currently selected group name */ - selectedGroupName: string | null; - /** Currently selected session ID within the group */ - selectedSessionId: string | null; - /** Loading state */ - isLoading: boolean; - /** Error message */ - error: string | null; -} - -interface AgentGroupsActions { - /** Load/refresh agent groups from OpenCode sessions */ - loadGroups: () => Promise; - /** Select a group */ - selectGroup: (groupName: string | null) => void; - /** Select a session within the current group */ - selectSession: (sessionId: string | null) => void; - /** Delete the entire group (all worktrees + sessions in those worktrees). */ - deleteGroup: (groupName: string) => Promise; - /** Delete a single worktree within a group (and all sessions in that worktree). */ - deleteGroupWorktree: (groupName: string, worktreePath: string) => Promise; - /** Keep one worktree and remove all others in the group. */ - keepOnlyGroupWorktree: (groupName: string, keepWorktreePath: string) => Promise; - /** Get the currently selected group */ - getSelectedGroup: () => AgentGroup | null; - /** Get the currently selected session */ - getSelectedSession: () => AgentGroupSession | null; - /** Clear error */ - clearError: () => void; -} - -type AgentGroupsStore = AgentGroupsState & AgentGroupsActions; +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- const normalize = (value: string): string => { if (!value) return ''; @@ -106,632 +19,338 @@ const normalize = (value: string): string => { return replaced.replace(/\/+$/, ''); }; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- -const startsWithDirectory = (candidate: string, root: string): boolean => { - const normalizedCandidate = normalize(candidate); - const normalizedRoot = normalize(root); - if (!normalizedCandidate || !normalizedRoot) { - return false; - } - if (normalizedCandidate === normalizedRoot) { - return true; - } - const prefix = normalizedRoot === '/' ? '/' : `${normalizedRoot}/`; - return normalizedCandidate.startsWith(prefix); -}; +export interface AgentGroupSession { + id: string; + path: string; + providerId: string; + modelId: string; + instanceNumber: number; + branch: string; + displayLabel: string; + worktreeMetadata?: WorktreeMetadata; +} -const resolveCanonicalDirectory = async ( - apiClient: ReturnType, - directory: string -): Promise => { - const normalized = normalize(directory); - if (!normalized) { - return normalized; - } - try { - const response = await apiClient.path.get({ directory: normalized }); - const canonical = normalize((response.data as { directory?: string | null } | null)?.directory ?? ''); - return canonical || normalized; - } catch { - return normalized; - } -}; +export interface AgentGroup { + name: string; + sessions: AgentGroupSession[]; + lastActive: number; + sessionCount: number; +} -const listSessionsForDirectory = async ( - apiClient: ReturnType, - directory: string -): Promise => { - const normalized = normalize(directory); - if (!normalized) { - return []; - } +export interface DeleteAgentGroupResult { + failedIds: string[]; + failedWorktreePaths: string[]; +} - const canonical = await resolveCanonicalDirectory(apiClient, normalized); +// --------------------------------------------------------------------------- +// parseSessionTitle +// --------------------------------------------------------------------------- - const filterToDirectory = (sessions: Session[]) => { - return sessions.filter((session) => { - const dir = normalize((session as { directory?: string | null }).directory ?? ''); - if (!dir) return false; - return startsWithDirectory(dir, normalized) || (canonical !== normalized && startsWithDirectory(dir, canonical)); - }); - }; - - const attemptList = async (dir: string) => { - const response = await apiClient.session.list({ directory: dir }); - return Array.isArray(response.data) ? response.data : []; - }; - - try { - const list = filterToDirectory(await attemptList(normalized)); - if (list.length > 0) { - return list; - } - } catch { - // ignore - } - - if (canonical && canonical !== normalized) { - try { - const list = filterToDirectory(await attemptList(canonical)); - if (list.length > 0) { - return list; - } - } catch { - // ignore - } - } - - try { - const global = await apiClient.session.list(undefined); - const list = Array.isArray(global.data) ? global.data : []; - return filterToDirectory(list); - } catch { - return []; - } -}; - -const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory: string): Promise> => { - const map = new Map(); - - group.sessions.forEach((session) => { - if (session.worktreeMetadata) { - map.set(normalize(session.path), session.worktreeMetadata); - } - }); - - const missingPaths = Array.from(new Set(group.sessions.map((session) => normalize(session.path)))) - .filter(Boolean) - .filter((path) => !map.has(path)); - - if (missingPaths.length === 0) { - return map; - } - - try { - const worktrees = await listProjectWorktrees({ id: `path:${projectDirectory}`, path: projectDirectory }); - const infoByPath = new Map(worktrees.map((meta) => [normalize(meta.path), meta])); - missingPaths.forEach((path) => { - const info = infoByPath.get(path); - if (info) { - map.set(path, info); - } - }); - } catch { - // ignore - } - - return map; -}; - -const collectDeleteCandidates = async (params: { - apiClient: ReturnType; - group: AgentGroup; - projectDirectory: string; - worktreePaths: string[]; -}): Promise> => { - const { apiClient, group, projectDirectory, worktreePaths } = params; - const metadataByPath = await buildWorktreeMetadataByPath(group, projectDirectory); - const sessionStore = useSessionStore.getState(); - - const uniqueWorktreePaths = Array.from(new Set(worktreePaths.map((path) => normalize(path)).filter(Boolean))); - const concurrency = 5; - let index = 0; - - const results: Array<{ worktreePath: string; sessionIds: string[]; metadata?: WorktreeMetadata }> = []; - - const worker = async () => { - while (index < uniqueWorktreePaths.length) { - const current = uniqueWorktreePaths[index]; - index += 1; - - const sessionsInGroup = group.sessions.filter((session) => normalize(session.path) === current).map((session) => session.id); - const cached = sessionStore.getSessionsByDirectory(current); - const cachedIds = Array.isArray(cached) ? cached.map((session) => session.id) : []; - - // Prefer the session store cache (already directory-partitioned). If empty, fall back to direct API listing. - let listedIds: string[] = []; - if (cachedIds.length === 0) { - try { - const listed = await listSessionsForDirectory(apiClient, current); - listedIds = listed.map((session) => session.id); - } catch { - listedIds = []; - } - } - - const ids = Array.from(new Set([...cachedIds, ...listedIds, ...sessionsInGroup].filter(Boolean))); - results.push({ - worktreePath: current, - sessionIds: ids, - metadata: metadataByPath.get(current), - }); - } - }; - - await Promise.all(Array.from({ length: Math.min(concurrency, uniqueWorktreePaths.length) }, worker)); - return results; -}; - -const deleteGroupWorktreeSessions = async (params: { - group: AgentGroup; - projectDirectory: string; - worktreePaths: string[]; -}) => { - const apiClient = opencodeClient.getApiClient(); - const candidates = await collectDeleteCandidates({ - apiClient, - group: params.group, - projectDirectory: params.projectDirectory, - worktreePaths: params.worktreePaths, - }); - - const sessionStore = useSessionStore.getState(); - const ids = new Set(); - - candidates.forEach(({ worktreePath, sessionIds, metadata }) => { - sessionIds.forEach((id) => { - ids.add(id); - if (metadata) { - sessionStore.setWorktreeMetadata(id, metadata); - sessionStore.setSessionDirectory(id, worktreePath); - } - }); - }); - - if (ids.size === 0) { - return { failedIds: [] as string[] }; - } - - return sessionStore.deleteSessions(Array.from(ids), { archiveWorktree: true, silent: true }); -}; - -/** - * Parse a session title to extract group, provider, model, and index. - * Title format: groupSlug/provider/model[/index] - * - * The groupSlug is always the first segment (cannot contain `/` as it's sanitized). - * The provider is always the second segment. - * Everything after the provider (excluding numeric index) is the model. - * Model can contain `/` for creator/model format. - * - * Examples: - * - "feature/opencode/claude-sonnet-4-5" → { groupSlug: "feature", provider: "opencode", model: "claude-sonnet-4-5", index: 1 } - * - "feature/opencode/claude-sonnet-4-1/2" → { groupSlug: "feature", provider: "opencode", model: "claude-sonnet-4-1", index: 2 } - * - "feature/openrouter/anthropic/claude-opus-4-5" → { groupSlug: "feature", provider: "openrouter", model: "anthropic/claude-opus-4-5", index: 1 } - * - "my-task/anthropic/claude-sonnet-4/1" → { groupSlug: "my-task", provider: "anthropic", model: "claude-sonnet-4", index: 1 } - */ -function parseSessionTitle(title: string | undefined): { +export function parseSessionTitle(title: string | undefined): { groupSlug: string; provider: string; model: string; index: number; } | null { if (!title) return null; - const parts = title.split('/'); if (parts.length < 3) return null; - // First part is always groupSlug (cannot contain / or spaces as it's sanitized by toGitSafeSlug) const groupSlug = parts[0]; if (!groupSlug || groupSlug.includes(' ')) return null; - // Second part is always provider const provider = parts[1]; if (!provider) return null; - // Check if last part is a numeric index const lastPart = parts[parts.length - 1]; const lastPartNum = parseInt(lastPart, 10); const hasIndex = parts.length >= 4 && !isNaN(lastPartNum) && String(lastPartNum) === lastPart; - // Model is everything from parts[2] to end (excluding index if present) - const modelParts = hasIndex - ? parts.slice(2, -1) - : parts.slice(2); + const modelParts = hasIndex ? parts.slice(2, -1) : parts.slice(2); + if (modelParts.length === 0) return null; - // Must have at least one model part - if (modelParts.length === 0) { - return null; - } - - const model = modelParts.join('/'); - - return { - groupSlug, - provider, - model, - index: hasIndex ? lastPartNum : 1, - }; + return { groupSlug, provider, model: modelParts.join('/'), index: hasIndex ? lastPartNum : 1 }; } -export const useAgentGroupsStore = create()( - devtools( - (set, get) => ({ - groups: [], - selectedGroupName: null, - selectedSessionId: null, - isLoading: false, - error: null, +// --------------------------------------------------------------------------- +// resolveProjectRef +// --------------------------------------------------------------------------- - loadGroups: async () => { - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); +function resolveProjectRef(): { id: string; path: string } | null { + const currentDirectory = useDirectoryStore.getState().currentDirectory; + const projectsState = useProjectsStore.getState(); + const activeProjectId = projectsState.activeProjectId; + const activeProjectPath = activeProjectId + ? projectsState.projects.find((p) => p.id === activeProjectId)?.path + : undefined; - if (!projectDirectory) { - set({ groups: [], isLoading: false, error: 'No project directory selected' }); + const raw = (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) + ? activeProjectPath + : currentDirectory; + + if (!raw) return null; + const path = normalize(raw); + if (!path) return null; + + const entry = projectsState.projects.find((p) => normalize(p.path) === path); + return { id: entry?.id ?? `path:${path}`, path }; +} + +function resolveProjectRefForWorktree(session: AgentGroupSession): ProjectRef | null { + const projectsState = useProjectsStore.getState(); + const projectPath = normalize(session.worktreeMetadata?.projectDirectory ?? ''); + if (projectPath) { + const project = projectsState.projects.find((entry) => normalize(entry.path) === projectPath); + return { id: project?.id ?? `path:${projectPath}`, path: projectPath }; + } + return resolveProjectRef(); +} + +// --------------------------------------------------------------------------- +// buildGroups — turns raw sessions + worktree metadata into AgentGroup[] +// --------------------------------------------------------------------------- + +function buildGroups( + sessions: Session[], + metaByPath: Map, +): AgentGroup[] { + const map = new Map(); + + for (const session of sessions) { + const parsed = parseSessionTitle(session.title); + if (!parsed) continue; + + const sessionPath = normalize(session.directory ?? ''); + const meta = metaByPath.get(sessionPath); + + const entry: AgentGroupSession = { + id: session.id, + path: sessionPath, + providerId: parsed.provider, + modelId: parsed.model, + instanceNumber: parsed.index, + branch: meta?.branch ?? '', + displayLabel: `${parsed.provider}/${parsed.model}`, + worktreeMetadata: meta, + }; + + const existing = map.get(parsed.groupSlug); + if (existing) existing.push(entry); + else map.set(parsed.groupSlug, [entry]); + } + + const groups: AgentGroup[] = []; + for (const [name, groupSessions] of map) { + const lastActive = groupSessions.reduce((max, gs) => { + const raw = sessions.find((s) => s.id === gs.id); + const t = (raw as { time?: { updated?: number | null } } | undefined)?.time?.updated ?? 0; + return Math.max(max, typeof t === 'number' ? t : 0); + }, 0); + + groupSessions.sort((a, b) => { + const p = a.providerId.localeCompare(b.providerId); + if (p !== 0) return p; + const m = a.modelId.localeCompare(b.modelId); + if (m !== 0) return m; + return a.instanceNumber - b.instanceNumber; + }); + + groups.push({ name, sessions: groupSessions, lastActive: lastActive || Date.now(), sessionCount: groupSessions.length }); + } + + groups.sort((a, b) => a.name.localeCompare(b.name)); + return groups; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +interface AgentGroupsState { + groups: AgentGroup[]; + selectedGroupName: string | null; + selectedSessionId: string | null; + isLoading: boolean; + error: string | null; +} + +interface AgentGroupsActions { + /** List worktrees, fetch sessions per worktree, build groups. */ + loadGroups: () => Promise; + selectGroup: (groupName: string | null) => void; + selectSession: (sessionId: string | null) => void; + deleteGroupSessions: (sessions: AgentGroupSession[], options?: { removeWorktrees?: boolean }) => Promise; + clearError: () => void; +} + +type Store = AgentGroupsState & AgentGroupsActions; + +export const useAgentGroupsStore = create()( + (set, get) => ({ + groups: [], + selectedGroupName: null, + selectedSessionId: null, + isLoading: false, + error: null, + + loadGroups: async () => { + const projectRef = resolveProjectRef(); + if (!projectRef) { + set({ groups: [], isLoading: false, error: 'No project directory' }); + return; + } + + set({ isLoading: true, error: null }); + + try { + // 1. List worktrees (already cached 30s by worktreeManager) + const worktrees = await listProjectWorktrees(projectRef); + const metaByPath = new Map(); + const dirs: string[] = []; + for (const meta of worktrees) { + if (meta?.path) { + const key = normalize(meta.path); + dirs.push(key); + metaByPath.set(key, meta); + } + } + + if (dirs.length === 0) { + set({ groups: [], isLoading: false }); return; } - const normalizedProject = normalize(projectDirectory); + // 2. Fetch sessions for each worktree directory (parallel, max 5) + const api = opencodeClient.getApiClient(); + const allSessions: Session[] = []; + const failedDirectories = new Set(); - const projectsState = useProjectsStore.getState(); - const projectEntry = projectsState.projects.find((p) => normalize(p.path) === normalizedProject); - const projectRef = { - id: projectEntry?.id ?? `path:${normalizedProject}`, - path: normalizedProject, + const fetchDir = async (dir: string) => { + try { + const res = await retry(async () => { + const result = await api.session.list({ directory: dir }); + if ((result as { error?: unknown }).error) { + throw new Error(`session.list failed for ${dir}: ${String((result as { error?: unknown }).error)}`); + } + return result; + }); + const list = Array.isArray(res.data) ? res.data : []; + for (const s of list) if (s?.id) allSessions.push(s); + } catch { + failedDirectories.add(dir); + } }; - const previousGroups = get().groups; - set({ isLoading: true, error: null }); - - try { - const apiClient = opencodeClient.getApiClient(); - const canonicalProject = await resolveCanonicalDirectory(apiClient, normalizedProject); - const canonicalRef = canonicalProject && canonicalProject !== normalizedProject - ? { ...projectRef, path: canonicalProject } - : null; - - const managedWorktrees = await listProjectWorktrees(projectRef).catch(() => []); - const managedWorktreesCanonical = canonicalRef - ? await listProjectWorktrees(canonicalRef).catch(() => []) - : []; - - const worktreeDirectorySet = new Set(); - const worktreeMetadataMap = new Map(); - [...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => { - if (meta?.path) { - const key = normalize(meta.path); - worktreeDirectorySet.add(key); - if (!worktreeMetadataMap.has(key)) { - worktreeMetadataMap.set(key, meta); - } - } - }); - - const fetchCandidateSessions = async (): Promise => { - try { - const scoped = await apiClient.session.list({ directory: normalizedProject }); - const list = Array.isArray(scoped.data) ? scoped.data : []; - if (list.some((session) => { - const dir = normalize((session as { directory?: string | null }).directory ?? ''); - return dir ? worktreeDirectorySet.has(dir) : false; - })) { - return list; - } - } catch { - // ignore and fall back to global list - } - - const global = await apiClient.session.list(undefined); - return Array.isArray(global.data) ? global.data : []; - }; - - const fetchSessionsByWorktreeDirectories = async (directories: string[]): Promise => { - const sessionsMap = new Map(); - const concurrency = 5; - let index = 0; - - const worker = async () => { - while (index < directories.length) { - const current = directories[index]; - index += 1; - const normalizedDir = normalize(current); - if (!normalizedDir) continue; - - try { - const sessions = await listSessionsForDirectory(apiClient, normalizedDir); - sessions.forEach((session) => sessionsMap.set(session.id, session)); - } catch (err) { - console.debug('Failed to fetch sessions from worktree:', normalizedDir, err); - } - } - }; - - await Promise.all(Array.from({ length: Math.min(concurrency, directories.length) }, worker)); - return Array.from(sessionsMap.values()); - }; - - const candidateSessions = await fetchCandidateSessions(); - let allSessions = candidateSessions.filter((session) => { - const dir = normalize((session as { directory?: string | null }).directory ?? ''); - if (!dir) { - return false; - } - return worktreeDirectorySet.has(dir); - }); - - // Some OpenCode builds do not return sessions across directories in the global list. - // If we didn't discover any group sessions, fall back to querying each worktree directory directly. - if (allSessions.length === 0) { - const candidates = new Set(); - - // 1) Known worktree directories for this project - worktreeDirectorySet.forEach((dir) => candidates.add(dir)); - - if (candidates.size > 0) { - allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates)); - } + // Simple concurrency limiter + let idx = 0; + const worker = async () => { + while (idx < dirs.length) { + const i = idx++; + await fetchDir(dirs[i]); } + }; + await Promise.all(Array.from({ length: Math.min(5, dirs.length) }, () => worker())); - const sessionUpdatedAtById = new Map(); - for (const session of allSessions) { - const updatedAt = (session as { time?: { updated?: number | null } }).time?.updated ?? 0; - sessionUpdatedAtById.set(session.id, typeof updatedAt === 'number' ? updatedAt : 0); - } - - // Parse sessions and group by groupSlug - const groupsMap = new Map(); - - for (const session of allSessions) { - const parsed = parseSessionTitle(session.title); - if (!parsed) continue; // Skip sessions without valid agent group title - - const sessionPath = normalize(session.directory); - const worktreeInfo = worktreeMetadataMap.get(sessionPath); - - const agentSession: AgentGroupSession = { - id: session.id, - path: sessionPath, - providerId: parsed.provider, - modelId: parsed.model, - instanceNumber: parsed.index, - branch: worktreeInfo?.branch ?? '', - displayLabel: `${parsed.provider}/${parsed.model}`, - worktreeMetadata: worktreeInfo, - }; - - const existing = groupsMap.get(parsed.groupSlug); - if (existing) { - existing.push(agentSession); - } else { - groupsMap.set(parsed.groupSlug, [agentSession]); - } - } - - // Convert map to array and sort - const groups: AgentGroup[] = Array.from(groupsMap.entries()).map( - ([name, sessions]) => { - // Find the most recent session update time for lastActive - const lastActive = sessions.reduce((max, s) => { - const updatedTime = sessionUpdatedAtById.get(s.id) ?? 0; - return Math.max(max, updatedTime); - }, 0); - - return { - name, - sessions: sessions.sort((a, b) => { - // Sort by provider, then model, then instance - const providerCmp = a.providerId.localeCompare(b.providerId); - if (providerCmp !== 0) return providerCmp; - const modelCmp = a.modelId.localeCompare(b.modelId); - if (modelCmp !== 0) return modelCmp; - return a.instanceNumber - b.instanceNumber; - }), - lastActive: lastActive || Date.now(), - sessionCount: sessions.length, - }; - } - ); - - // Sort groups by name - groups.sort((a, b) => a.name.localeCompare(b.name)); - - set({ groups, isLoading: false, error: null }); - } catch (err) { - console.error('Failed to load agent groups:', err); - // Preserve existing groups on error to avoid UI flickering - set({ - groups: previousGroups.length > 0 ? previousGroups : [], - isLoading: false, - error: err instanceof Error ? err.message : 'Failed to load agent groups', - }); - } - }, - - selectGroup: (groupName) => { - const { groups } = get(); - const group = groups.find((g) => g.name === groupName); - + // 3. Build groups + const groups = buildGroups(allSessions, metaByPath); set({ - selectedGroupName: groupName, - // Auto-select first session when selecting a group - selectedSessionId: group?.sessions[0]?.id ?? null, + groups, + isLoading: false, + error: failedDirectories.size > 0 ? `Failed to load sessions for ${failedDirectories.size} worktree${failedDirectories.size === 1 ? '' : 's'}` : null, }); - }, + } catch (err) { + set({ + groups: get().groups, // preserve on error + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to load groups', + }); + } + }, - selectSession: (sessionId) => { - set({ selectedSessionId: sessionId }); - }, + selectGroup: (groupName) => { + if (!groupName) { + set({ selectedGroupName: null, selectedSessionId: null }); + return; + } + const group = get().groups.find((g) => g.name === groupName); + set({ + selectedGroupName: groupName, + selectedSessionId: group?.sessions[0]?.id ?? null, + }); + }, - deleteGroup: async (groupName) => { - const group = get().groups.find((g) => g.name === groupName); - if (!group) { - return false; + selectSession: (sessionId) => set({ selectedSessionId: sessionId }), + + deleteGroupSessions: async (sessions, options) => { + const failedIds: string[] = []; + const failedWorktreePaths: string[] = []; + const removeWorktrees = options?.removeWorktrees === true; + const deletedIds = new Set(); + + for (const s of sessions) { + if (!s.path) continue; + const ok = await deleteSessionInDirectory(s.id, s.path); + if (!ok) failedIds.push(s.id); + else deletedIds.add(s.id); + } + + if (removeWorktrees) { + const worktreesByPath = new Map(); + for (const session of sessions) { + const path = normalize(session.path); + if (!path) continue; + const existing = worktreesByPath.get(path); + if (existing) existing.push(session); + else worktreesByPath.set(path, [session]); } - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); - if (!projectDirectory) { - set({ error: 'No project directory selected' }); - return false; - } - - set({ isLoading: true, error: null }); - try { - const { failedIds } = await deleteGroupWorktreeSessions({ - group, - projectDirectory: normalize(projectDirectory), - worktreePaths: group.sessions.map((s) => s.path), - }); - if (failedIds.length > 0) { - set({ error: 'Failed to delete some sessions' }); + for (const [path, pathSessions] of worktreesByPath) { + if (pathSessions.some((session) => failedIds.includes(session.id))) { + failedWorktreePaths.push(path); + continue; } - if (get().selectedGroupName === groupName) { - set({ selectedGroupName: null, selectedSessionId: null }); + const source = pathSessions.find((session) => session.worktreeMetadata)?.worktreeMetadata ?? pathSessions[0]?.worktreeMetadata; + const projectRef = pathSessions.map(resolveProjectRefForWorktree).find((value): value is ProjectRef => value !== null) ?? null; + if (!source || !projectRef) { + failedWorktreePaths.push(path); + continue; } - await get().loadGroups(); - return failedIds.length === 0; - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to delete group' }); - return false; - } finally { - set({ isLoading: false }); - } - }, - - deleteGroupWorktree: async (groupName, worktreePath) => { - const group = get().groups.find((g) => g.name === groupName); - if (!group) { - return false; - } - const normalizedWorktreePath = normalize(worktreePath); - if (!normalizedWorktreePath) { - return false; - } - - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); - if (!projectDirectory) { - set({ error: 'No project directory selected' }); - return false; - } - - set({ isLoading: true, error: null }); - try { - const { failedIds } = await deleteGroupWorktreeSessions({ - group, - projectDirectory: normalize(projectDirectory), - worktreePaths: [normalizedWorktreePath], - }); - if (failedIds.length > 0) { - set({ error: 'Failed to delete some sessions' }); - } - - await get().loadGroups(); - - const updated = get().groups.find((g) => g.name === groupName); - if (!updated) { - if (get().selectedGroupName === groupName) { - set({ selectedGroupName: null, selectedSessionId: null }); + try { + await removeProjectWorktree(projectRef, source, { deleteLocalBranch: true }); + const directoryStore = useDirectoryStore.getState(); + if (normalize(directoryStore.currentDirectory) === path) { + directoryStore.setDirectory(projectRef.path, { showOverlay: false }); } - return failedIds.length === 0; + } catch { + failedWorktreePaths.push(path); } - - if (get().selectedGroupName === groupName) { - const currentSelected = get().selectedSessionId; - const remainingIds = new Set(updated.sessions.map((s) => s.id)); - if (!currentSelected || !remainingIds.has(currentSelected)) { - set({ selectedSessionId: updated.sessions[0]?.id ?? null }); - } - } - - return failedIds.length === 0; - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to delete worktree' }); - return false; - } finally { - set({ isLoading: false }); } - }, + } - keepOnlyGroupWorktree: async (groupName, keepWorktreePath) => { - const group = get().groups.find((g) => g.name === groupName); - if (!group) { - return false; - } - const keepPath = normalize(keepWorktreePath); - if (!keepPath) { - return false; + // Clear selection if needed + const { selectedSessionId, selectedGroupName } = get(); + if (selectedSessionId && deletedIds.has(selectedSessionId)) { + set({ selectedSessionId: null }); + } + if (selectedGroupName) { + const group = get().groups.find((g) => g.name === selectedGroupName); + if (group && group.sessions.every((s) => deletedIds.has(s.id))) { + set({ selectedGroupName: null, selectedSessionId: null }); } + } - const worktreePaths = Array.from(new Set(group.sessions.map((s) => normalize(s.path)).filter(Boolean))); - const toDelete = worktreePaths.filter((path) => path !== keepPath); - if (toDelete.length === 0) { - return true; - } + // Refresh groups after delete + void get().loadGroups(); - const currentDirectory = useDirectoryStore.getState().currentDirectory; - const projectDirectory = resolveProjectDirectory(currentDirectory); - if (!projectDirectory) { - set({ error: 'No project directory selected' }); - return false; - } + return { failedIds, failedWorktreePaths }; + }, - set({ isLoading: true, error: null }); - try { - const { failedIds } = await deleteGroupWorktreeSessions({ - group, - projectDirectory: normalize(projectDirectory), - worktreePaths: toDelete, - }); - if (failedIds.length > 0) { - set({ error: 'Failed to delete some sessions' }); - } - - await get().loadGroups(); - if (get().selectedGroupName === groupName) { - const updated = get().groups.find((g) => g.name === groupName); - const keepSession = updated?.sessions.find((s) => normalize(s.path) === keepPath) ?? updated?.sessions[0] ?? null; - set({ selectedSessionId: keepSession?.id ?? null }); - } - return failedIds.length === 0; - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to remove other worktrees' }); - return false; - } finally { - set({ isLoading: false }); - } - }, - - getSelectedGroup: () => { - const { groups, selectedGroupName } = get(); - if (!selectedGroupName) return null; - return groups.find((g) => g.name === selectedGroupName) ?? null; - }, - - getSelectedSession: () => { - const { selectedSessionId } = get(); - const group = get().getSelectedGroup(); - if (!group || !selectedSessionId) return null; - return group.sessions.find((s) => s.id === selectedSessionId) ?? null; - }, - - clearError: () => { - set({ error: null }); - }, - }), - { name: 'agent-groups-store' } - ) + clearError: () => set({ error: null }), + }), ); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index e4cfc296..e965bec0 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -6,8 +6,9 @@ import { opencodeClient } from "@/lib/opencode/client"; import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import type { ModelMetadata } from "@/types"; import { getSafeStorage } from "./utils/safeStorage"; -import type { SessionStore } from "./types/sessionTypes"; import { filterVisibleAgents } from "./useAgentsStore"; +import { useSessionUIStore } from "@/sync/session-ui-store"; +import { useSelectionStore } from "@/sync/selection-store"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; import { updateDesktopSettings } from "@/lib/persistence"; import { useDirectoryStore } from "@/stores/useDirectoryStore"; @@ -529,10 +530,13 @@ interface ConfigStore { declare global { interface Window { __zustand_config_store__?: UseBoundStore>; - __zustand_session_store__?: UseBoundStore>; } } +// In-flight dedup: prevent concurrent duplicate loadProviders/loadAgents calls for the same directory +const _inFlightProviders = new Map>(); +const _inFlightAgents = new Map>(); + export const useConfigStore = create()( devtools( persist( @@ -724,6 +728,12 @@ export const useConfigStore = create()( loadProviders: async (options) => { const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + + // Dedup: if a load is already in-flight for this directory, reuse it + const existing = _inFlightProviders.get(directoryKey); + if (existing) return existing; + + const promise = (async () => { const existingSnapshot = get().directoryScoped[directoryKey]; const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []); const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {}); @@ -872,6 +882,10 @@ export const useConfigStore = create()( return nextState; }); + })().finally(() => _inFlightProviders.delete(directoryKey)); + + _inFlightProviders.set(directoryKey, promise); + return promise; }, setProvider: (providerId: string) => { @@ -1082,6 +1096,12 @@ export const useConfigStore = create()( loadAgents: async (options) => { const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + + // Dedup: if a load is already in-flight for this directory, reuse it + const existing = _inFlightAgents.get(directoryKey); + if (existing) return existing; + + const promise = (async (): Promise => { const existingSnapshot = get().directoryScoped[directoryKey]; const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []); let lastError: unknown = null; @@ -1392,6 +1412,10 @@ export const useConfigStore = create()( }); return false; + })().finally(() => _inFlightAgents.delete(directoryKey)); + + _inFlightAgents.set(directoryKey, promise); + return promise; }, setAgent: (agentName: string | undefined) => { @@ -1424,44 +1448,29 @@ export const useConfigStore = create()( }; }); - if (agentName && typeof window !== "undefined") { + if (agentName) { + const { currentSessionId } = useSessionUIStore.getState(); + const selState = useSelectionStore.getState(); - const sessionStore = window.__zustand_session_store__; - if (sessionStore) { - const sessionState = sessionStore.getState(); - const { currentSessionId, isOpenChamberCreatedSession, initializeNewOpenChamberSession, getAgentModelForSession } = sessionState; + if (currentSessionId) { + selState.saveSessionAgentSelection(currentSessionId, agentName); + } - if (currentSessionId) { - - sessionStore.setState((state) => { - const newAgentContext = new Map(state.currentAgentContext); - newAgentContext.set(currentSessionId, agentName); - return { currentAgentContext: newAgentContext }; - }); - } - - if (currentSessionId && isOpenChamberCreatedSession(currentSessionId)) { - const existingAgentModel = getAgentModelForSession(currentSessionId, agentName); - if (!existingAgentModel) { - - initializeNewOpenChamberSession(currentSessionId, agents); - } + if (currentSessionId && useSessionUIStore.getState().isOpenChamberCreatedSession(currentSessionId)) { + const existingAgentModel = selState.getAgentModelForSession(currentSessionId, agentName); + if (!existingAgentModel) { + useSessionUIStore.getState().initializeNewOpenChamberSession(currentSessionId, agents); } } } - if (agentName && typeof window !== "undefined") { - const sessionStore = window.__zustand_session_store__; - if (sessionStore?.getState) { - const { currentSessionId, getAgentModelForSession } = sessionStore.getState(); + if (agentName) { + const { currentSessionId } = useSessionUIStore.getState(); - if (currentSessionId) { - const existingAgentModel = getAgentModelForSession(currentSessionId, agentName); - - if (existingAgentModel) { - - return; - } + if (currentSessionId) { + const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); + if (existingAgentModel) { + return; } } @@ -1792,9 +1801,7 @@ export const useConfigStore = create()( return undefined; } - const derived = deriveModelMetadata(providerId, model); - set({ modelsMetadata: new Map(modelsMetadata).set(key, derived) }); - return derived; + return deriveModelMetadata(providerId, model); }, getVisibleAgents: () => { const { agents } = get(); diff --git a/packages/ui/src/stores/useGitHubAuthStore.ts b/packages/ui/src/stores/useGitHubAuthStore.ts index 811fd797..1cbc24ad 100644 --- a/packages/ui/src/stores/useGitHubAuthStore.ts +++ b/packages/ui/src/stores/useGitHubAuthStore.ts @@ -33,6 +33,9 @@ const fetchStatus = async ( return payload; }; +// In-flight dedup for refreshStatus +let _inFlightAuthRefresh: Promise | null = null; + export const useGitHubAuthStore = create((set, get) => ({ status: null, isLoading: false, @@ -44,19 +47,25 @@ export const useGitHubAuthStore = create((set, get) => ({ return status; } + if (_inFlightAuthRefresh) return _inFlightAuthRefresh; + set({ isLoading: true }); - try { - const payload = await fetchStatus(runtimeGitHub); - set({ status: payload, isLoading: false, hasChecked: true }); - return payload; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - set({ - status: { connected: false, error: message }, - isLoading: false, - hasChecked: true, - }); - return null; - } + _inFlightAuthRefresh = (async () => { + try { + const payload = await fetchStatus(runtimeGitHub); + set({ status: payload, isLoading: false, hasChecked: true }); + return payload; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + set({ + status: { connected: false, error: message }, + isLoading: false, + hasChecked: true, + }); + return null; + } + })().finally(() => { _inFlightAuthRefresh = null; }); + + return _inFlightAuthRefresh; }, })); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index b626592b..4544ffd0 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -16,8 +16,9 @@ const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; const DIFF_PREFETCH_MAX_FILES = 25; const DIFF_PREFETCH_FOCUS_MAX_FILES = 40; -const DIFF_PREFETCH_CONCURRENCY = 4; +const DIFF_PREFETCH_CONCURRENCY = 2; const DIFF_PREFETCH_TIMEOUT_MS = 15000; +const DIFF_PREFETCH_LARGE_FILE_THRESHOLD = 500; // skip prefetch for files with >500 changed lines const RECENT_DIRECTORIES_LIMIT = 3; // Diff cache limits to prevent memory bloat with many modified files @@ -57,7 +58,7 @@ interface GitStore { setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; - fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean }) => Promise; + fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise; fetchBranches: (directory: string, git: GitAPI) => Promise; fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise; fetchIdentity: (directory: string, git: GitAPI) => Promise; @@ -87,7 +88,7 @@ interface GitFileDiffResponse { interface GitAPI { checkIsGitRepository: (directory: string) => Promise; - getGitStatus: (directory: string) => Promise; + getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise; getGitBranches: (directory: string) => Promise; getGitLog: (directory: string, options?: { maxCount?: number }) => Promise; getCurrentGitIdentity: (directory: string) => Promise; @@ -216,7 +217,8 @@ const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | nu } } - if (haveDiffStatsChanged(oldStatus.diffStats, newStatus.diffStats)) return true; + // Skip diffStats comparison when light mode omits them (undefined) + if (newStatus.diffStats !== undefined && haveDiffStatsChanged(oldStatus.diffStats, newStatus.diffStats)) return true; return false; }; @@ -249,21 +251,24 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus | } } - const oldStats = oldStatus?.diffStats ?? {}; - const newStats = newStatus.diffStats ?? {}; - const allStatPaths = new Set([...Object.keys(oldStats), ...Object.keys(newStats)]); + // Only compare diffStats when light mode provides them (non-undefined) + if (newStatus.diffStats !== undefined) { + const oldStats = oldStatus?.diffStats ?? {}; + const newStats = newStatus.diffStats ?? {}; + const allStatPaths = new Set([...Object.keys(oldStats), ...Object.keys(newStats)]); - for (const filePath of allStatPaths) { - const oldEntry = oldStats[filePath]; - const newEntry = newStats[filePath]; + for (const filePath of allStatPaths) { + const oldEntry = oldStats[filePath]; + const newEntry = newStats[filePath]; - if (!oldEntry || !newEntry) { - changed.add(filePath); - continue; - } + if (!oldEntry || !newEntry) { + changed.add(filePath); + continue; + } - if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) { - changed.add(filePath); + if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) { + changed.add(filePath); + } } } @@ -371,7 +376,7 @@ export const useGitStore = create()( return false; } - const newStatus = await git.getGitStatus(directory); + const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined); if (hasStatusChanged(dirState.status, newStatus)) { statusChanged = true; @@ -402,10 +407,15 @@ export const useGitStore = create()( bumpDiffFetchGeneration(directory); } + // Preserve diffStats from previous status when light mode returns none + const mergedStatus = newStatus.diffStats === undefined && currentDirState.status?.diffStats + ? { ...newStatus, diffStats: currentDirState.status.diffStats } + : newStatus; + newDirectories.set(directory, { ...currentDirState, isGitRepo: true, - status: newStatus, + status: mergedStatus, diffCache: nextDiffCache, lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt, lastStatusFetch: Date.now(), @@ -534,8 +544,7 @@ export const useGitStore = create()( await get().fetchIdentity(directory, git); - // Pre-fetch all diffs so they're ready when user opens Diff tab - void get().fetchAllDiffs(directory, git); + // Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch) }, @@ -581,6 +590,7 @@ export const useGitStore = create()( const { maxFiles = DIFF_PREFETCH_FOCUS_MAX_FILES } = options; const availablePaths = new Set(dirState.status.files.map((file) => file.path)); + const diffStats = dirState.status.diffStats; const inFlight = getInFlightDiffs(directory); const dedupedPaths: string[] = []; @@ -599,6 +609,11 @@ export const useGitStore = create()( if (inFlight.has(filePath)) { continue; } + // Skip large files during prefetch — they'll be fetched on-demand when user clicks + const stats = diffStats?.[filePath]; + if (stats && (stats.insertions + stats.deletions) > DIFF_PREFETCH_LARGE_FILE_THRESHOLD) { + continue; + } dedupedPaths.push(filePath); } @@ -731,18 +746,24 @@ export const useGitStore = create()( let anyStatusChanged = false; + const heavyFollowUps: string[] = []; for (const targetDirectory of pollTargets) { - const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true }); + const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true, mode: 'light' }); if (statusChanged) { anyStatusChanged = true; + heavyFollowUps.push(targetDirectory); if (targetDirectory === activeDirectory) { await get().fetchLog(activeDirectory, git); - // Pre-fetch all diffs so they're ready when user opens Diff tab - void get().fetchAllDiffs(activeDirectory, git); + // Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch) } } } + // Light mode detected real changes — follow up with heavy fetch for diffStats + for (const dir of heavyFollowUps) { + get().fetchStatus(dir, git, { silent: true }); + } + const bounds = getPollingBounds(get().pollingMode); if (anyStatusChanged) { // Reset to base interval on changes diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts new file mode 100644 index 00000000..d78b1bd0 --- /dev/null +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -0,0 +1,308 @@ +import { create } from 'zustand'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { opencodeClient } from '@/lib/opencode/client'; +import { listGlobalSessionPages } from '@/stores/globalSessions'; + +type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error'; + +type LoadResult = { + activeSessions: Session[]; + archivedSessions: Session[]; +}; + +type GlobalSessionsState = { + activeSessions: Session[]; + archivedSessions: Session[]; + sessionsByDirectory: Map; + hasLoaded: boolean; + status: GlobalSessionsStatus; + loadSessions: (fallbackActive?: Session[]) => Promise; + applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void; + upsertSession: (session: Session) => void; + removeSessions: (ids: Iterable) => void; + archiveSessions: (ids: Iterable, archivedAt?: number) => void; +}; + +const PAGE_SIZE = 200; + +let inflightLoad: Promise | null = null; + +const normalizePath = (value?: string | null): string | null => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const replaced = trimmed.replace(/\\/g, '/'); + if (replaced === '/') { + return '/'; + } + return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; +}; + +export const resolveGlobalSessionDirectory = (session: Session): string | null => { + const record = session as Session & { + directory?: string | null; + project?: { worktree?: string | null } | null; + }; + + return normalizePath(record.directory ?? null) + ?? normalizePath(record.project?.worktree ?? null); +}; + +const buildSessionsByDirectory = (sessions: Session[]): Map => { + const next = new Map(); + for (const session of sessions) { + const directory = resolveGlobalSessionDirectory(session); + if (!directory) { + continue; + } + const existing = next.get(directory); + if (existing) { + existing.push(session); + continue; + } + next.set(directory, [session]); + } + return next; +}; + +const getSessionSignature = (session: Session): string => { + return [ + session.id, + session.title ?? '', + session.time?.created ?? 0, + session.time?.updated ?? 0, + session.time?.archived ?? 0, + session.share ? 1 : 0, + resolveGlobalSessionDirectory(session) ?? '', + ].join(':'); +}; + +const sameSessionList = (prev: Session[], next: Session[]): boolean => { + if (prev === next) { + return true; + } + if (prev.length !== next.length) { + return false; + } + for (let index = 0; index < prev.length; index += 1) { + if (getSessionSignature(prev[index]) !== getSessionSignature(next[index])) { + return false; + } + } + return true; +}; + +const upsertSessionIntoList = (sessions: Session[], session: Session): Session[] => { + const index = sessions.findIndex((candidate) => candidate.id === session.id); + if (index === -1) { + return [session, ...sessions]; + } + if (getSessionSignature(sessions[index]) === getSessionSignature(session)) { + return sessions; + } + const next = [...sessions]; + next[index] = session; + return next; +}; + +const applySnapshot = ( + state: GlobalSessionsState, + activeSessions: Session[], + archivedSessions: Session[], + status: GlobalSessionsStatus, +): Partial | GlobalSessionsState => { + const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions) + ? state.activeSessions + : activeSessions; + const nextArchivedSessions = sameSessionList(state.archivedSessions, archivedSessions) + ? state.archivedSessions + : archivedSessions; + const nextSessionsByDirectory = nextActiveSessions === state.activeSessions + ? state.sessionsByDirectory + : buildSessionsByDirectory(nextActiveSessions); + + if ( + nextActiveSessions === state.activeSessions + && nextArchivedSessions === state.archivedSessions + && nextSessionsByDirectory === state.sessionsByDirectory + && state.hasLoaded + && state.status === status + ) { + return state; + } + + return { + activeSessions: nextActiveSessions, + archivedSessions: nextArchivedSessions, + sessionsByDirectory: nextSessionsByDirectory, + hasLoaded: true, + status, + }; +}; + +export const useGlobalSessionsStore = create((set, get) => ({ + activeSessions: [], + archivedSessions: [], + sessionsByDirectory: new Map(), + hasLoaded: false, + status: 'idle', + + applySnapshot: (activeSessions, archivedSessions, status = 'ready') => { + set((state) => applySnapshot(state, activeSessions, archivedSessions, status)); + }, + + loadSessions: async (fallbackActive) => { + if (inflightLoad) { + return inflightLoad; + } + + set((state) => (state.status === 'loading' ? state : { status: 'loading' })); + + inflightLoad = (async () => { + const current = get(); + + try { + const sdk = opencodeClient.getSdkClient(); + const [activeResult, archivedResult] = await Promise.allSettled([ + listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }), + listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }), + ]); + + const nextActiveSessions = activeResult.status === 'fulfilled' + ? activeResult.value + : (fallbackActive ?? current.activeSessions); + const nextArchivedSessions = archivedResult.status === 'fulfilled' + ? archivedResult.value + : current.archivedSessions; + + if (activeResult.status === 'rejected') { + console.warn('[GlobalSessions] Failed to load active sessions, using fallback:', activeResult.reason); + } + if (archivedResult.status === 'rejected') { + console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason); + } + + set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready')); + return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions }; + } catch (error) { + const nextActiveSessions = fallbackActive ?? current.activeSessions; + const nextArchivedSessions = current.archivedSessions; + console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error); + set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'error')); + return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions }; + } finally { + inflightLoad = null; + } + })(); + + return inflightLoad; + }, + + upsertSession: (session) => { + set((state) => { + const isArchived = Boolean(session.time?.archived); + const nextActiveSessions = isArchived + ? state.activeSessions.filter((candidate) => candidate.id !== session.id) + : upsertSessionIntoList(state.activeSessions, session); + const nextArchivedSessions = isArchived + ? upsertSessionIntoList(state.archivedSessions, session) + : state.archivedSessions.filter((candidate) => candidate.id !== session.id); + + if ( + nextActiveSessions === state.activeSessions + && nextArchivedSessions === state.archivedSessions + ) { + return state; + } + + return { + activeSessions: nextActiveSessions, + archivedSessions: nextArchivedSessions, + sessionsByDirectory: nextActiveSessions === state.activeSessions + ? state.sessionsByDirectory + : buildSessionsByDirectory(nextActiveSessions), + }; + }); + }, + + removeSessions: (ids) => { + const idSet = ids instanceof Set ? ids : new Set(ids); + if (idSet.size === 0) { + return; + } + + set((state) => { + const nextActiveSessions = state.activeSessions.filter((session) => !idSet.has(session.id)); + const nextArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id)); + + if ( + nextActiveSessions.length === state.activeSessions.length + && nextArchivedSessions.length === state.archivedSessions.length + ) { + return state; + } + + return { + activeSessions: nextActiveSessions, + archivedSessions: nextArchivedSessions, + sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), + }; + }); + }, + + archiveSessions: (ids, archivedAt = Date.now()) => { + const idSet = ids instanceof Set ? ids : new Set(ids); + if (idSet.size === 0) { + return; + } + + set((state) => { + const movedSessions: Session[] = []; + const nextActiveSessions = state.activeSessions.filter((session) => { + if (!idSet.has(session.id)) { + return true; + } + + movedSessions.push({ + ...session, + time: { + ...session.time, + archived: archivedAt, + }, + }); + return false; + }); + + if (movedSessions.length === 0) { + return state; + } + + const remainingArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id)); + + return { + activeSessions: nextActiveSessions, + archivedSessions: [...movedSessions, ...remainingArchivedSessions], + sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), + }; + }); + }, +})); + +export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise => { + const state = useGlobalSessionsStore.getState(); + if (state.hasLoaded && state.status !== 'error') { + return { + activeSessions: state.activeSessions, + archivedSessions: state.archivedSessions, + }; + } + return state.loadSessions(fallbackActive); +}; + +export const refreshGlobalSessions = async (fallbackActive?: Session[]): Promise => { + return useGlobalSessionsStore.getState().loadSessions(fallbackActive); +}; diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 77ecf9bb..10fd12c3 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { devtools } from 'zustand/middleware'; import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun'; import { opencodeClient } from '@/lib/opencode/client'; @@ -7,7 +8,7 @@ import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { checkIsGitRepository } from '@/lib/gitApi'; -import { useSessionStore } from './sessionStore'; +// sessionStore removed — sync bootstrap handles session loading import { useDirectoryStore } from './useDirectoryStore'; import { useProjectsStore } from './useProjectsStore'; @@ -186,7 +187,7 @@ export const useMultiRunStore = create()( () => opencodeClient.createSession({ title: sessionTitle }) ); - useSessionStore.getState().setWorktreeMetadata(session.id, enrichedMetadata); + useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata); createdRuns.push({ sessionId: session.id, @@ -228,12 +229,7 @@ export const useMultiRunStore = create()( url: f.url, })); - // Refresh sessions list so sidebar shows the new sessions immediately - try { - await useSessionStore.getState().loadSessions(); - } catch { - // Ignore refresh errors - } + // Session list refresh handled by sync bootstrap via SSE events // Setup commands run via SDK worktree startCommand. diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts deleted file mode 100644 index fb4eba56..00000000 --- a/packages/ui/src/stores/useSessionStore.ts +++ /dev/null @@ -1,1484 +0,0 @@ -import { create } from "zustand"; -import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools } from "zustand/middleware"; -import type { Session, Message, Part } from "@opencode-ai/sdk/v2"; -import type { PermissionRequest, PermissionResponse } from "@/types/permission"; -import type { QuestionRequest } from "@/types/question"; -import type { SessionStore, AttachedFile, EditPermissionMode, SyntheticContextPart } from "./types/sessionTypes"; - -import { useSessionStore as useSessionManagementStore } from "./sessionStore"; -import { useMessageStore } from "./messageStore"; -import { useFileStore } from "./fileStore"; -import { useContextStore } from "./contextStore"; -import { usePermissionStore } from "./permissionStore"; -import { useQuestionStore } from "./questionStore"; -import { opencodeClient } from "@/lib/opencode/client"; -import { useDirectoryStore } from "./useDirectoryStore"; -import { useConfigStore } from "./useConfigStore"; -import { useProjectsStore } from "./useProjectsStore"; -import { useSessionFoldersStore } from "./useSessionFoldersStore"; -import { getSafeStorage } from "./utils/safeStorage"; -import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta"; -import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"; -import { flattenAssistantTextParts } from "@/lib/messages/messageText"; -import { normalizeMessageRecordsForProjection } from "./utils/messageProjectors"; -import type { ProjectEntry } from "@/lib/api/types"; -import type { WorktreeMetadata } from "@/types/worktree"; -import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"; -import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"; - -export type { AttachedFile, EditPermissionMode }; -export { MEMORY_LIMITS, ACTIVE_SESSION_WINDOW } from "./types/sessionTypes"; - -declare global { - interface Window { - __zustand_session_store__?: UseBoundStore>; - } -} - -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const replaced = trimmed.replace(/\\/g, "/"); - if (replaced === "/") { - return "/"; - } - return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; -}; - -const sessionChoiceAnalysisSignature = new Map(); -const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget"; - -type PersistedDraftTarget = { - projectId: string | null; - directory: string | null; -}; - -const safeStorage = getSafeStorage(); - -const readPersistedDraftTarget = (): PersistedDraftTarget | null => { - try { - const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown }; - return { - projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null, - directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null), - }; - } catch { - return null; - } -}; - -const persistDraftTarget = (target: PersistedDraftTarget): void => { - try { - safeStorage.setItem(DRAFT_TARGET_STORAGE_KEY, JSON.stringify(target)); - } catch { - // ignored - } -}; - -const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => { - const normalizedDirectory = normalizePath(directory); - if (!normalizedDirectory) { - return null; - } - - let bestMatch: ProjectEntry | null = null; - for (const project of projects) { - const projectPath = normalizePath(project.path); - if (!projectPath) { - continue; - } - const isExact = normalizedDirectory === projectPath; - const isNested = normalizedDirectory.startsWith(`${projectPath}/`); - if (!isExact && !isNested) { - continue; - } - if (!bestMatch || projectPath.length > (normalizePath(bestMatch.path)?.length ?? 0)) { - bestMatch = project; - } - } - return bestMatch; -}; - -const resolveProjectFromWorktreeDirectory = ( - projects: ProjectEntry[], - availableWorktreesByProject: Map, - directory: string | null, -): ProjectEntry | null => { - const normalizedDirectory = normalizePath(directory); - if (!normalizedDirectory) { - return null; - } - - let matchedWorktree: WorktreeMetadata | null = null; - let matchedProjectPath: string | null = null; - let bestPathLength = -1; - - for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) { - for (const worktree of worktrees) { - const worktreePath = normalizePath(worktree.path); - if (!worktreePath) { - continue; - } - const isExact = normalizedDirectory === worktreePath; - const isNested = normalizedDirectory.startsWith(`${worktreePath}/`); - if (!isExact && !isNested) { - continue; - } - if (worktreePath.length > bestPathLength) { - bestPathLength = worktreePath.length; - matchedWorktree = worktree; - matchedProjectPath = normalizePath(projectPath); - } - } - } - - if (!matchedWorktree) { - return null; - } - - const normalizedMetadataProjectPath = normalizePath(matchedWorktree.projectDirectory); - const candidates = [normalizedMetadataProjectPath, matchedProjectPath].filter((value): value is string => Boolean(value)); - - for (const candidatePath of candidates) { - const exact = projects.find((project) => normalizePath(project.path) === candidatePath) ?? null; - if (exact) { - return exact; - } - const nested = resolveProjectForDirectory(projects, candidatePath); - if (nested) { - return nested; - } - } - - return null; -}; - -const resolveDraftProjectForDirectory = ( - projects: ProjectEntry[], - availableWorktreesByProject: Map, - directory: string | null, -): ProjectEntry | null => { - return resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) - ?? resolveProjectForDirectory(projects, directory); -}; - -const buildSessionChoiceAnalysisSignature = (messages: Array<{ info: Message; parts: Part[] }>): string => { - const lastMessage = messages[messages.length - 1]; - const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : ''; - const lastAssistant = [...messages] - .reverse() - .find((message) => message.info?.role === 'assistant'); - const lastAssistantId = typeof lastAssistant?.info?.id === 'string' ? lastAssistant.info.id : ''; - return `${messages.length}:${lastMessageId}:${lastAssistantId}`; -}; - -const resolveSessionDirectory = ( - sessions: Session[], - sessionId: string | null | undefined, - getWorktreeMetadata: (id: string) => { path?: string } | undefined, -): string | null => { - if (!sessionId) { - return null; - } - const metadataPath = getWorktreeMetadata(sessionId)?.path; - if (typeof metadataPath === "string" && metadataPath.trim().length > 0) { - return normalizePath(metadataPath); - } - - const target = sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined; - if (!target) { - return null; - } - return normalizePath(target.directory ?? null); -}; - -export const useSessionStore = create()( - devtools( - (set, get) => ({ - - sessions: [], - archivedSessions: [], - sessionsByDirectory: new Map(), - currentSessionId: null, - lastLoadedDirectory: null, - messages: new Map(), - sessionMemoryState: new Map(), - sessionHistoryMeta: new Map(), - messageStreamStates: new Map(), - sessionCompactionUntil: new Map(), - sessionAbortFlags: new Map(), - permissions: new Map(), - questions: new Map(), - attachedFiles: [], - isLoading: false, - error: null, - streamingMessageIds: new Map(), - abortControllers: new Map(), - lastUsedProvider: null, - isSyncing: false, - sessionModelSelections: new Map(), - sessionAgentSelections: new Map(), - sessionAgentModelSelections: new Map(), - webUICreatedSessions: new Set(), - worktreeMetadata: new Map(), - availableWorktrees: [], - availableWorktreesByProject: new Map(), - currentAgentContext: new Map(), - sessionContextUsage: new Map(), - sessionAgentEditModes: new Map(), - abortPromptSessionId: null, - abortPromptExpiresAt: null, - sessionStatus: new Map(), - sessionAttentionStates: new Map(), - userSummaryTitles: new Map(), - pendingInputText: null, - pendingInputMode: 'replace', - pendingSyntheticParts: null, - newSessionDraft: { open: true, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null }, - - // Voice state (initialized to disconnected/idle) - voiceStatus: 'disconnected', - voiceMode: 'idle', - - // Voice actions - setVoiceStatus: (status: import("./types/sessionTypes").VoiceStatus) => { - set({ voiceStatus: status }); - }, - setVoiceMode: (mode: import("./types/sessionTypes").VoiceMode) => { - set({ voiceMode: mode }); - }, - - getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => { - return useContextStore.getState().getSessionAgentEditMode(sessionId, agentName, defaultMode); - }, - - toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => { - return useContextStore.getState().toggleSessionAgentEditMode(sessionId, agentName, defaultMode); - }, - - setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => { - return useContextStore.getState().setSessionAgentEditMode(sessionId, agentName, mode, defaultMode); - }, - - loadSessions: () => useSessionManagementStore.getState().loadSessions(), - - openNewSessionDraft: (options) => { - const projectsState = useProjectsStore.getState(); - const projects = projectsState.projects; - const availableWorktreesByProject = get().availableWorktreesByProject; - const activeProject = projectsState.getActiveProject(); - const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null); - const persistedTarget = readPersistedDraftTarget(); - - const explicitDirectory = options?.directoryOverride !== undefined - ? normalizePath(options.directoryOverride) - : null; - const explicitProject = options?.projectId - ? projects.find((project) => project.id === options.projectId) ?? null - : null; - - const inferredProjectFromDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory); - const fallbackProject = (() => { - if (activeProject) { - return activeProject; - } - if (projectsState.activeProjectId) { - return projects.find((project) => project.id === projectsState.activeProjectId) ?? null; - } - return projects[0] ?? null; - })(); - - const persistedProjectById = persistedTarget?.projectId - ? projects.find((project) => project.id === persistedTarget.projectId) ?? null - : null; - const persistedProjectByDirectory = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null); - const currentDirectoryProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory); - - const selectedProject = (() => { - if (explicitProject || explicitDirectory !== null) { - return explicitProject ?? inferredProjectFromDirectory ?? fallbackProject; - } - if (currentDirectory) { - return currentDirectoryProject ?? fallbackProject; - } - return persistedProjectByDirectory ?? persistedProjectById ?? fallbackProject; - })(); - - const directory = (() => { - if (explicitDirectory !== null) { - return explicitDirectory; - } - if (explicitProject) { - return normalizePath(explicitProject.path ?? null); - } - if (currentDirectory) { - return currentDirectory; - } - if (persistedTarget?.directory) { - return persistedTarget.directory; - } - return normalizePath(selectedProject?.path ?? null); - })(); - - persistDraftTarget({ - projectId: selectedProject?.id ?? null, - directory, - }); - - set({ - newSessionDraft: { - open: true, - selectedProjectId: selectedProject?.id ?? null, - directoryOverride: directory, - pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null, - bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null), - preserveDirectoryOverride: options?.preserveDirectoryOverride === true, - parentID: options?.parentID ?? null, - title: options?.title, - initialPrompt: options?.initialPrompt, - syntheticParts: options?.syntheticParts, - targetFolderId: options?.targetFolderId, - }, - currentSessionId: null, - error: null, - // Set pending input text if initialPrompt is provided - ...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}), - }); - - try { - const configState = useConfigStore.getState(); - const visibleAgents = configState.getVisibleAgents(); - - // Priority: settingsDefaultAgent → build → first visible - let agentName: string | undefined; - if (configState.settingsDefaultAgent) { - const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); - if (settingsAgent) { - agentName = settingsAgent.name; - } - } - if (!agentName) { - agentName = - visibleAgents.find((agent) => agent.name === 'build')?.name || - visibleAgents[0]?.name; - } - - if (agentName) { - configState.setAgent(agentName); - } - } catch { - // ignored - } - }, - - overrideNewSessionDraftTarget: (options) => { - const projectsState = useProjectsStore.getState(); - const projects = projectsState.projects; - const availableWorktreesByProject = get().availableWorktreesByProject; - const explicitDirectory = normalizePath(options?.directoryOverride ?? null); - const explicitProject = options?.projectId - ? projects.find((project) => project.id === options.projectId) ?? null - : null; - const inferredProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory); - const fallbackProject = explicitProject ?? inferredProject ?? projectsState.getActiveProject() ?? projects[0] ?? null; - const selectedProject = explicitProject ?? inferredProject ?? fallbackProject; - const nextDirectory = explicitDirectory ?? normalizePath(selectedProject?.path ?? null); - - persistDraftTarget({ - projectId: selectedProject?.id ?? null, - directory: nextDirectory, - }); - - set((state) => { - const previousDraft = state.newSessionDraft; - const hasPendingWorktreeRequestId = Object.prototype.hasOwnProperty.call(options, 'pendingWorktreeRequestId'); - const hasBootstrapPendingDirectory = Object.prototype.hasOwnProperty.call(options, 'bootstrapPendingDirectory'); - return { - newSessionDraft: { - ...previousDraft, - open: true, - selectedProjectId: selectedProject?.id ?? null, - directoryOverride: nextDirectory, - pendingWorktreeRequestId: hasPendingWorktreeRequestId - ? (options.pendingWorktreeRequestId ?? null) - : (previousDraft.pendingWorktreeRequestId ?? null), - bootstrapPendingDirectory: hasBootstrapPendingDirectory - ? normalizePath(options.bootstrapPendingDirectory ?? null) - : (previousDraft.bootstrapPendingDirectory ?? null), - preserveDirectoryOverride: options?.preserveDirectoryOverride === true, - title: options?.title ?? previousDraft.title, - initialPrompt: options?.initialPrompt ?? previousDraft.initialPrompt, - }, - currentSessionId: null, - error: null, - ...(options?.initialPrompt ? { pendingInputText: options.initialPrompt, pendingInputMode: 'replace' as const } : {}), - }; - }); - }, - - setNewSessionDraftTarget: ({ projectId, directoryOverride }, options) => { - const projects = useProjectsStore.getState().projects; - const project = projectId - ? projects.find((entry) => entry.id === projectId) ?? null - : null; - const normalizedDirectory = normalizePath(directoryOverride); - const normalizedProjectPath = normalizePath(project?.path ?? null); - const nextDirectory = normalizedDirectory ?? normalizedProjectPath ?? null; - - let didUpdate = false; - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - if ( - options?.force !== true - && ( - state.newSessionDraft.pendingWorktreeRequestId - || state.newSessionDraft.bootstrapPendingDirectory - || state.newSessionDraft.preserveDirectoryOverride - ) - ) { - return state; - } - didUpdate = true; - return { - newSessionDraft: { - ...state.newSessionDraft, - selectedProjectId: project?.id ?? null, - directoryOverride: nextDirectory, - pendingWorktreeRequestId: null, - bootstrapPendingDirectory: null, - preserveDirectoryOverride: false, - parentID: null, - }, - }; - }); - - if (didUpdate) { - persistDraftTarget({ - projectId: project?.id ?? null, - directory: nextDirectory, - }); - } - }, - - closeNewSessionDraft: () => { - const realCurrentSessionId = useSessionManagementStore.getState().currentSessionId; - set({ - newSessionDraft: { open: false, selectedProjectId: null, directoryOverride: null, pendingWorktreeRequestId: null, bootstrapPendingDirectory: null, preserveDirectoryOverride: false, parentID: null, title: undefined, initialPrompt: undefined, syntheticParts: undefined, targetFolderId: undefined }, - currentSessionId: realCurrentSessionId, - }); - }, - - setPendingDraftWorktreeRequest: (requestId) => { - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - pendingWorktreeRequestId: requestId, - }, - }; - }); - }, - - resolvePendingDraftWorktreeTarget: (requestId, directory, options) => { - set((state) => { - if (!state.newSessionDraft?.open || state.newSessionDraft.pendingWorktreeRequestId !== requestId) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - selectedProjectId: options?.projectId ?? state.newSessionDraft.selectedProjectId ?? null, - directoryOverride: normalizePath(directory), - pendingWorktreeRequestId: null, - bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? state.newSessionDraft.bootstrapPendingDirectory ?? null), - preserveDirectoryOverride: options?.preserveDirectoryOverride ?? true, - }, - }; - }); - }, - - setDraftBootstrapPendingDirectory: (directory) => { - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - bootstrapPendingDirectory: normalizePath(directory), - }, - }; - }); - }, - - setDraftPreserveDirectoryOverride: (value) => { - set((state) => { - if (!state.newSessionDraft?.open) { - return state; - } - return { - newSessionDraft: { - ...state.newSessionDraft, - preserveDirectoryOverride: value, - }, - }; - }); - }, - - createSession: async (title?: string, directoryOverride?: string | null, parentID?: string | null) => { - const draft = get().newSessionDraft; - const targetFolderId = draft.targetFolderId; - get().closeNewSessionDraft(); - - const result = await useSessionManagementStore.getState().createSession(title, directoryOverride, parentID); - - if (result?.id) { - await get().setCurrentSession(result.id); - const finalScopeKey = directoryOverride || get().lastLoadedDirectory || result.directory; - if (targetFolderId && finalScopeKey) { - useSessionFoldersStore.getState().addSessionToFolder(finalScopeKey, targetFolderId, result.id); - } - } - return result; - }, - createSessionFromAssistantMessage: async (sourceMessageId: string) => { - if (!sourceMessageId) { - return; - } - - const messageStore = useMessageStore.getState(); - const { messages, lastUsedProvider } = messageStore; - let sourceEntry: { info: Message; parts: Part[] } | undefined; - let sourceSessionId: string | undefined; - - messages.forEach((messageList, sessionId) => { - const found = messageList.find((entry) => entry.info?.id === sourceMessageId); - if (found && !sourceEntry) { - sourceEntry = found; - sourceSessionId = sessionId; - } - }); - - if (!sourceEntry || sourceEntry.info.role !== "assistant") { - return; - } - - const assistantPlanText = flattenAssistantTextParts(sourceEntry.parts); - if (!assistantPlanText.trim()) { - return; - } - - const sessionManagementStore = useSessionManagementStore.getState(); - const directory = resolveSessionDirectory( - sessionManagementStore.sessions, - sourceSessionId ?? null, - sessionManagementStore.getWorktreeMetadata, - ); - - const session = await get().createSession(undefined, directory ?? null, null); - if (!session) { - return; - } - - const { currentProviderId, currentModelId, currentAgentName } = useConfigStore.getState(); - const providerID = currentProviderId || lastUsedProvider?.providerID; - const modelID = currentModelId || lastUsedProvider?.modelID; - - if (!providerID || !modelID) { - return; - } - - await opencodeClient.sendMessage({ - id: session.id, - providerID, - modelID, - text: assistantPlanText, - prefaceText: EXECUTION_FORK_META_TEXT, - agent: currentAgentName ?? undefined, - }); - }, - deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options), - deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(ids, options), - archiveSession: (id: string) => useSessionManagementStore.getState().archiveSession(id), - archiveSessions: (ids: string[], options) => useSessionManagementStore.getState().archiveSessions(ids, options), - updateSessionTitle: (id: string, title: string) => useSessionManagementStore.getState().updateSessionTitle(id, title), - shareSession: (id: string) => useSessionManagementStore.getState().shareSession(id), - unshareSession: (id: string) => useSessionManagementStore.getState().unshareSession(id), - setCurrentSession: async (id: string | null) => { - if (id) { - get().closeNewSessionDraft(); - } - - const previousSessionId = useSessionManagementStore.getState().currentSessionId; - - const sessionDirectory = resolveSessionDirectory( - useSessionManagementStore.getState().sessions, - id, - useSessionManagementStore.getState().getWorktreeMetadata - ); - const fallbackDirectory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory ?? null; - const resolvedDirectory = sessionDirectory ?? fallbackDirectory; - - try { - opencodeClient.setDirectory(resolvedDirectory ?? undefined); - } catch (error) { - console.warn("Failed to set OpenCode directory for session switch:", error); - } - - if (previousSessionId && previousSessionId !== id) { - const memoryState = get().sessionMemoryState.get(previousSessionId); - if (!memoryState?.isStreaming) { - - const previousMessages = get().messages.get(previousSessionId) || []; - if (previousMessages.length > 0) { - get().updateViewportAnchor(previousSessionId, previousMessages.length - 1); - } - } - } - - useSessionManagementStore.getState().setCurrentSession(id); - - if (id) { - - const existingMessages = get().messages.get(id); - const historyMeta = get().sessionHistoryMeta.get(id); - const needsHistoryBootstrap = - !historyMeta || - typeof historyMeta.complete !== 'boolean'; - - if (!existingMessages || needsHistoryBootstrap) { - - await get().loadMessages(id); - } - - // Analyze session messages to extract agent/model/variant choices - // This ensures context is available even when ModelControls isn't mounted - const sessionMessages = get().messages.get(id); - if (sessionMessages && sessionMessages.length > 0) { - const agents = useConfigStore.getState().agents; - if (agents.length > 0) { - const analysisSignature = buildSessionChoiceAnalysisSignature(sessionMessages); - if (sessionChoiceAnalysisSignature.get(id) === analysisSignature) { - return; - } - try { - await useContextStore.getState().analyzeAndSaveExternalSessionChoices( - id, - agents, - get().messages - ); - sessionChoiceAnalysisSignature.set(id, analysisSignature); - } catch (error) { - console.warn('Failed to analyze session choices:', error); - } - } - } - } - - }, - loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit), - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => { - const draft = get().newSessionDraft; - const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; - - const setStatus = (sessionId: string, type: 'idle' | 'busy') => { - set((state) => { - const next = new Map(state.sessionStatus ?? new Map()); - next.set(sessionId, { type }); - return { sessionStatus: next }; - }); - }; - - if (draft?.open) { - const draftTargetFolderId = draft.targetFolderId; - let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null; - const draftProjectId = draft.selectedProjectId ?? null; - - if (draft.pendingWorktreeRequestId) { - draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId); - get().resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride); - } - - const created = await useSessionManagementStore - .getState() - .createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null); - - if (!created?.id) { - throw new Error('Failed to create session'); - } - - persistDraftTarget({ - projectId: draftProjectId, - directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), - }); - - const configState = useConfigStore.getState(); - const draftAgentName = configState.currentAgentName; - const effectiveDraftAgent = trimmedAgent ?? draftAgentName; - const draftProviderId = configState.currentProviderId; - const draftModelId = configState.currentModelId; - - if (draftProviderId && draftModelId) { - try { - useContextStore.getState().saveSessionModelSelection(created.id, draftProviderId, draftModelId); - } catch { - // ignored - } - } - - if (effectiveDraftAgent) { - try { - useContextStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent); - } catch { - // ignored - } - - if (draftProviderId && draftModelId) { - try { - useContextStore - .getState() - .saveAgentModelForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId); - } catch { - // ignored - } - - try { - useContextStore - .getState() - .saveAgentModelVariantForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId, variant); - } catch { - // ignored - } - } - } - - try { - useSessionManagementStore - .getState() - .initializeNewOpenChamberSession(created.id, configState.agents); - } catch { - // ignored - } - - // Capture synthetic parts before clearing draft - const draftSyntheticParts = draft.syntheticParts; - - get().closeNewSessionDraft(); - await get().setCurrentSession(created.id); - - // Assign to target folder if session was created from folder's + button - if (draftTargetFolderId) { - const scopeKey = draftDirectoryOverride || created.directory || null; - if (scopeKey) { - useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id); - } - } - - setStatus(created.id, 'busy'); - - // Merge draft synthetic parts with any additional parts passed to sendMessage - const mergedAdditionalParts = draftSyntheticParts?.length - ? [...(additionalParts || []), ...draftSyntheticParts] - : additionalParts; - - const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null); - if (createdDirectory) { - await waitForWorktreeBootstrap(createdDirectory); - } - - try { - markPendingUserSendAnimation(created.id); - return await useMessageStore - .getState() - .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant, inputMode); - } catch (error) { - setStatus(created.id, 'idle'); - throw error; - } - } - - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - const sessionAgentSelection = currentSessionId - ? useContextStore.getState().getSessionAgentSelection(currentSessionId) - : null; - const configAgentName = useConfigStore.getState().currentAgentName; - const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined; - - if (currentSessionId && effectiveAgent) { - try { - useContextStore.getState().saveSessionAgentSelection(currentSessionId, effectiveAgent); - } catch { - // ignored - } - - try { - useContextStore - .getState() - .saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant); - } catch { - // ignored - } - } - - if (currentSessionId) { - setStatus(currentSessionId, 'busy'); - - const memoryState = get().sessionMemoryState.get(currentSessionId); - if (!memoryState || !memoryState.lastUserMessageAt) { - const currentMemoryState = get().sessionMemoryState; - const newMemoryState = new Map(currentMemoryState); - newMemoryState.set(currentSessionId, { - viewportAnchor: memoryState?.viewportAnchor ?? 0, - isStreaming: memoryState?.isStreaming ?? false, - lastAccessedAt: Date.now(), - backgroundMessageCount: memoryState?.backgroundMessageCount ?? 0, - lastUserMessageAt: Date.now(), - }); - set({ sessionMemoryState: newMemoryState }); - } - } - - const currentSessionDirectory = currentSessionId - ? normalizePath(useSessionManagementStore.getState().getDirectoryForSession(currentSessionId)) - : null; - if (currentSessionDirectory) { - await waitForWorktreeBootstrap(currentSessionDirectory); - } - - // Notify server that user sent a message in this session - if (currentSessionId) { - fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: 'POST' }) - .catch(() => { /* ignore */ }); - } - - try { - if (currentSessionId) { - markPendingUserSendAnimation(currentSessionId); - } - return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant, inputMode); - } catch (error) { - if (currentSessionId) { - setStatus(currentSessionId, 'idle'); - } - throw error; - } - }, - abortCurrentOperation: (sessionIdOverride?: string) => { - const sessionId = sessionIdOverride || useSessionManagementStore.getState().currentSessionId; - return useMessageStore.getState().abortCurrentOperation(sessionId || undefined); - }, - armAbortPrompt: (durationMs = 3000) => { - const sessionId = useSessionManagementStore.getState().currentSessionId; - if (!sessionId) { - return null; - } - const expiresAt = Date.now() + durationMs; - set({ abortPromptSessionId: sessionId, abortPromptExpiresAt: expiresAt }); - return expiresAt; - }, - clearAbortPrompt: () => { - set({ abortPromptSessionId: null, abortPromptExpiresAt: null }); - }, - acknowledgeSessionAbort: (sessionId: string) => { - if (!sessionId) { - return; - } - useMessageStore.getState().acknowledgeSessionAbort(sessionId); - }, - addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string) => { - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - - const effectiveCurrent = currentSessionId || sessionId; - return useMessageStore.getState().addStreamingPart(sessionId, messageId, part, role, effectiveCurrent); - }, - applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string) => { - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - const effectiveCurrent = currentSessionId || sessionId; - return useMessageStore.getState().applyPartDelta(sessionId, messageId, partId, field, delta, role, effectiveCurrent); - }, - completeStreamingMessage: (sessionId: string, messageId: string) => useMessageStore.getState().completeStreamingMessage(sessionId, messageId), - markMessageStreamSettled: (messageId: string) => useMessageStore.getState().markMessageStreamSettled(messageId), - updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Record) => useMessageStore.getState().updateMessageInfo(sessionId, messageId, messageInfo), - updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => useMessageStore.getState().updateSessionCompaction(sessionId, compactingTimestamp ?? null), - addPermission: (permission: PermissionRequest) => { - return usePermissionStore.getState().addPermission(permission); - }, - respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => usePermissionStore.getState().respondToPermission(sessionId, requestId, response), - dismissPermission: (sessionId: string, requestId: string) => usePermissionStore.getState().dismissPermission(sessionId, requestId), - - addQuestion: (question: QuestionRequest) => useQuestionStore.getState().addQuestion(question), - dismissQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().dismissQuestion(sessionId, requestId), - respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => useQuestionStore.getState().respondToQuestion(sessionId, requestId, answers), - rejectQuestion: (sessionId: string, requestId: string) => useQuestionStore.getState().rejectQuestion(sessionId, requestId), - - clearError: () => useSessionManagementStore.getState().clearError(), - getSessionsByDirectory: (directory: string) => useSessionManagementStore.getState().getSessionsByDirectory(directory), - getDirectoryForSession: (sessionId: string) => useSessionManagementStore.getState().getDirectoryForSession(sessionId), - getLastMessageModel: (sessionId: string) => useMessageStore.getState().getLastMessageModel(sessionId), - getCurrentAgent: (sessionId: string) => useContextStore.getState().getCurrentAgent(sessionId), - syncMessages: ( - sessionId: string, - messages: { info: Message; parts: Part[] }[], - options?: { replace?: boolean } - ) => useMessageStore.getState().syncMessages(sessionId, messages, options), - applySessionMetadata: (sessionId: string, metadata: Partial) => useSessionManagementStore.getState().applySessionMetadata(sessionId, metadata), - - addAttachedFile: (file: File) => useFileStore.getState().addAttachedFile(file), - addServerFile: (path: string, name: string, content?: string) => useFileStore.getState().addServerFile(path, name, content), - removeAttachedFile: (id: string) => useFileStore.getState().removeAttachedFile(id), - clearAttachedFiles: () => useFileStore.getState().clearAttachedFiles(), - - updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor), - loadMoreMessages: (sessionId: string, direction: "up" | "down") => useMessageStore.getState().loadMoreMessages(sessionId, direction), - - saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => useContextStore.getState().saveSessionModelSelection(sessionId, providerId, modelId), - getSessionModelSelection: (sessionId: string) => useContextStore.getState().getSessionModelSelection(sessionId), - saveSessionAgentSelection: (sessionId: string, agentName: string) => useContextStore.getState().saveSessionAgentSelection(sessionId, agentName), - getSessionAgentSelection: (sessionId: string) => useContextStore.getState().getSessionAgentSelection(sessionId), - saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerId, modelId), - getAgentModelForSession: (sessionId: string, agentName: string) => useContextStore.getState().getAgentModelForSession(sessionId, agentName), - saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, variant), - getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().getAgentModelVariantForSession(sessionId, agentName, providerId, modelId), - analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Record[]) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().analyzeAndSaveExternalSessionChoices(sessionId, agents, messages); - }, - isOpenChamberCreatedSession: (sessionId: string) => useSessionManagementStore.getState().isOpenChamberCreatedSession(sessionId), - markSessionAsOpenChamberCreated: (sessionId: string) => useSessionManagementStore.getState().markSessionAsOpenChamberCreated(sessionId), - initializeNewOpenChamberSession: (sessionId: string, agents: Record[]) => useSessionManagementStore.getState().initializeNewOpenChamberSession(sessionId, agents), - setWorktreeMetadata: (sessionId: string, metadata) => useSessionManagementStore.getState().setWorktreeMetadata(sessionId, metadata), - setSessionDirectory: (sessionId: string, directory: string | null) => useSessionManagementStore.getState().setSessionDirectory(sessionId, directory), - getWorktreeMetadata: (sessionId: string) => useSessionManagementStore.getState().getWorktreeMetadata(sessionId), - getContextUsage: (contextLimit: number, outputLimit: number) => { - if (get().newSessionDraft?.open) { - return null; - } - - const currentSessionId = useSessionManagementStore.getState().currentSessionId; - if (!currentSessionId) return null; - const messages = useMessageStore.getState().messages; - return useContextStore.getState().getContextUsage(currentSessionId, contextLimit, outputLimit, messages); - }, - updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().updateSessionContextUsage(sessionId, contextLimit, outputLimit, messages); - }, - initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().initializeSessionContextUsage(sessionId, contextLimit, outputLimit, messages); - }, - debugSessionMessages: async (sessionId: string) => { - const messages = normalizeMessageRecordsForProjection( - useMessageStore.getState().messages.get(sessionId) || [] - ); - const session = useSessionManagementStore.getState().sessions.find(s => s.id === sessionId); - console.log(`Debug session ${sessionId}:`, { - session, - messageCount: messages.length, - messages: messages.map(m => ({ - id: m.info.id, - role: m.info.role, - parts: m.parts.length, - tokens: (m.info as Record).tokens - })) - }); - }, - pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => { - const messages = useMessageStore.getState().messages; - return useContextStore.getState().pollForTokenUpdates(sessionId, messageId, messages, maxAttempts); - }, - updateSession: (session: Session) => useSessionManagementStore.getState().updateSession(session), - removeSessionFromStore: (sessionId: string) => useSessionManagementStore.getState().removeSessionFromStore(sessionId), - - revertToMessage: async (sessionId: string, messageId: string) => { - // Get the message text before reverting - const messages = useMessageStore.getState().messages.get(sessionId) || []; - const targetMessage = messages.find((m) => m.info.id === messageId); - let messageText = ''; - - if (targetMessage && targetMessage.info.role === 'user') { - // Extract text from user message parts - const textParts = targetMessage.parts.filter((p) => p.type === 'text'); - messageText = textParts - .map((p) => { - const part = p as { text?: string; content?: string }; - return part.text || part.content || ''; - }) - .join('\n') - .trim(); - } - - // Call revert API - const updatedSession = await opencodeClient.revertSession(sessionId, messageId); - - // Update session in store (this stores the revert.messageID) - useSessionManagementStore.getState().updateSession(updatedSession); - - // Filter out reverted messages from the store - // Messages with ID >= revert.messageID should be removed - const currentMessages = useMessageStore.getState().messages.get(sessionId) || []; - const revertMessageId = updatedSession.revert?.messageID; - - if (revertMessageId) { - // Keep only messages before the revert point. - // Fallback to the originally clicked message if SDK returns an id - // that is not loaded in the current in-memory window. - let revertIndex = currentMessages.findIndex((m) => m.info.id === revertMessageId); - if (revertIndex === -1) { - revertIndex = currentMessages.findIndex((m) => m.info.id === messageId); - } - - if (revertIndex !== -1) { - const filteredMessages = currentMessages.slice(0, revertIndex); - useMessageStore.getState().syncMessages(sessionId, filteredMessages, { replace: true }); - } - } - - // Set pending input text for ChatInput to consume - if (messageText) { - set({ pendingInputText: messageText, pendingInputMode: 'replace' }); - } - }, - - handleSlashUndo: async (sessionId: string) => { - const messages = get().messages.get(sessionId) || []; - const userMessages = messages.filter(m => m.info.role === 'user'); - const sessions = get().sessions; - const currentSession = sessions.find(s => s.id === sessionId); - - // No-op when there is nothing to undo/redo - if (userMessages.length === 0) { - return; - } - - // Get current revert state to determine which message to undo next - const revertToId = currentSession?.revert?.messageID; - - // Find the user message AFTER the revert point (or last message if no revert) - let targetMessage; - if (revertToId) { - const revertIndex = userMessages.findIndex(m => m.info.id === revertToId); - targetMessage = userMessages[revertIndex + 1]; - } else { - targetMessage = userMessages[userMessages.length - 1]; - } - - // No-op when there is nothing to undo/redo - if (!targetMessage) { - return; - } - - // Helper to extract text preview - const textPart = targetMessage.parts.find(p => p.type === 'text'); - const preview = typeof textPart === 'object' && textPart && 'text' in textPart - ? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '') - : '[No text]'; - - await get().revertToMessage(sessionId, targetMessage.info.id); - - const { toast } = await import('sonner'); - toast.success(`Undid to: ${preview}`); - }, - - handleSlashRedo: async (sessionId: string) => { - const sessions = get().sessions; - const currentSession = sessions.find(s => s.id === sessionId); - const revertToId = currentSession?.revert?.messageID; - - // No-op when there is nothing to undo/redo - if (!revertToId) { - return; - } - - const messages = get().messages.get(sessionId) || []; - const userMessages = messages.filter(m => m.info.role === 'user'); - - // Find the user message BEFORE the revert point - const revertIndex = userMessages.findIndex(m => m.info.id === revertToId); - const targetMessage = userMessages[revertIndex - 1]; - - if (targetMessage) { - // Partial redo: move to previous message - const textPart = targetMessage.parts.find(p => p.type === 'text'); - const preview = typeof textPart === 'object' && textPart && 'text' in textPart - ? String(textPart.text).slice(0, 50) + (String(textPart.text).length > 50 ? '...' : '') - : '[No text]'; - - await get().revertToMessage(sessionId, targetMessage.info.id); - - const { toast } = await import('sonner'); - toast.success(`Redid to: ${preview}`); - } else { - // Full unrevert: restore all - const session = await opencodeClient.unrevertSession(sessionId); - await useSessionManagementStore.getState().updateSession(session); - await get().loadMessages(sessionId); - - const { toast } = await import('sonner'); - toast.success('Restored all messages'); - } - }, - - forkFromMessage: async (sessionId: string, messageId: string) => { - const sessions = get().sessions; - const existingSession = sessions.find(s => s.id === sessionId); - if (!existingSession) return; - - try { - // 1. Call SDK fork - backend copies all messages up to messageId - const result = await opencodeClient.forkSession(sessionId, messageId); - - if (!result || !result.id) { - const { toast } = await import('sonner'); - toast.error('Failed to fork session'); - return; - } - - // 2. Extract fork point content for input field (text + file attachments) - const messages = get().messages.get(sessionId) || []; - const message = messages.find(m => m.info.id === messageId); - - if (!message) { - const { toast } = await import('sonner'); - toast.error('Message not found'); - return; - } - - // Extract text content from non-synthetic, non-ignored text parts - let inputText = ''; - for (const part of message.parts) { - if (part.type === 'text' && !part.synthetic && !part.ignored) { - const typedPart = part as { text?: string }; - inputText += typedPart.text || ''; - } - } - - // 3. Switch to new session - get().setCurrentSession(result.id); - - // 4. Show fork point as pending input (will populate ChatInput) - if (inputText) { - set({ pendingInputText: inputText, pendingInputMode: 'replace' }); - } - - // Load the new session's messages - await get().loadMessages(result.id); - - const { toast } = await import('sonner'); - toast.success(`Forked from ${existingSession.title}`); - } catch (error) { - console.error('Failed to fork session:', error); - const { toast } = await import('sonner'); - toast.error('Failed to fork session'); - } - }, - - setPendingInputText: (text: string | null, mode: 'replace' | 'append' | 'append-inline' = 'replace') => { - set({ pendingInputText: text, pendingInputMode: mode }); - }, - - consumePendingInputText: () => { - const text = get().pendingInputText; - const mode = get().pendingInputMode; - if (text !== null) { - set({ pendingInputText: null, pendingInputMode: 'replace' }); - } - if (text === null) { - return null; - } - return { text, mode }; - }, - - setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => { - set({ pendingSyntheticParts: parts }); - }, - - consumePendingSyntheticParts: () => { - const parts = get().pendingSyntheticParts; - if (parts !== null) { - set({ pendingSyntheticParts: null }); - } - return parts; - }, - }), - { - name: "composed-session-store", - } - ), -); - -// rAF debounce IDs for useMessageStore -> useSessionStore sync -let messageStoreSyncRafId: number | null = null; -let userSummaryTitlesRafId: ReturnType | null = null; - -useSessionManagementStore.subscribe((state, prevState) => { - - if ( - state.sessions === prevState.sessions && - state.archivedSessions === prevState.archivedSessions && - state.sessionsByDirectory === prevState.sessionsByDirectory && - state.currentSessionId === prevState.currentSessionId && - state.lastLoadedDirectory === prevState.lastLoadedDirectory && - state.isLoading === prevState.isLoading && - state.error === prevState.error && - state.webUICreatedSessions === prevState.webUICreatedSessions && - state.worktreeMetadata === prevState.worktreeMetadata && - state.availableWorktrees === prevState.availableWorktrees && - state.availableWorktreesByProject === prevState.availableWorktreesByProject - ) { - return; - } - - const draftOpen = useSessionStore.getState().newSessionDraft?.open; - - useSessionStore.setState({ - sessions: state.sessions, - archivedSessions: state.archivedSessions, - sessionsByDirectory: state.sessionsByDirectory, - currentSessionId: draftOpen ? null : state.currentSessionId, - lastLoadedDirectory: state.lastLoadedDirectory, - isLoading: state.isLoading, - error: state.error, - webUICreatedSessions: state.webUICreatedSessions, - worktreeMetadata: state.worktreeMetadata, - availableWorktrees: state.availableWorktrees, - availableWorktreesByProject: state.availableWorktreesByProject, - }); -}); - -useMessageStore.subscribe((state, prevState) => { - // Early-return equality check stays outside the rAF so we skip scheduling - // entirely when nothing relevant changed. - if ( - state.messages === prevState.messages && - state.sessionMemoryState === prevState.sessionMemoryState && - state.sessionHistoryMeta === prevState.sessionHistoryMeta && - state.messageStreamStates === prevState.messageStreamStates && - state.sessionCompactionUntil === prevState.sessionCompactionUntil && - state.sessionAbortFlags === prevState.sessionAbortFlags && - state.streamingMessageIds === prevState.streamingMessageIds && - state.abortControllers === prevState.abortControllers && - state.lastUsedProvider === prevState.lastUsedProvider && - state.isSyncing === prevState.isSyncing - ) { - return; - } - - // Debounce the expensive sessionStore update to at most once per animation - // frame. Multiple messageStore updates within the same frame (e.g. several - // SSE tokens arriving before the next paint) collapse into a single setState. - if (messageStoreSyncRafId !== null) { - cancelAnimationFrame(messageStoreSyncRafId); - } - messageStoreSyncRafId = requestAnimationFrame(() => { - messageStoreSyncRafId = null; - - // Read the LATEST state at flush time, not the stale state captured by - // the subscription closure. - const latest = useMessageStore.getState(); - - useSessionStore.setState({ - messages: latest.messages, - sessionMemoryState: latest.sessionMemoryState, - sessionHistoryMeta: latest.sessionHistoryMeta, - messageStreamStates: latest.messageStreamStates, - sessionCompactionUntil: latest.sessionCompactionUntil, - sessionAbortFlags: latest.sessionAbortFlags, - streamingMessageIds: latest.streamingMessageIds, - abortControllers: latest.abortControllers, - lastUsedProvider: latest.lastUsedProvider, - isSyncing: latest.isSyncing, - }); - - // Sidebar titles don't need real-time updates; debounce separately at - // 500 ms so the expensive per-message iteration doesn't happen every - // frame during streaming. - if (userSummaryTitlesRafId !== null) { - clearTimeout(userSummaryTitlesRafId); - } - userSummaryTitlesRafId = setTimeout(() => { - userSummaryTitlesRafId = null; - const titleState = useMessageStore.getState(); - const userSummaryTitles = new Map(); - titleState.messages.forEach((messageList, sessionId) => { - if (!Array.isArray(messageList) || messageList.length === 0) { - return; - } - for (let index = messageList.length - 1; index >= 0; index -= 1) { - const entry = messageList[index]; - if (!entry || !entry.info) { - continue; - } - const info = entry.info as Message & { - summary?: { title?: string | null } | null; - time?: { created?: number | null }; - }; - if (info.role === "user") { - const title = info.summary?.title; - if (typeof title === "string") { - const trimmed = title.trim(); - if (trimmed.length > 0) { - const createdAt = - info.time && typeof info.time.created === "number" - ? info.time.created - : null; - userSummaryTitles.set(sessionId, { title: trimmed, createdAt }); - break; - } - } - } - } - }); - useSessionStore.setState({ userSummaryTitles }); - }, 500); - }); -}); - -useFileStore.subscribe((state, prevState) => { - if (state.attachedFiles === prevState.attachedFiles) { - return; - } - - useSessionStore.setState({ - attachedFiles: state.attachedFiles, - }); -}); - -useContextStore.subscribe((state, prevState) => { - if ( - state.sessionModelSelections === prevState.sessionModelSelections && - state.sessionAgentSelections === prevState.sessionAgentSelections && - state.sessionAgentModelSelections === prevState.sessionAgentModelSelections && - state.currentAgentContext === prevState.currentAgentContext && - state.sessionContextUsage === prevState.sessionContextUsage && - state.sessionAgentEditModes === prevState.sessionAgentEditModes - ) { - return; - } - - useSessionStore.setState({ - sessionModelSelections: state.sessionModelSelections, - sessionAgentSelections: state.sessionAgentSelections, - sessionAgentModelSelections: state.sessionAgentModelSelections, - currentAgentContext: state.currentAgentContext, - sessionContextUsage: state.sessionContextUsage, - sessionAgentEditModes: state.sessionAgentEditModes, - }); -}); - -usePermissionStore.subscribe((state, prevState) => { - if (state.permissions === prevState.permissions) { - return; - } - - useSessionStore.setState({ - permissions: state.permissions, - }); -}); - -useQuestionStore.subscribe((state, prevState) => { - if (state.questions === prevState.questions) { - return; - } - - useSessionStore.setState({ - questions: state.questions, - }); -}); - -useDirectoryStore.subscribe((state, prevState) => { - const nextDirectory = normalizePath(state.currentDirectory ?? null); - const prevDirectory = normalizePath(prevState.currentDirectory ?? null); - if (nextDirectory === prevDirectory) { - return; - } - - const draft = useSessionStore.getState().newSessionDraft; - if (!draft?.open) { - return; - } - - if (draft.pendingWorktreeRequestId || draft.bootstrapPendingDirectory || draft.preserveDirectoryOverride) { - return; - } - - const draftDirectory = normalizePath(draft.directoryOverride); - if (draftDirectory && draftDirectory !== prevDirectory) { - return; - } - - const projects = useProjectsStore.getState().projects; - const resolvedProject = resolveDraftProjectForDirectory( - projects, - useSessionStore.getState().availableWorktreesByProject, - nextDirectory, - ); - - useSessionStore.setState((store) => ({ - newSessionDraft: { - ...store.newSessionDraft, - selectedProjectId: resolvedProject?.id ?? store.newSessionDraft.selectedProjectId ?? null, - directoryOverride: nextDirectory, - parentID: null, - }, - })); - - persistDraftTarget({ - projectId: resolvedProject?.id ?? draft.selectedProjectId ?? null, - directory: nextDirectory, - }); -}); - -const bootDraftOpen = useSessionStore.getState().newSessionDraft?.open; - -useSessionStore.setState({ - sessions: useSessionManagementStore.getState().sessions, - currentSessionId: bootDraftOpen ? null : useSessionManagementStore.getState().currentSessionId, - lastLoadedDirectory: useSessionManagementStore.getState().lastLoadedDirectory, - isLoading: useSessionManagementStore.getState().isLoading, - error: useSessionManagementStore.getState().error, - webUICreatedSessions: useSessionManagementStore.getState().webUICreatedSessions, - worktreeMetadata: useSessionManagementStore.getState().worktreeMetadata, - availableWorktrees: useSessionManagementStore.getState().availableWorktrees, - availableWorktreesByProject: useSessionManagementStore.getState().availableWorktreesByProject, - messages: useMessageStore.getState().messages, - sessionMemoryState: useMessageStore.getState().sessionMemoryState, - sessionHistoryMeta: useMessageStore.getState().sessionHistoryMeta, - messageStreamStates: useMessageStore.getState().messageStreamStates, - sessionCompactionUntil: useMessageStore.getState().sessionCompactionUntil, - sessionAbortFlags: useMessageStore.getState().sessionAbortFlags, - streamingMessageIds: useMessageStore.getState().streamingMessageIds, - abortControllers: useMessageStore.getState().abortControllers, - lastUsedProvider: useMessageStore.getState().lastUsedProvider, - isSyncing: useMessageStore.getState().isSyncing, - permissions: usePermissionStore.getState().permissions, - questions: useQuestionStore.getState().questions, - attachedFiles: useFileStore.getState().attachedFiles, - sessionModelSelections: useContextStore.getState().sessionModelSelections, - sessionAgentSelections: useContextStore.getState().sessionAgentSelections, - sessionAgentModelSelections: useContextStore.getState().sessionAgentModelSelections, - currentAgentContext: useContextStore.getState().currentAgentContext, - sessionContextUsage: useContextStore.getState().sessionContextUsage, - sessionAgentEditModes: useContextStore.getState().sessionAgentEditModes, - abortPromptSessionId: null, - abortPromptExpiresAt: null, -}); - -if (typeof window !== "undefined") { - window.__zustand_session_store__ = useSessionStore; -} diff --git a/packages/ui/src/stores/useTodoStore.ts b/packages/ui/src/stores/useTodoStore.ts deleted file mode 100644 index 4e1818a8..00000000 --- a/packages/ui/src/stores/useTodoStore.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { create } from "zustand"; -import { devtools } from "zustand/middleware"; - -import { opencodeClient } from "@/lib/opencode/client"; -import { useSessionStore } from "./useSessionStore"; - -export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; -export type TodoPriority = "high" | "medium" | "low"; - -export interface TodoItem { - id: string; - content: string; - status: TodoStatus; - priority: TodoPriority; -} - -interface TodoStore { - // Map of sessionId -> todos - sessionTodos: Map; - isLoading: boolean; - - // Actions - loadTodos: (sessionId: string) => Promise; - updateTodos: (sessionId: string, todos: TodoItem[]) => void; - getTodosForSession: (sessionId: string) => TodoItem[]; - clearTodos: (sessionId: string) => void; -} - -type RawTodo = { id: string; content: string; status: string; priority: string }; - -const normalizeTodo = (todo: RawTodo): TodoItem => ({ - id: todo.id, - content: todo.content, - status: (todo.status as TodoStatus) || "pending", - priority: (todo.priority as TodoPriority) || "medium", -}); - -export const useTodoStore = create()( - devtools( - (set, get) => ({ - sessionTodos: new Map(), - isLoading: false, - - loadTodos: async (sessionId: string) => { - if (!sessionId) return; - - set({ isLoading: true }); - - try { - const directory = useSessionStore.getState().getDirectoryForSession(sessionId); - const rawTodos = directory - ? await opencodeClient.withDirectory(directory, () => opencodeClient.getSessionTodos(sessionId)) - : await opencodeClient.getSessionTodos(sessionId); - const todos = rawTodos.map(normalizeTodo); - - set((state) => { - const newMap = new Map(state.sessionTodos); - newMap.set(sessionId, todos); - return { sessionTodos: newMap, isLoading: false }; - }); - } catch (error) { - console.warn("[TodoStore] Failed to load todos:", error); - set({ isLoading: false }); - } - }, - - updateTodos: (sessionId: string, todos: TodoItem[]) => { - set((state) => { - const newMap = new Map(state.sessionTodos); - newMap.set(sessionId, todos); - return { sessionTodos: newMap }; - }); - }, - - getTodosForSession: (sessionId: string) => { - return get().sessionTodos.get(sessionId) || []; - }, - - clearTodos: (sessionId: string) => { - set((state) => { - const newMap = new Map(state.sessionTodos); - newMap.delete(sessionId); - return { sessionTodos: newMap }; - }); - }, - }), - { name: "todo-store" } - ) -); - -// Helper to handle SSE todo.updated events -export const handleTodoUpdatedEvent = ( - sessionId: string, - todos: RawTodo[] -): void => { - const normalizedTodos = todos.map(normalizeTodo); - useTodoStore.getState().updateTodos(sessionId, normalizedTodos); -}; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 934ed070..89b26e02 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -12,6 +12,7 @@ export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; export type ChatRenderMode = 'sorted' | 'live'; export type ActivityRenderMode = 'collapsed' | 'summary'; +export type SessionRetentionAction = 'archive' | 'delete'; type ContextPanelTab = { id: string; @@ -503,6 +504,7 @@ interface UIStore { showDeletionDialog: boolean; autoDeleteEnabled: boolean; autoDeleteAfterDays: number; + sessionRetentionAction: SessionRetentionAction; autoDeleteLastRunAt: number | null; messageLimit: number; fontSize: number; @@ -617,6 +619,7 @@ interface UIStore { setShowDeletionDialog: (value: boolean) => void; setAutoDeleteEnabled: (value: boolean) => void; setAutoDeleteAfterDays: (days: number) => void; + setSessionRetentionAction: (value: SessionRetentionAction) => void; setAutoDeleteLastRunAt: (timestamp: number | null) => void; setMessageLimit: (value: number) => void; setFontSize: (size: number) => void; @@ -730,6 +733,7 @@ export const useUIStore = create()( showDeletionDialog: true, autoDeleteEnabled: false, autoDeleteAfterDays: 30, + sessionRetentionAction: 'archive', autoDeleteLastRunAt: null, messageLimit: 200, fontSize: 100, @@ -1313,6 +1317,10 @@ export const useUIStore = create()( set({ autoDeleteAfterDays: clampedDays }); }, + setSessionRetentionAction: (value) => { + set({ sessionRetentionAction: value }); + }, + setAutoDeleteLastRunAt: (timestamp) => { set({ autoDeleteLastRunAt: timestamp }); }, @@ -1835,6 +1843,7 @@ export const useUIStore = create()( showDeletionDialog: state.showDeletionDialog, autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, + sessionRetentionAction: state.sessionRetentionAction, autoDeleteLastRunAt: state.autoDeleteLastRunAt, messageLimit: state.messageLimit, fontSize: state.fontSize, diff --git a/packages/ui/src/stores/utils/messageUtils.ts b/packages/ui/src/stores/utils/messageUtils.ts index b4c7aad2..50274297 100644 --- a/packages/ui/src/stores/utils/messageUtils.ts +++ b/packages/ui/src/stores/utils/messageUtils.ts @@ -214,3 +214,73 @@ export const normalizeStreamingPart = (incoming: Part, existing?: Part): Part => return normalized as Part; }; + +const deepEqualRecord = (left: Record, right: Record): boolean => { + const keys = new Set([ + ...Object.keys(left), + ...Object.keys(right), + ]); + + for (const key of keys) { + const leftValue = left[key]; + const rightValue = right[key]; + + if (Array.isArray(leftValue) || Array.isArray(rightValue)) { + if (!Array.isArray(leftValue) || !Array.isArray(rightValue) || leftValue.length !== rightValue.length) { + return false; + } + for (let index = 0; index < leftValue.length; index += 1) { + if (!deepEqualUnknown(leftValue[index], rightValue[index])) { + return false; + } + } + continue; + } + + if (!deepEqualUnknown(leftValue, rightValue)) { + return false; + } + } + + return true; +}; + +const deepEqualUnknown = (left: unknown, right: unknown): boolean => { + if (left === right) { + return true; + } + + if (!left || !right) { + return false; + } + + if (typeof left !== typeof right) { + return false; + } + + if (typeof left === 'object' && typeof right === 'object') { + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (!deepEqualUnknown(left[index], right[index])) { + return false; + } + } + return true; + } + + return deepEqualRecord(left as Record, right as Record); + } + + return false; +}; + +export const arePartsEquivalent = (left: Part | undefined, right: Part | undefined): boolean => { + if (!left || !right) { + return left === right; + } + + return deepEqualUnknown(left, right); +}; diff --git a/packages/ui/src/stores/utils/streamDebug.ts b/packages/ui/src/stores/utils/streamDebug.ts index f478f673..5ee628d9 100644 --- a/packages/ui/src/stores/utils/streamDebug.ts +++ b/packages/ui/src/stores/utils/streamDebug.ts @@ -15,3 +15,241 @@ export const sessionStatusDebugEnabled = (): boolean => { return false; } }; + +const STREAM_PERF_STORAGE_KEY = 'openchamber_stream_perf'; + +type PerfCounter = { + count: number; + total: number; + max: number; + last: number; +}; + +type StreamPerfState = { + counters: Map; + startedAt: number; + lastUpdatedAt: number; +}; + +export type StreamPerfEntry = { + metric: string; + count: number; + avg: number; + max: number; + total: number; + last: number; +}; + +export type StreamPerfSnapshot = { + enabled: boolean; + startedAt: number | null; + lastUpdatedAt: number | null; + durationMs: number; + entries: StreamPerfEntry[]; +}; + +declare global { + interface Window { + __openchamberStreamPerfState?: StreamPerfState; + __openchamberVsCodeStreamPerfState?: { + counters: Map; + lastReportAt?: number; + lastUpdatedAt?: number; + reportTimer?: number | null; + startedAt?: number; + }; + } +} + +export const streamPerfEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const nowMs = (): number => { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); +}; + +const ensureStreamPerfState = (): StreamPerfState | null => { + if (!streamPerfEnabled() || typeof window === 'undefined') { + return null; + } + + if (!window.__openchamberStreamPerfState) { + const startedAt = Date.now(); + window.__openchamberStreamPerfState = { + counters: new Map(), + startedAt, + lastUpdatedAt: startedAt, + }; + } + + return window.__openchamberStreamPerfState; +}; + +const normalizePerfEntries = (counters: Map): StreamPerfEntry[] => { + return Array.from(counters.entries()) + .map(([metric, bucket]) => ({ + metric, + count: bucket.count, + avg: bucket.count > 0 ? Number((bucket.total / bucket.count).toFixed(3)) : 0, + max: Number(bucket.max.toFixed(3)), + total: Number(bucket.total.toFixed(3)), + last: Number(bucket.last.toFixed(3)), + })) + .sort((a, b) => b.total - a.total || b.count - a.count); +}; + +const updatePerfCounter = (metric: string, amount: number): void => { + const state = ensureStreamPerfState(); + if (!state) { + return; + } + + const bucket = state.counters.get(metric) ?? { count: 0, total: 0, max: 0, last: 0 }; + bucket.count += 1; + bucket.total += amount; + bucket.max = Math.max(bucket.max, amount); + bucket.last = amount; + state.counters.set(metric, bucket); + state.lastUpdatedAt = Date.now(); +}; + +export const setStreamPerfEnabled = (enabled: boolean): void => { + if (typeof window === 'undefined') { + return; + } + + try { + if (enabled) { + window.localStorage.setItem(STREAM_PERF_STORAGE_KEY, '1'); + window.__openchamberStreamPerfState = { + counters: new Map(), + startedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + return; + } + + window.localStorage.removeItem(STREAM_PERF_STORAGE_KEY); + delete window.__openchamberStreamPerfState; + delete window.__openchamberVsCodeStreamPerfState; + } catch { + // ignore storage failures in debug helper + } +}; + +export const resetStreamPerf = (): void => { + if (typeof window === 'undefined') { + return; + } + + if (streamPerfEnabled()) { + window.__openchamberStreamPerfState = { + counters: new Map(), + startedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + } + + if (window.__openchamberVsCodeStreamPerfState) { + window.__openchamberVsCodeStreamPerfState = { + ...window.__openchamberVsCodeStreamPerfState, + counters: new Map(), + startedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + } +}; + +export const getStreamPerfSnapshot = (): StreamPerfSnapshot => { + if (typeof window === 'undefined') { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + const state = window.__openchamberStreamPerfState; + if (!streamPerfEnabled() || !state) { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + return { + enabled: true, + startedAt: state.startedAt, + lastUpdatedAt: state.lastUpdatedAt, + durationMs: Math.max(0, Date.now() - state.startedAt), + entries: normalizePerfEntries(state.counters), + }; +}; + +export const getVsCodeStreamPerfSnapshot = (): StreamPerfSnapshot => { + if (typeof window === 'undefined') { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + const state = window.__openchamberVsCodeStreamPerfState; + if (!streamPerfEnabled() || !state) { + return { + enabled: false, + startedAt: null, + lastUpdatedAt: null, + durationMs: 0, + entries: [], + }; + } + + const startedAt = typeof state.startedAt === 'number' ? state.startedAt : null; + const lastUpdatedAt = typeof state.lastUpdatedAt === 'number' ? state.lastUpdatedAt : null; + return { + enabled: true, + startedAt, + lastUpdatedAt, + durationMs: startedAt ? Math.max(0, Date.now() - startedAt) : 0, + entries: normalizePerfEntries(state.counters), + }; +}; + +export const streamPerfCount = (metric: string, count = 1): void => { + updatePerfCounter(metric, count); +}; + +export const streamPerfObserve = (metric: string, value: number): void => { + updatePerfCounter(metric, value); +}; + +export const streamPerfMeasure = (metric: string, fn: () => T): T => { + if (!streamPerfEnabled()) { + return fn(); + } + + const start = nowMs(); + try { + return fn(); + } finally { + updatePerfCounter(metric, nowMs() - start); + } +}; diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md new file mode 100644 index 00000000..cebf8ab2 --- /dev/null +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -0,0 +1,217 @@ +# Sync architecture, event handling & store update rules + +## Scope + +This document covers the current client-side session/data architecture in `packages/ui/src/sync` and the rules for updating stores safely. + +There are **two distinct session data scopes** in the UI: + +1. **Directory-scoped sync stores** + - Owned by the sync layer child stores created in `sync-context.tsx` + - Source for per-directory live session/message/part/permission/question state + - Backed by SSE / directory-scoped polling + - Read via hooks like `useSessions()`, `useDirectorySync()`, `getSyncSessions()`, `getDirectoryState()` + +2. **Global sessions cache** + - Owned by `packages/ui/src/stores/useGlobalSessionsStore.ts` + - Shared source of truth for the Sessions sidebar global lists and Session Retention cleanup + - Holds: + - global active sessions + - global archived sessions + - active sessions indexed by directory + +These two scopes are intentionally different. + +### Why both exist + +The directory-scoped sync stores are **not** a complete global view. + +- They are created lazily per directory +- They only contain data for directories initialized in the current app session +- They are optimized for live per-directory domain data +- They do not maintain the complete global active+archived session view needed by the sidebar and retention settings + +So: + +- Use the **directory sync stores** for per-directory live session/message state +- Use the **global sessions store** for sidebar/retention global session lists + +## Ownership map + +| Layer / Store | Owns | Scope | +|---|---|---| +| child directory stores in `sync-context.tsx` | `session`, `message`, `part`, `permission`, `question`, etc. | One directory | +| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state | +| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists | +| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | +| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state | +| `selection-store.ts` | Model/agent/variant selections | App UI state | +| `voice-store.ts` | Voice state | App UI state | + +## Session list rules + +### Directory-scoped session list + +Use the directory-scoped sync store when the UI needs the live session list for the **current directory**. + +Examples: + +- current chat/session switching +- per-directory session/message bootstrap +- session/message/part SSE updates + +### Global session list + +Use `useGlobalSessionsStore` when the UI needs a **shared global session view**. + +Current consumers: + +- `SessionSidebar.tsx` +- `useSessionAutoCleanup.ts` + +### Mutation responsibility + +`useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by: + +1. shared global fetch/reconciliation via `loadSessions()` / `refreshGlobalSessions()` +2. direct mutation from session actions after successful SDK calls: + - create + - title update + - share + - unshare + - archive + - delete + - retention cleanup batch archive/delete + +This keeps sidebar/retention UI responsive without requiring a refetch after every change. + +## Session action rules + +Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists. + +Rules: + +1. If an action mutates session list membership or visible session metadata, update `useGlobalSessionsStore` there. +2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct. +3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls. + +Examples of global-store updates performed in `session-actions.ts`: + +- `createSession()` -> `upsertSession(session)` +- `updateSessionTitle()` -> `upsertSession(result.data)` +- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)` +- `archiveSession()` -> `archiveSessions([id], archivedAt)` +- `deleteSession()` -> `removeSessions([id])` + +## The golden rule + +When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly. + +```typescript +// WRONG — clones everything, breaks referential equality for all subscribers +const draft = { + ...current, + session: [...current.session], + message: { ...current.message }, + part: { ...current.part }, + permission: { ...current.permission }, + // ... +} + +// RIGHT — only clone what this event type touches +const draft = { ...current } +switch (event.type) { + case "message.part.delta": + draft.part = { ...current.part } + break +} +``` + +## Why this matters + +Zustand skips re-renders when a selector returns the same reference (`Object.is`). If you spread `session: [...current.session]` but the event only modifies `part`, the `session` array gets a new reference. Every component using `useSessions()` re-renders for nothing. + +During streaming, `message.part.delta` fires ~60 times/sec. Eagerly cloning all fields caused every subscriber in the entire app to re-render 60/sec — a 10x overhead. Targeted cloning reduced MessageList renders from ~1972 to ~296 per session. + +## Event → field mapping + +Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`: + +| Event type | Fields to clone | +|---|---| +| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part` | +| `session.diff` | `session_diff` | +| `session.status` | `session_status` | +| `todo.updated` | `todo` | +| `message.updated` | `message` | +| `message.removed` | `message`, `part` | +| `message.part.updated/removed/delta` | `part` | +| `vcs.branch.updated` | (none — mutates `draft.vcs` directly) | +| `permission.asked/replied` | `permission` | +| `question.asked/replied/rejected` | `question` | +| `lsp.updated` | `lsp` | + +## Adding a new event type + +1. Add the case to the event reducer (`event-reducer.ts`) +2. Add a corresponding case to the switch in `handleDirectoryEvent` (`sync-context.tsx`) that clones **only** the fields your reducer writes to +3. If your event fires frequently (more than a few times per second), verify that unrelated components don't re-render — check with the stream perf counters + +## Selector hygiene + +Select leaf values, not containers: + +```typescript +// WRONG — returns entire Map/object, new reference on any mutation +useDirectorySync((s) => s.permission) + +// RIGHT — returns the value for one key, stable unless that key changes +useDirectorySync((s) => s.permission[sessionID] ?? EMPTY) +``` + +Same applies to `useStreamingStore` — select `.get(key)` not the Map itself. + +## Store splitting pattern + +### Why split + +A single Zustand store with N properties means every subscriber's selector re-evaluates on every state change — even if the change is unrelated to what that subscriber reads. During streaming, `sessionMemoryState` updates ~60/sec. Before the split, all 68+ `useSessionUIStore` subscribers re-evaluated on each update. After splitting into focused stores, only `useViewportStore` subscribers (2-3 components) re-evaluate. + +The optimization multiplies with targeted event cloning: fewer new references per event × fewer subscribers per store = dramatically less work per SSE frame. + +### The stores + +| Store | Owns | When it changes | +|-------|------|-----------------| +| `session-ui-store.ts` | Session selection, draft lifecycle, abort, worktree, SDK actions | Session switch, draft open/close | +| `voice-store.ts` | Voice connection/activity state | Voice toggle | +| `input-store.ts` | Pending input text, synthetic parts, attached files | User typing, file attach, revert/fork | +| `selection-store.ts` | Per-session model/agent/variant choices | Model/agent picker | +| `viewport-store.ts` | Scroll anchors, session memory state, sync status | Streaming, scroll, session switch | + +### Rules for new UI state + +1. **Never add to `session-ui-store`** unless it's session selection, draft lifecycle, or abort state +2. **Group by change frequency** — state that changes during streaming (viewport, memory) must not live with state that changes on user action (selections, input) +3. **Group by subscriber set** — if only 2 components read a value, it should be in a store that only those 2 components subscribe to +4. **Prefer a new store over growing an existing one** if the new state has different subscribers or change frequency +5. **Cross-store reads use `.getState()`** — actions in one store that need to read another store call `useOtherStore.getState()` (imperative, no subscription) + +### Anti-patterns + +```typescript +// WRONG — stuffing unrelated state into one store +const useEverythingStore = create(() => ({ + voiceMode: "idle", + scrollAnchor: 0, + selectedModel: null, + pendingInput: "", + // 20 more fields... +})) + +// RIGHT — separate stores by concern + change frequency +const useVoiceStore = create(() => ({ voiceMode: "idle" })) +const useViewportStore = create(() => ({ scrollAnchor: 0 })) +const useSelectionStore = create(() => ({ selectedModel: null })) +const useInputStore = create(() => ({ pendingInput: "" })) +``` diff --git a/packages/ui/src/sync/binary.ts b/packages/ui/src/sync/binary.ts new file mode 100644 index 00000000..03c585cf --- /dev/null +++ b/packages/ui/src/sync/binary.ts @@ -0,0 +1,46 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Binary { + export function search( + array: readonly T[], + id: string, + compare: (item: T) => string, + ): { found: boolean; index: number } { + let left = 0 + let right = array.length - 1 + + while (left <= right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId === id) { + return { found: true, index: mid } + } else if (midId < id) { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return { found: false, index: left } + } + + export function insert(array: T[], item: T, compare: (item: T) => string): T[] { + const id = compare(item) + let left = 0 + let right = array.length + + while (left < right) { + const mid = Math.floor((left + right) / 2) + const midId = compare(array[mid]) + + if (midId < id) { + left = mid + 1 + } else { + right = mid + } + } + + array.splice(left, 0, item) + return array + } +} diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts new file mode 100644 index 00000000..4ad2c4ce --- /dev/null +++ b/packages/ui/src/sync/bootstrap.ts @@ -0,0 +1,161 @@ +import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { retry } from "./retry" +import type { GlobalState, State } from "./types" + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +function groupBySession(input: T[]) { + return input.reduce>((acc, item) => { + if (!item?.id || !item.sessionID) return acc + const list = acc[item.sessionID] + if (list) list.push(item) + else acc[item.sessionID] = [item] + return acc + }, {}) +} + +function projectID(directory: string, projects: Project[]) { + return projects.find( + (project) => project.worktree === directory || project.sandboxes?.includes(directory), + )?.id +} + +// --------------------------------------------------------------------------- +// Bootstrap global state +// --------------------------------------------------------------------------- + +export async function bootstrapGlobal( + sdk: OpencodeClient, + set: (patch: Partial) => void, +) { + const results = await Promise.allSettled([ + retry(() => sdk.path.get().then((x) => set({ path: x.data! }))), + retry(() => sdk.global.config.get().then((x) => set({ config: x.data! }))), + retry(() => + sdk.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p): p is Project => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .sort((a, b) => cmp(a.id, b.id)) + set({ projects }) + }), + ), + retry(() => sdk.provider.list().then((x) => set({ providers: x.data! }))), + ]) + + const errors = results + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason) + if (errors.length) { + console.error("[bootstrap] global bootstrap failed", errors[0]) + } + + set({ ready: true }) +} + +// --------------------------------------------------------------------------- +// Bootstrap per-directory state +// --------------------------------------------------------------------------- + +export async function bootstrapDirectory(input: { + directory: string + sdk: OpencodeClient + getState: () => State + set: (patch: Partial) => void + global: { + config: Record + projects: Project[] + providers: { all: unknown[]; connected: unknown[]; default: Record } + } + loadSessions: (directory: string) => Promise | void +}) { + const { directory, sdk, getState, set, global: g } = input + const state = getState() + const loading = state.status !== "complete" + + // Seed from global state while we fetch directory-specific data + const seededProject = projectID(directory, g.projects) + if (seededProject) set({ project: seededProject }) + if (state.provider.all.length === 0 && g.providers.all.length > 0) { + set({ provider: g.providers as State["provider"] }) + } + if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) { + set({ config: g.config as State["config"] }) + } + if (loading) set({ status: "partial" }) + + const results = await Promise.allSettled([ + seededProject + ? Promise.resolve() + : retry(() => sdk.project.current().then((x) => set({ project: x.data!.id }))), + retry(() => sdk.provider.list().then((x) => set({ provider: x.data! }))), + retry(() => sdk.app.agents().then((x) => set({ agent: x.data ?? [] }))), + retry(() => sdk.config.get().then((x) => set({ config: x.data! }))), + retry(() => + sdk.path.get().then((x) => { + set({ path: x.data! }) + const next = projectID(x.data?.directory ?? directory, g.projects) + if (next) set({ project: next }) + }), + ), + retry(() => sdk.command.list().then((x) => set({ command: x.data ?? [] }))), + retry(() => sdk.session.status().then((x) => set({ session_status: x.data! }))), + input.loadSessions(directory), + retry(() => sdk.mcp.status().then((x) => set({ mcp: x.data! }))), + retry(() => sdk.lsp.status().then((x) => set({ lsp: x.data! }))), + retry(() => + sdk.vcs.get().then((x) => { + const current = getState() + set({ vcs: x.data ?? current.vcs }) + }), + ), + retry(() => + sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + const permission: Record = {} + // Clear sessions no longer having permissions + const current = getState() + for (const sessionID of Object.keys(current.permission ?? {})) { + if (!grouped[sessionID]) permission[sessionID] = [] + } + // Set grouped permissions sorted by id + for (const [sessionID, perms] of Object.entries(grouped)) { + permission[sessionID] = perms + .filter((p) => !!p?.id) + .sort((a, b) => cmp(a.id, b.id)) + } + set({ permission }) + }), + ), + retry(() => + sdk.question.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID), + ) + const question: Record = {} + const current = getState() + for (const sessionID of Object.keys(current.question ?? {})) { + if (!grouped[sessionID]) question[sessionID] = [] + } + for (const [sessionID, questions] of Object.entries(grouped)) { + question[sessionID] = questions + .filter((q) => !!q?.id) + .sort((a, b) => cmp(a.id, b.id)) + } + set({ question }) + }), + ), + ]) + + const errors = results + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason) + if (errors.length) { + console.error(`[bootstrap] directory bootstrap failed for ${directory}`, errors[0]) + return + } + + if (loading) set({ status: "complete" }) +} diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts new file mode 100644 index 00000000..6e757d65 --- /dev/null +++ b/packages/ui/src/sync/child-store.ts @@ -0,0 +1,172 @@ +import { create, type StoreApi } from "zustand" +import type { DirState, State } from "./types" +import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS } from "./types" +import { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction" +import { readDirCache, persistVcs, persistProjectMeta, persistIcon } from "./persist-cache" + +export type DirectoryStore = State & { + /** Apply a partial state update */ + patch: (partial: Partial) => void + /** Replace state wholesale (used during bootstrap) */ + replace: (next: State) => void +} + +function createDirectoryStore(directory: string): StoreApi { + // Restore cached metadata from localStorage + const cached = readDirCache(directory) + + const store = create()((set) => ({ + ...INITIAL_STATE, + vcs: cached.vcs ?? INITIAL_STATE.vcs, + projectMeta: cached.projectMeta ?? INITIAL_STATE.projectMeta, + icon: cached.icon ?? INITIAL_STATE.icon, + patch: (partial) => set(partial), + replace: (next) => set(next), + })) + + // Subscribe to persist metadata changes back to localStorage + store.subscribe((state, prev) => { + if (state.vcs !== prev.vcs) persistVcs(directory, state.vcs) + if (state.projectMeta !== prev.projectMeta) persistProjectMeta(directory, state.projectMeta) + if (state.icon !== prev.icon) persistIcon(directory, state.icon) + }) + + return store +} + +export class ChildStoreManager { + readonly children = new Map>() + private readonly lifecycle = new Map() + private readonly pins = new Map() + private readonly disposers = new Map void>() + + private onBootstrap?: (directory: string) => void + private onDispose?: (directory: string) => void + private isBooting?: (directory: string) => boolean + private isLoadingSessions?: (directory: string) => boolean + + configure(callbacks: { + onBootstrap?: (directory: string) => void + onDispose?: (directory: string) => void + isBooting?: (directory: string) => boolean + isLoadingSessions?: (directory: string) => boolean + }) { + this.onBootstrap = callbacks.onBootstrap + this.onDispose = callbacks.onDispose + this.isBooting = callbacks.isBooting + this.isLoadingSessions = callbacks.isLoadingSessions + } + + mark(directory: string) { + if (!directory) return + this.lifecycle.set(directory, { lastAccessAt: Date.now() }) + this.runEviction(directory) + } + + pin(directory: string) { + if (!directory) return + this.pins.set(directory, (this.pins.get(directory) ?? 0) + 1) + this.mark(directory) + } + + unpin(directory: string) { + if (!directory) return + const next = (this.pins.get(directory) ?? 0) - 1 + if (next > 0) { + this.pins.set(directory, next) + return + } + this.pins.delete(directory) + this.runEviction() + } + + pinned(directory: string) { + return (this.pins.get(directory) ?? 0) > 0 + } + + ensureChild(directory: string, options?: { bootstrap?: boolean }): StoreApi { + if (!directory) throw new Error("No directory provided to ensureChild") + + let store = this.children.get(directory) + if (!store) { + store = createDirectoryStore(directory) + this.children.set(directory, store) + } + + this.mark(directory) + + const shouldBootstrap = options?.bootstrap ?? true + if (shouldBootstrap && store.getState().status === "loading") { + this.onBootstrap?.(directory) + } + + return store + } + + getChild(directory: string): StoreApi | undefined { + return this.children.get(directory) + } + + disposeDirectory(directory: string): boolean { + if ( + !canDisposeDirectory({ + directory, + hasStore: this.children.has(directory), + pinned: this.pinned(directory), + booting: this.isBooting?.(directory) ?? false, + loadingSessions: this.isLoadingSessions?.(directory) ?? false, + }) + ) { + return false + } + + this.lifecycle.delete(directory) + this.children.delete(directory) + const dispose = this.disposers.get(directory) + if (dispose) { + dispose() + this.disposers.delete(directory) + } + this.onDispose?.(directory) + return true + } + + runEviction(skip?: string) { + const stores = [...this.children.keys()] + if (stores.length === 0) return + const list = pickDirectoriesToEvict({ + stores, + state: this.lifecycle, + pins: new Set(stores.filter((d) => this.pinned(d))), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + now: Date.now(), + }).filter((d) => d !== skip) + for (const directory of list) { + this.disposeDirectory(directory) + } + } + + /** Apply a state mutation to a directory's store */ + update(directory: string, fn: (state: State) => Partial) { + const store = this.children.get(directory) + if (!store) return + const current = store.getState() + const patch = fn(current) + store.setState(patch) + } + + /** Get current state of a directory store (snapshot) */ + getState(directory: string): State | undefined { + return this.children.get(directory)?.getState() + } + + disposeAll() { + for (const directory of [...this.children.keys()]) { + this.children.delete(directory) + } + this.lifecycle.clear() + this.pins.clear() + this.disposers.clear() + } +} diff --git a/packages/ui/src/sync/content-cache.ts b/packages/ui/src/sync/content-cache.ts new file mode 100644 index 00000000..4e552be8 --- /dev/null +++ b/packages/ui/src/sync/content-cache.ts @@ -0,0 +1,93 @@ +/** + * File content LRU cache — dual constraint eviction. + * Port of OpenCode's content-cache.ts. + * + * Evicts when either entry count exceeds MAX_FILE_CONTENT_ENTRIES + * or total byte estimate exceeds MAX_FILE_CONTENT_BYTES. + * Uses Map insertion order as LRU (oldest = first key). + */ + +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 // 20 MB + +// LRU map: path → approximate bytes. Map insertion order = access order. +const lru = new Map() +let total = 0 + +/** Estimate byte size of a string (UTF-16 → ~2 bytes per char). */ +export function approxStringBytes(content: string): number { + return content.length * 2 +} + +function setBytes(path: string, nextBytes: number) { + const prev = lru.get(path) + if (prev !== undefined) total -= prev + lru.delete(path) + lru.set(path, nextBytes) + total += nextBytes +} + +function touch(path: string, bytes?: number) { + const prev = lru.get(path) + if (prev === undefined && bytes === undefined) return + setBytes(path, bytes ?? prev ?? 0) +} + +function remove(path: string) { + const prev = lru.get(path) + if (prev === undefined) return + lru.delete(path) + total -= prev +} + +/** + * Evict entries until both constraints are satisfied. + * @param keep - paths to preserve (moved to end of LRU if encountered) + * @param evict - callback to actually remove content from the store + */ +export function evictContentLru(keep: Set | undefined, evict: (path: string) => void) { + const safeSet = keep ?? new Set() + + while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) { + const path = lru.keys().next().value + if (!path) return + + if (safeSet.has(path)) { + touch(path) + if (lru.size <= safeSet.size) return + continue + } + + remove(path) + evict(path) + } +} + +export function resetContentLru() { + lru.clear() + total = 0 +} + +export function setContentBytes(path: string, bytes: number) { + setBytes(path, bytes) +} + +export function removeContentBytes(path: string) { + remove(path) +} + +export function touchContent(path: string, bytes?: number) { + touch(path, bytes) +} + +export function getContentBytesTotal(): number { + return total +} + +export function getContentEntryCount(): number { + return lru.size +} + +export function hasContent(path: string): boolean { + return lru.has(path) +} diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts new file mode 100644 index 00000000..f91e059e --- /dev/null +++ b/packages/ui/src/sync/event-pipeline.ts @@ -0,0 +1,221 @@ +/** + * Event Pipeline — SSE connection, event coalescing, and batched flush. + * + * Plain closure API: + * const { cleanup } = createEventPipeline({ sdk, onEvent }) + * + * No class, no start/stop lifecycle. One pipeline per mount. + * Abort controller created once at init, cleaned up via returned cleanup fn. + */ + +import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2/client" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type QueuedEvent = { + directory: string + payload: Event +} + +export type FlushHandler = (events: QueuedEvent[]) => void + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const FLUSH_FRAME_MS = 16 +const STREAM_YIELD_MS = 8 +const RECONNECT_DELAY_MS = 250 +const HEARTBEAT_TIMEOUT_MS = 15_000 + +// --------------------------------------------------------------------------- +// Pipeline factory +// --------------------------------------------------------------------------- + +export function createEventPipeline(input: { + sdk: OpencodeClient + onEvent: (directory: string, payload: Event) => void +}) { + const { sdk, onEvent } = input + const abort = new AbortController() + + // Queue state + let queue: QueuedEvent[] = [] + let buffer: QueuedEvent[] = [] + const coalesced = new Map() + const staleDeltas = new Set() + let timer: ReturnType | undefined + let last = 0 + + const deltaKey = (directory: string, messageID: string, partID: string) => + `${directory}:${messageID}:${partID}` + + // Coalesce key — same-type events for the same entity replace earlier ones + const key = (directory: string, payload: Event): string | undefined => { + if (payload.type === "session.status") { + const props = payload.properties as { sessionID: string } + return `session.status:${directory}:${props.sessionID}` + } + if (payload.type === "lsp.updated") { + return `lsp.updated:${directory}` + } + if (payload.type === "message.part.updated") { + const part = (payload.properties as { part: { messageID: string; id: string } }).part + return `message.part.updated:${directory}:${part.messageID}:${part.id}` + } + return undefined + } + + // Flush — swap queue, dispatch events, skip stale deltas + const flush = () => { + if (timer) clearTimeout(timer) + timer = undefined + + if (queue.length === 0) return + + const events = queue + const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined + queue = buffer + buffer = events + queue.length = 0 + coalesced.clear() + staleDeltas.clear() + + last = Date.now() + // React 18 batches synchronous setState calls automatically, + // equivalent to SolidJS batch() + for (const event of events) { + if (skip && event.payload.type === "message.part.delta") { + const props = event.payload.properties as { messageID: string; partID: string } + if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue + } + onEvent(event.directory, event.payload) + } + + buffer.length = 0 + } + + const schedule = () => { + if (timer) return + const elapsed = Date.now() - last + timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) + } + + // Helpers + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const isAbortError = (error: unknown): boolean => + error instanceof DOMException && error.name === "AbortError" || + (typeof error === "object" && error !== null && (error as { name?: string }).name === "AbortError") + + let streamErrorLogged = false + let attempt: AbortController | undefined + let lastEventAt = Date.now() + let heartbeat: ReturnType | undefined + + const resetHeartbeat = () => { + lastEventAt = Date.now() + if (heartbeat) clearTimeout(heartbeat) + heartbeat = setTimeout(() => { + attempt?.abort() + }, HEARTBEAT_TIMEOUT_MS) + } + + const clearHeartbeat = () => { + if (!heartbeat) return + clearTimeout(heartbeat) + heartbeat = undefined + } + + // SSE loop — iterate SDK global event stream, enqueue with coalescing + void (async () => { + while (!abort.signal.aborted) { + attempt = new AbortController() + lastEventAt = Date.now() + const onAbort = () => { + attempt?.abort() + } + abort.signal.addEventListener("abort", onAbort) + + try { + const events = await sdk.global.event({ + signal: attempt.signal, + onSseError: (error: unknown) => { + if (isAbortError(error)) return + if (streamErrorLogged) return + streamErrorLogged = true + console.error("[event-pipeline] stream error", error) + }, + }) + + let yielded = Date.now() + resetHeartbeat() + + // Enqueue event with coalescing + stale delta tracking + for await (const event of events.stream) { + resetHeartbeat() + streamErrorLogged = false + const directory = (event as { directory?: string }).directory ?? "global" + const payload = (event as { payload?: Event }).payload ?? (event as unknown as Event) + if (!payload || typeof payload !== "object" || typeof (payload as { type?: unknown }).type !== "string") { + continue + } + const k = key(directory, payload) + if (k) { + const i = coalesced.get(k) + if (i !== undefined) { + queue[i] = { directory, payload } + if (payload.type === "message.part.updated") { + const part = (payload.properties as { part: { messageID: string; id: string } }).part + staleDeltas.add(deltaKey(directory, part.messageID, part.id)) + } + continue + } + coalesced.set(k, queue.length) + } + queue.push({ directory, payload }) + schedule() + + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0) + } + } catch (error) { + if (!isAbortError(error) && !streamErrorLogged) { + streamErrorLogged = true + console.error("[event-pipeline] stream failed", error) + } + } finally { + abort.signal.removeEventListener("abort", onAbort) + attempt = undefined + clearHeartbeat() + } + + if (abort.signal.aborted) return + await wait(RECONNECT_DELAY_MS) + } + })().finally(flush) + + // Visibility handler — flush immediately when tab becomes visible + const onVisibility = () => { + if (typeof document === "undefined") return + if (document.visibilityState !== "visible") return + if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return + attempt?.abort() + } + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibility) + } + + // Cleanup — abort SSE, flush remaining events, remove listeners + const cleanup = () => { + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", onVisibility) + } + abort.abort() + flush() + } + + return { cleanup } +} diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts new file mode 100644 index 00000000..6d710e7c --- /dev/null +++ b/packages/ui/src/sync/event-reducer.ts @@ -0,0 +1,345 @@ +import type { + Event, + FileDiff, + Message, + Part, + PermissionRequest, + Project, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" +import type { GlobalState, State } from "./types" +import { dropSessionCaches } from "./session-cache" +import { stripSessionDiffSnapshots } from "./sanitize" + +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + +// --------------------------------------------------------------------------- +// Global events +// --------------------------------------------------------------------------- + +export type GlobalEventResult = { + type: "refresh" +} | { + type: "project" + project: Project +} | null + +export function reduceGlobalEvent(event: Event): GlobalEventResult { + if (event.type === "global.disposed" || event.type === "server.connected") { + return { type: "refresh" } + } + if (event.type === "project.updated") { + return { type: "project", project: event.properties as Project } + } + return null +} + +export function applyGlobalProject(state: GlobalState, project: Project): GlobalState { + const projects = [...state.projects] + const result = Binary.search(projects, project.id, (s) => s.id) + if (result.found) { + projects[result.index] = { ...projects[result.index], ...project } + } else { + projects.splice(result.index, 0, project) + } + return { ...state, projects } +} + +// --------------------------------------------------------------------------- +// Directory events — mutates draft in place for batching efficiency. +// Caller MUST pass a mutable copy of State (e.g. structuredClone or spread). +// --------------------------------------------------------------------------- + +export function applyDirectoryEvent( + draft: State, + event: Event, + callbacks?: { + onRefresh?: (directory: string) => void + onLoadLsp?: () => void + onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void + }, +): boolean { + switch (event.type) { + case "server.instance.disposed": { + callbacks?.onRefresh?.("") + return false + } + + case "session.created": { + const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) + const sessions = draft.session + const result = Binary.search(sessions, info.id, (s) => s.id) + if (result.found) { + sessions[result.index] = info + } else { + sessions.splice(result.index, 0, info) + trimSessions(draft) + if (!info.parentID) draft.sessionTotal += 1 + } + return true + } + + case "session.updated": { + const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) + const sessions = draft.session + const result = Binary.search(sessions, info.id, (s) => s.id) + + if (info.time.archived) { + if (result.found) sessions.splice(result.index, 1) + cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo) + if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1) + return true + } + + if (result.found) { + sessions[result.index] = info + } else { + sessions.splice(result.index, 0, info) + trimSessions(draft) + } + return true + } + + case "session.deleted": { + const info = (event.properties as { info: Session }).info + const sessions = draft.session + const result = Binary.search(sessions, info.id, (s) => s.id) + if (result.found) sessions.splice(result.index, 1) + cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo) + if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1) + return true + } + + case "session.diff": { + const props = event.properties as { sessionID: string; diff: FileDiff[] } + draft.session_diff[props.sessionID] = props.diff + return true + } + + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: Todo[] } + draft.todo[props.sessionID] = props.todos + callbacks?.onSetSessionTodo?.(props.sessionID, props.todos) + return true + } + + case "session.status": { + const props = event.properties as { sessionID: string; status: SessionStatus } + draft.session_status[props.sessionID] = props.status + return true + } + + case "message.updated": { + const info = (event.properties as { info: Message }).info + const messages = draft.message[info.sessionID] + if (!messages) { + draft.message[info.sessionID] = [info] + return true + } + const result = Binary.search(messages, info.id, (m) => m.id) + if (result.found) { + // Skip message replacement if unchanged — preserves reference, avoids re-render + const existing = messages[result.index] + const unchanged = existing.role === info.role + && (existing as { finish?: unknown }).finish === (info as { finish?: unknown }).finish + && (existing.time as { completed?: number })?.completed === (info.time as { completed?: number })?.completed + if (unchanged) { + return false + } + const next = [...messages] + next[result.index] = info + draft.message[info.sessionID] = next + } else { + const next = [...messages] + next.splice(result.index, 0, info) + draft.message[info.sessionID] = next + } + return true + } + + case "message.removed": { + const props = event.properties as { sessionID: string; messageID: string } + const messages = draft.message[props.sessionID] + if (messages) { + const next = [...messages] + const result = Binary.search(next, props.messageID, (m) => m.id) + if (result.found) { + next.splice(result.index, 1) + draft.message[props.sessionID] = next + } + } + delete draft.part[props.messageID] + return true + } + + case "message.part.updated": { + const part = (event.properties as { part: Part }).part + if (SKIP_PARTS.has(part.type)) return false + const messageID = (part as { messageID: string }).messageID + const parts = draft.part[messageID] + if (!parts) { + draft.part[messageID] = [part] + return true + } + const next = [...parts] + const result = Binary.search(next, part.id, (p) => p.id) + if (result.found) { + next[result.index] = part + } else { + // Replace optimistic part (no sessionID) with server part of same type. + // Gate: only scan if the first part lacks sessionID (optimistic parts are + // always inserted first). Assistant messages never have optimistic parts, + // so this check is effectively free during streaming. + const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID + const optimisticIdx = hasOptimistic && (part.type === "text" || part.type === "file") + ? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID) + : -1 + if (optimisticIdx >= 0) { + next.splice(optimisticIdx, 1) + } + const insertResult = Binary.search(next, part.id, (p) => p.id) + next.splice(insertResult.index, 0, part) + } + draft.part[messageID] = next + return true + } + + case "message.part.removed": { + const props = event.properties as { messageID: string; partID: string } + const parts = draft.part[props.messageID] + if (!parts) return false + const result = Binary.search(parts, props.partID, (p) => p.id) + if (result.found) { + const next = [...parts] + next.splice(result.index, 1) + if (next.length === 0) { + delete draft.part[props.messageID] + } else { + draft.part[props.messageID] = next + } + return true + } + return false + } + + case "message.part.delta": { + const props = event.properties as { + messageID: string + partID: string + field: string + delta: string + } + const parts = draft.part[props.messageID] + if (!parts) return false + const result = Binary.search(parts, props.partID, (p) => p.id) + if (!result.found) return false + const existing = parts[result.index] as Record + const existingValue = existing[props.field] as string | undefined + // Create new Part object + new array so React detects the change + const next = [...parts] + next[result.index] = { ...existing, [props.field]: (existingValue ?? "") + props.delta } as Part + draft.part[props.messageID] = next + return true + } + + case "vcs.branch.updated": { + const props = event.properties as { branch: string } + if (draft.vcs?.branch === props.branch) return false + draft.vcs = { branch: props.branch } + return true + } + + case "permission.asked": { + const permission = event.properties as PermissionRequest + const permissions = draft.permission[permission.sessionID] ?? [] + draft.permission[permission.sessionID] = permissions + const result = Binary.search(permissions, permission.id, (p) => p.id) + if (result.found) { + permissions[result.index] = permission + } else { + permissions.splice(result.index, 0, permission) + } + return true + } + + case "permission.replied": { + const props = event.properties as { sessionID: string; requestID: string } + const permissions = draft.permission[props.sessionID] + if (!permissions) return false + const result = Binary.search(permissions, props.requestID, (p) => p.id) + if (result.found) { + permissions.splice(result.index, 1) + return true + } + return false + } + + case "question.asked": { + const question = event.properties as QuestionRequest + const questions = draft.question[question.sessionID] ?? [] + draft.question[question.sessionID] = questions + const result = Binary.search(questions, question.id, (q) => q.id) + if (result.found) { + questions[result.index] = question + } else { + questions.splice(result.index, 0, question) + } + return true + } + + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } + const questions = draft.question[props.sessionID] + if (!questions) return false + const result = Binary.search(questions, props.requestID, (q) => q.id) + if (result.found) { + questions.splice(result.index, 1) + return true + } + return false + } + + case "lsp.updated": { + callbacks?.onLoadLsp?.() + return false + } + + default: + return false + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function trimSessions(draft: State) { + if (draft.session.length <= draft.limit) return + // Keep sessions that have pending permissions (they need to stay visible) + const hasPermission = new Set( + Object.entries(draft.permission ?? {}) + .filter(([, perms]) => perms && perms.length > 0) + .map(([sessionID]) => sessionID), + ) + while (draft.session.length > draft.limit) { + // Remove from the beginning (oldest by sorted ID) + const candidate = draft.session[0] + if (hasPermission.has(candidate.id)) break + draft.session.shift() + } +} + +function cleanupSessionCaches( + draft: State, + sessionID: string, + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + if (!sessionID) return + setSessionTodo?.(sessionID, undefined) + dropSessionCaches(draft, [sessionID]) +} diff --git a/packages/ui/src/sync/eviction.ts b/packages/ui/src/sync/eviction.ts new file mode 100644 index 00000000..676a6ee1 --- /dev/null +++ b/packages/ui/src/sync/eviction.ts @@ -0,0 +1,28 @@ +import type { DisposeCheck, EvictPlan } from "./types" + +export function pickDirectoriesToEvict(input: EvictPlan) { + const overflow = Math.max(0, input.stores.length - input.max) + let pendingOverflow = overflow + const sorted = input.stores + .filter((dir) => !input.pins.has(dir)) + .slice() + .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) + const output: string[] = [] + for (const dir of sorted) { + const last = input.state.get(dir)?.lastAccessAt ?? 0 + const idle = input.now - last >= input.ttl + if (!idle && pendingOverflow <= 0) continue + output.push(dir) + if (pendingOverflow > 0) pendingOverflow -= 1 + } + return output +} + +export function canDisposeDirectory(input: DisposeCheck) { + if (!input.directory) return false + if (!input.hasStore) return false + if (input.pinned) return false + if (input.booting) return false + if (input.loadingSessions) return false + return true +} diff --git a/packages/ui/src/sync/global-sync-store.ts b/packages/ui/src/sync/global-sync-store.ts new file mode 100644 index 00000000..52dea534 --- /dev/null +++ b/packages/ui/src/sync/global-sync-store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand" +import type { GlobalState } from "./types" +import { INITIAL_GLOBAL_STATE } from "./types" + +export type GlobalSyncStore = GlobalState & { + actions: { + set: (patch: Partial) => void + reset: () => void + } +} + +export const useGlobalSyncStore = create()((set) => ({ + ...INITIAL_GLOBAL_STATE, + actions: { + set: (patch) => set(patch), + reset: () => set(INITIAL_GLOBAL_STATE), + }, +})) + +// Fine-grained selectors — use these in components for minimal re-renders +export const selectReady = (s: GlobalSyncStore) => s.ready +export const selectProjects = (s: GlobalSyncStore) => s.projects +export const selectProviders = (s: GlobalSyncStore) => s.providers +export const selectConfig = (s: GlobalSyncStore) => s.config +export const selectPath = (s: GlobalSyncStore) => s.path +export const selectReload = (s: GlobalSyncStore) => s.reload +export const selectSessionTodo = (s: GlobalSyncStore) => s.sessionTodo diff --git a/packages/ui/src/sync/index.ts b/packages/ui/src/sync/index.ts new file mode 100644 index 00000000..ae915772 --- /dev/null +++ b/packages/ui/src/sync/index.ts @@ -0,0 +1,152 @@ +// Core utilities +export { Binary } from "./binary" +export { retry, type RetryOptions } from "./retry" + +// Types +export type { State, GlobalState, ProjectMeta, DirState, EvictPlan, DisposeCheck, ChildOptions } from "./types" +export { + INITIAL_STATE, + INITIAL_GLOBAL_STATE, + MAX_DIR_STORES, + DIR_IDLE_TTL_MS, + SESSION_CACHE_LIMIT, + SESSION_RECENT_LIMIT, + SESSION_RECENT_WINDOW, +} from "./types" + +// Eviction +export { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction" + +// Session cache +export { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" + +// Optimistic +export { + applyOptimisticAdd, + applyOptimisticRemove, + mergeOptimisticPage, + mergeMessages, + type OptimisticItem, + type OptimisticStore, + type OptimisticAddInput, + type OptimisticRemoveInput, + type MessagePage, +} from "./optimistic" + +// Event reducer +export { + reduceGlobalEvent, + applyGlobalProject, + applyDirectoryEvent, + type GlobalEventResult, +} from "./event-reducer" + +// Event pipeline +export { createEventPipeline, type QueuedEvent, type FlushHandler } from "./event-pipeline" + +// Stores +export { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" +export { ChildStoreManager, type DirectoryStore } from "./child-store" + +// Bootstrap +export { bootstrapGlobal, bootstrapDirectory } from "./bootstrap" + +// React integration +export { + SyncProvider, + useGlobalSync, + useGlobalSyncSelector, + useDirectoryStore, + useDirectorySync, + useSessionMessages, + useSessionParts, + useSessionStatus, + useSessionPermissions, + useSessionQuestions, + useSessions, + useSyncSDK, + useSyncDirectory, + useChildStoreManager, + useSessionMessageRecords, +} from "./sync-context" + +// Sync operations +export { useSync } from "./use-sync" + +// Prompt submission +export { usePromptSubmit, type SubmitInput } from "./submit" + + +// Streaming lifecycle +export { + useStreamingStore, + updateStreamingState, + selectStreamingMessageId, + selectMessageStreamState, + selectIsStreaming, + type StreamPhase, + type MessageStreamState, + type StreamingStore, +} from "./streaming" + +// Session UI state +export { + useSessionUIStore, + type SessionUIState, + type AttachedFile, + type NewSessionDraftState, +} from "./session-ui-store" + +// Input store (pending input, synthetic parts, attached files) +export { useInputStore, type SyntheticContextPart } from "./input-store" + +// Viewport store (per-session scroll anchors, memory state) +export { + useViewportStore, + type SessionMemoryState, + type ViewportState, +} from "./viewport-store" + +// Sync refs (imperative access from non-React code) +export { + setSyncRefs, + getSyncSDK, + getSyncChildStores, + getSyncDirectory, + getDirectoryState, + getSyncSessions, + getSyncMessages, + getSyncParts, + getSyncSessionStatus, + getSyncPermissions, + getSyncQuestions, +} from "./sync-refs" + +// Persisted metadata caches +export { + readDirCache, + persistVcs, + persistProjectMeta, + persistIcon, + clearDirCache, + type PersistedDirCache, +} from "./persist-cache" + +// Session actions +export { + setActionRefs, + createSession, + deleteSession, + archiveSession, + updateSessionTitle, + shareSession, + unshareSession, + optimisticSend, + abortCurrentOperation, + respondToPermission, + dismissPermission, + respondToQuestion, + rejectQuestion, + revertToMessage, + forkFromMessage, +} from "./session-actions" diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts new file mode 100644 index 00000000..cc61897e --- /dev/null +++ b/packages/ui/src/sync/input-store.ts @@ -0,0 +1,79 @@ +/** + * Input Store — pending input text, synthetic parts, and attached files. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" +import type { AttachedFile } from "@/stores/types/sessionTypes" + +export type SyntheticContextPart = { + text: string + attachments?: AttachedFile[] + synthetic?: boolean +} + +export type InputState = { + pendingInputText: string | null + pendingInputMode: "replace" | "append" | "append-inline" + pendingSyntheticParts: SyntheticContextPart[] | null + attachedFiles: AttachedFile[] + + setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void + consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null + setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void + consumePendingSyntheticParts: () => SyntheticContextPart[] | null + addAttachedFile: (file: File) => Promise + removeAttachedFile: (id: string) => void + clearAttachedFiles: () => void +} + +export const useInputStore = create()((set, get) => ({ + pendingInputText: null, + pendingInputMode: "replace", + pendingSyntheticParts: null, + attachedFiles: [], + + setPendingInputText: (text, mode = "replace") => + set({ pendingInputText: text, pendingInputMode: mode }), + + consumePendingInputText: () => { + const { pendingInputText, pendingInputMode } = get() + if (pendingInputText === null) return null + set({ pendingInputText: null, pendingInputMode: "replace" }) + return { text: pendingInputText, mode: pendingInputMode } + }, + + setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }), + + consumePendingSyntheticParts: () => { + const { pendingSyntheticParts } = get() + if (pendingSyntheticParts !== null) { + set({ pendingSyntheticParts: null }) + } + return pendingSyntheticParts + }, + + addAttachedFile: async (file: File) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.readAsDataURL(file) + }) + const attached: AttachedFile = { + id, + file, + dataUrl, + mimeType: file.type, + filename: file.name, + size: file.size, + source: "local", + } + set((s) => ({ attachedFiles: [...s.attachedFiles, attached] })) + }, + + removeAttachedFile: (id) => + set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })), + + clearAttachedFiles: () => set({ attachedFiles: [] }), +})) diff --git a/packages/ui/src/sync/notification-store.ts b/packages/ui/src/sync/notification-store.ts new file mode 100644 index 00000000..1501c7b7 --- /dev/null +++ b/packages/ui/src/sync/notification-store.ts @@ -0,0 +1,170 @@ +// --------------------------------------------------------------------------- +// Notification store — session turn-complete and error tracking +// +// Tracks session turn-complete and error notifications with viewed/unviewed +// state. Replaces the old sessionAttentionStates polling system. +// --------------------------------------------------------------------------- + +import { create } from "zustand" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type NotificationBase = { + directory?: string + session?: string + time: number + viewed: boolean +} + +type TurnCompleteNotification = NotificationBase & { + type: "turn-complete" +} + +type ErrorNotification = NotificationBase & { + type: "error" + error?: { message?: string; code?: string } +} + +export type Notification = TurnCompleteNotification | ErrorNotification + +type NotificationIndex = { + session: { + unseenCount: Record + unseenHasError: Record + } + project: { + unseenCount: Record + unseenHasError: Record + } +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_NOTIFICATIONS = 500 +const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 days + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function pruneNotifications(list: Notification[]): Notification[] { + const cutoff = Date.now() - NOTIFICATION_TTL_MS + const pruned = list.filter((n) => n.time >= cutoff) + if (pruned.length <= MAX_NOTIFICATIONS) return pruned + return pruned.slice(pruned.length - MAX_NOTIFICATIONS) +} + +function buildIndex(list: Notification[]): NotificationIndex { + const index: NotificationIndex = { + session: { unseenCount: {}, unseenHasError: {} }, + project: { unseenCount: {}, unseenHasError: {} }, + } + + for (const n of list) { + if (n.viewed) continue + + if (n.session) { + index.session.unseenCount[n.session] = (index.session.unseenCount[n.session] ?? 0) + 1 + if (n.type === "error") index.session.unseenHasError[n.session] = true + } + if (n.directory) { + index.project.unseenCount[n.directory] = (index.project.unseenCount[n.directory] ?? 0) + 1 + if (n.type === "error") index.project.unseenHasError[n.directory] = true + } + } + + return index +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +interface NotificationStore { + list: Notification[] + index: NotificationIndex + + // Mutations + append: (notification: Notification) => void + markSessionViewed: (sessionId: string) => void + markProjectViewed: (directory: string) => void + + // Selectors + sessionUnseenCount: (sessionId: string) => number + sessionHasError: (sessionId: string) => boolean + projectUnseenCount: (directory: string) => number + projectHasError: (directory: string) => boolean +} + +export const useNotificationStore = create((set, get) => ({ + list: [], + index: { + session: { unseenCount: {}, unseenHasError: {} }, + project: { unseenCount: {}, unseenHasError: {} }, + }, + + append: (notification) => { + const current = get().list + const next = pruneNotifications([...current, notification]) + set({ list: next, index: buildIndex(next) }) + }, + + markSessionViewed: (sessionId) => { + const current = get() + const count = current.index.session.unseenCount[sessionId] ?? 0 + if (count === 0) return + + const next = current.list.map((n) => + n.session === sessionId && !n.viewed ? { ...n, viewed: true } : n, + ) + set({ list: next, index: buildIndex(next) }) + }, + + markProjectViewed: (directory) => { + const current = get() + const count = current.index.project.unseenCount[directory] ?? 0 + if (count === 0) return + + const next = current.list.map((n) => + n.directory === directory && !n.viewed ? { ...n, viewed: true } : n, + ) + set({ list: next, index: buildIndex(next) }) + }, + + sessionUnseenCount: (sessionId) => get().index.session.unseenCount[sessionId] ?? 0, + sessionHasError: (sessionId) => get().index.session.unseenHasError[sessionId] ?? false, + projectUnseenCount: (directory) => get().index.project.unseenCount[directory] ?? 0, + projectHasError: (directory) => get().index.project.unseenHasError[directory] ?? false, +})) + +// --------------------------------------------------------------------------- +// Imperative API for non-React code (event handler in sync-context) +// --------------------------------------------------------------------------- + +export function appendNotification(notification: Notification) { + useNotificationStore.getState().append(notification) +} + +export function markSessionViewed(sessionId: string) { + useNotificationStore.getState().markSessionViewed(sessionId) +} + +// --------------------------------------------------------------------------- +// React hooks for fine-grained subscriptions +// --------------------------------------------------------------------------- + +export function useSessionUnseenCount(sessionId: string): number { + return useNotificationStore((s) => s.index.session.unseenCount[sessionId] ?? 0) +} + +export function useSessionHasError(sessionId: string): boolean { + return useNotificationStore((s) => s.index.session.unseenHasError[sessionId] ?? false) +} + +export function useProjectUnseenCount(directory: string): number { + return useNotificationStore((s) => s.index.project.unseenCount[directory] ?? 0) +} diff --git a/packages/ui/src/sync/optimistic.ts b/packages/ui/src/sync/optimistic.ts new file mode 100644 index 00000000..ac1b5a48 --- /dev/null +++ b/packages/ui/src/sync/optimistic.ts @@ -0,0 +1,127 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +function sortParts(parts: Part[]) { + return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) +} + +export type OptimisticStore = { + message: Record + part: Record +} + +export type OptimisticItem = { + message: Message + parts: Part[] +} + +export type OptimisticAddInput = { + sessionID: string + message: Message + parts: Part[] +} + +export type OptimisticRemoveInput = { + sessionID: string + messageID: string +} + +export type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + cursor?: string + complete: boolean +} + +const hasParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return want.length === 0 + return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) +} + +const mergeParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return sortParts(want) + const next = [...parts] + let changed = false + for (const part of want) { + const result = Binary.search(next, part.id, (item) => item.id) + if (result.found) continue + next.splice(result.index, 0, part) + changed = true + } + if (!changed) return parts + return next +} + +export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { + if (items.length === 0) return { ...page, confirmed: [] as string[] } + + const session = [...page.session] + const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) + const confirmed: string[] = [] + + for (const item of items) { + const result = Binary.search(session, item.message.id, (message) => message.id) + const found = result.found + if (!found) session.splice(result.index, 0, item.message) + + const current = part.get(item.message.id) + if (found && hasParts(current, item.parts)) { + confirmed.push(item.message.id) + continue + } + + part.set(item.message.id, mergeParts(current, item.parts)) + } + + return { + cursor: page.cursor, + complete: page.complete, + session, + part: [...part.entries()] + .sort((a, b) => cmp(a[0], b[0])) + .map(([id, part]) => ({ id, part })), + confirmed, + } +} + +/** Apply optimistic add to a mutable draft (for immer/produce) */ +export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.message.id, (m) => m.id) + if (!result.found) { + messages.splice(result.index, 0, input.message) + } + } else { + draft.message[input.sessionID] = [input.message] + } + draft.part[input.message.id] = sortParts(input.parts) +} + +/** Apply optimistic remove to a mutable draft (for immer/produce) */ +export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[input.messageID] +} + +/** Merge two sorted message arrays by id, deduplicating. + * Preserves references from `a` for items that already exist — avoids + * unnecessary React re-renders when prepending older history. */ +export function mergeMessages(a: readonly T[], b: readonly T[]) { + const existing = new Map(a.map((item) => [item.id, item] as const)) + let changed = false + for (const item of b) { + if (!existing.has(item.id)) { + existing.set(item.id, item) + changed = true + } + } + if (!changed) return a as T[] + return [...existing.values()].sort((x, y) => cmp(x.id, y.id)) +} diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts new file mode 100644 index 00000000..7be0cafe --- /dev/null +++ b/packages/ui/src/sync/persist-cache.ts @@ -0,0 +1,116 @@ +/** + * Persisted child-store metadata caches. + * + * VCS info, project metadata, and icons are cached to localStorage + * per directory so they survive page reloads. + * Only metadata is persisted — session/message/part data is always fresh + * from the server via SSE bootstrap. + */ + +import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { ProjectMeta } from "./types" + +// --------------------------------------------------------------------------- +// Storage key generation +// --------------------------------------------------------------------------- + +function hashCode(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const chr = str.charCodeAt(i) + hash = ((hash << 5) - hash) + chr + hash |= 0 + } + return Math.abs(hash).toString(36) +} + +function storagePrefix(directory: string): string { + const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_") + return `oc.dir.${head}.${hashCode(directory)}` +} + +// --------------------------------------------------------------------------- +// Typed cache helpers +// --------------------------------------------------------------------------- + +type CacheKey = "vcs" | "projectMeta" | "icon" + +function cacheKey(directory: string, key: CacheKey): string { + return `${storagePrefix(directory)}.${key}` +} + +function readCache(directory: string, key: CacheKey): T | undefined { + try { + const raw = localStorage.getItem(cacheKey(directory, key)) + if (!raw) return undefined + return JSON.parse(raw) as T + } catch { + return undefined + } +} + +function writeCache(directory: string, key: CacheKey, value: T | undefined): void { + try { + const k = cacheKey(directory, key) + if (value === undefined) { + localStorage.removeItem(k) + } else { + localStorage.setItem(k, JSON.stringify(value)) + } + } catch { + // localStorage quota exceeded — ignore + } +} + +function clearCache(directory: string): void { + try { + const prefix = storagePrefix(directory) + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k?.startsWith(prefix)) keys.push(k) + } + for (const k of keys) localStorage.removeItem(k) + } catch { + // ignore + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export type PersistedDirCache = { + vcs: VcsInfo | undefined + projectMeta: ProjectMeta | undefined + icon: string | undefined +} + +/** Read all cached metadata for a directory */ +export function readDirCache(directory: string): PersistedDirCache { + return { + vcs: readCache(directory, "vcs"), + projectMeta: readCache(directory, "projectMeta"), + icon: readCache(directory, "icon"), + } +} + +/** Write vcs info to cache */ +export function persistVcs(directory: string, vcs: VcsInfo | undefined): void { + writeCache(directory, "vcs", vcs) +} + +/** Write project metadata to cache */ +export function persistProjectMeta(directory: string, meta: ProjectMeta | undefined): void { + writeCache(directory, "projectMeta", meta) +} + +/** Write icon to cache */ +export function persistIcon(directory: string, icon: string | undefined): void { + writeCache(directory, "icon", icon) +} + +/** Clear all cached metadata for a directory */ +export function clearDirCache(directory: string): void { + clearCache(directory) +} diff --git a/packages/ui/src/sync/retry.ts b/packages/ui/src/sync/retry.ts new file mode 100644 index 00000000..7d580407 --- /dev/null +++ b/packages/ui/src/sync/retry.ts @@ -0,0 +1,54 @@ +export interface RetryOptions { + attempts?: number + delay?: number + factor?: number + maxDelay?: number + retryIf?: (error: unknown) => boolean +} + +const TRANSIENT_MESSAGES = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", + "opencode api unavailable", + "503", + "502", +] + +function isTransientError(error: unknown): boolean { + if (!error) return false + const message = String(error instanceof Error ? error.message : error).toLowerCase() + if (TRANSIENT_MESSAGES.some((m) => message.includes(m))) return true + // SDK errors from HTTP 502/503 responses (VS Code bridge returns these before OpenCode is ready) + const status = (error as { status?: number })?.status + if (status === 502 || status === 503) return true + return false +} + +export async function retry(fn: () => Promise, options: RetryOptions = {}): Promise { + const { + attempts = 3, + delay = 500, + factor = 2, + maxDelay = 10000, + retryIf = isTransientError, + } = options + + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error + if (attempt === attempts - 1 || !retryIf(error)) throw error + const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay) + await new Promise((resolve) => setTimeout(resolve, wait)) + } + } + throw lastError +} diff --git a/packages/ui/src/sync/sanitize.ts b/packages/ui/src/sync/sanitize.ts new file mode 100644 index 00000000..9e6aa195 --- /dev/null +++ b/packages/ui/src/sync/sanitize.ts @@ -0,0 +1,67 @@ +// --------------------------------------------------------------------------- +// Payload sanitization — strip oversized diff snapshot fields client-side. +// +// OpenCode session objects carry summary.diffs[].before/after with full file +// contents. The UI never uses these fields but they waste browser memory and +// can crash tabs for large sessions. +// +// Applied at two points: +// 1. Event reducer — session.created/session.updated events +// 2. Message loading — fetchMessages response +// --------------------------------------------------------------------------- + +import type { Session, Message } from "@opencode-ai/sdk/v2/client" + +type DiffEntry = { + file?: string + status?: string + additions?: number + deletions?: number + before?: string + after?: string +} + +type SessionSummary = { + diffs?: DiffEntry[] + [key: string]: unknown +} + +/** Strip before/after from summary.diffs on a session object */ +export function stripSessionDiffSnapshots(session: Session): Session { + const summary = (session as { summary?: SessionSummary }).summary + if (!summary?.diffs || !Array.isArray(summary.diffs)) return session + + let changed = false + const stripped = summary.diffs.map((d) => { + if (d && (typeof d.before === "string" || typeof d.after === "string")) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { before: _before, after: _after, ...rest } = d + changed = true + return rest + } + return d + }) + + if (!changed) return session + return { ...session, summary: { ...summary, diffs: stripped } } as Session +} + +/** Strip before/after from summary.diffs on a message object */ +export function stripMessageDiffSnapshots(message: Message): Message { + const summary = (message as { summary?: SessionSummary }).summary + if (!summary?.diffs || !Array.isArray(summary.diffs)) return message + + let changed = false + const stripped = summary.diffs.map((d) => { + if (d && (typeof d.before === "string" || typeof d.after === "string")) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { before: _before, after: _after, ...rest } = d + changed = true + return rest + } + return d + }) + + if (!changed) return message + return { ...message, summary: { ...summary, diffs: stripped } } as Message +} diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts new file mode 100644 index 00000000..d53fae56 --- /dev/null +++ b/packages/ui/src/sync/selection-store.ts @@ -0,0 +1,86 @@ +/** + * Selection Store — per-session model, agent, and variant selections. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" + +export type SelectionState = { + sessionModelSelections: Map + sessionAgentSelections: Map + sessionAgentModelSelections: Map> + lastUsedProvider: { providerID: string; modelID: string } | null + + saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void + getSessionModelSelection: (sessionId: string) => { providerId: string; modelId: string } | null + saveSessionAgentSelection: (sessionId: string, agentName: string) => void + getSessionAgentSelection: (sessionId: string) => string | null + saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void + getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null + saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void + getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined +} + +// In-memory variant storage (not persisted) +const agentModelVariantSelections = new Map>>() + +export const useSelectionStore = create()((set, get) => ({ + sessionModelSelections: new Map(), + sessionAgentSelections: new Map(), + sessionAgentModelSelections: new Map(), + lastUsedProvider: null, + + saveSessionModelSelection: (sessionId, providerId, modelId) => + set((s) => { + const map = new Map(s.sessionModelSelections) + map.set(sessionId, { providerId, modelId }) + return { sessionModelSelections: map, lastUsedProvider: { providerID: providerId, modelID: modelId } } + }), + + getSessionModelSelection: (sessionId) => get().sessionModelSelections.get(sessionId) ?? null, + + saveSessionAgentSelection: (sessionId, agentName) => + set((s) => { + if (s.sessionAgentSelections.get(sessionId) === agentName) return s + const map = new Map(s.sessionAgentSelections) + map.set(sessionId, agentName) + return { sessionAgentSelections: map } + }), + + getSessionAgentSelection: (sessionId) => get().sessionAgentSelections.get(sessionId) ?? null, + + saveAgentModelForSession: (sessionId, agentName, providerId, modelId) => + set((s) => { + const existing = s.sessionAgentModelSelections.get(sessionId)?.get(agentName) + if (existing?.providerId === providerId && existing?.modelId === modelId) return s + const outer = new Map(s.sessionAgentModelSelections) + const inner = new Map(outer.get(sessionId) ?? new Map()) + inner.set(agentName, { providerId, modelId }) + outer.set(sessionId, inner) + return { sessionAgentModelSelections: outer } + }), + + getAgentModelForSession: (sessionId, agentName) => + get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null, + + saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => { + if (!variant) return + const key = `${providerId}/${modelId}` + let agentMap = agentModelVariantSelections.get(sessionId) + if (!agentMap) { + agentMap = new Map() + agentModelVariantSelections.set(sessionId, agentMap) + } + let modelMap = agentMap.get(agentName) + if (!modelMap) { + modelMap = new Map() + agentMap.set(agentName, modelMap) + } + modelMap.set(key, variant) + }, + + getAgentModelVariantForSession: (sessionId, agentName, providerId, modelId) => { + const key = `${providerId}/${modelId}` + return agentModelVariantSelections.get(sessionId)?.get(agentName)?.get(key) + }, +})) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts new file mode 100644 index 00000000..525ecc0e --- /dev/null +++ b/packages/ui/src/sync/session-actions.ts @@ -0,0 +1,586 @@ +/** + * Session actions — SDK-calling operations for session management. + * Replaces the action methods from the old useSessionStore. + */ + +import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" +import { useSessionUIStore } from "./session-ui-store" +import { useInputStore } from "./input-store" +import type { DirectoryStore } from "./child-store" +import type { StoreApi } from "zustand" +import { opencodeClient } from "@/lib/opencode/client" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" + +// Reference set by SyncProvider — allows actions to access SDK and stores +let _sdk: OpencodeClient | null = null +let _childStores: { ensureChild: (dir: string) => StoreApi } | null = null +let _getDirectory: () => string = () => "" +let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null +let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null + +export function setActionRefs( + sdk: OpencodeClient, + childStores: { ensureChild: (dir: string) => StoreApi }, + getDirectory: () => string, +) { + _sdk = sdk + _childStores = childStores + _getDirectory = getDirectory +} + +export function setOptimisticRefs( + add: (input: { sessionID: string; message: Message; parts: Part[] }) => void, + remove: (input: { sessionID: string; messageID: string }) => void, +) { + _optimisticAdd = add + _optimisticRemove = remove +} + +function sdk() { + if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?") + return _sdk +} + +function dirStore() { + if (!_childStores) throw new Error("Child stores not initialized") + const d = _getDirectory() + if (!d) throw new Error("No current directory") + return _childStores.ensureChild(d) +} + +function dir() { + return _getDirectory() || undefined +} + +function getSessionDirectory(sessionId: string): string | undefined { + return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir() +} + +function getDirectoryStore(directory?: string) { + if (!_childStores) throw new Error("Child stores not initialized") + const resolvedDirectory = directory || _getDirectory() + if (!resolvedDirectory) throw new Error("No current directory") + return _childStores.ensureChild(resolvedDirectory) +} + +function getSessionReplyClient(sessionId?: string): OpencodeClient { + const directory = sessionId + ? useSessionUIStore.getState().getDirectoryForSession(sessionId) + : null + if (directory) { + return opencodeClient.getScopedSdkClient(directory) + } + return sdk() +} + +// --------------------------------------------------------------------------- +// Session CRUD +// --------------------------------------------------------------------------- + +export async function createSession( + title?: string, + directoryOverride?: string | null, + parentID?: string | null, +): Promise { + try { + const result = await sdk().session.create({ + directory: directoryOverride ?? dir(), + title, + parentID: parentID ?? undefined, + }) + const session = result.data + if (!session) return null + + const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null + useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory) + useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id) + useGlobalSessionsStore.getState().upsertSession(session) + return session + } catch (error) { + console.error("[session-actions] createSession failed", error) + return null + } +} + +/** Optimistically remove a session from the child store list. Returns previous list for rollback. */ +function optimisticRemoveSession(sessionId: string, directory?: string): Session[] | null { + const store = getDirectoryStore(directory) + const current = store.getState() + const sessions = [...current.session] + const result = Binary.search(sessions, sessionId, (s) => s.id) + if (result.found) { + const snapshot = current.session + sessions.splice(result.index, 1) + store.setState({ session: sessions }) + return snapshot + } + return null +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function deleteSession(sessionId: string, _options?: Record): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + // Remove from UI immediately, rollback on error + const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) + const ui = useSessionUIStore.getState() + if (ui.currentSessionId === sessionId) { + ui.setCurrentSession(null) + } + try { + await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory }) + useGlobalSessionsStore.getState().removeSessions([sessionId]) + return true + } catch (error) { + console.error("[session-actions] deleteSession failed", error) + if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot }) + return false + } +} + +/** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */ +export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise { + if (!_childStores) return false + const store = _childStores.ensureChild(directory) + const current = store.getState() + const sessions = [...current.session] + const result = Binary.search(sessions, sessionId, (s) => s.id) + let snapshot: Session[] | null = null + if (result.found) { + snapshot = current.session + sessions.splice(result.index, 1) + store.setState({ session: sessions }) + } + const ui = useSessionUIStore.getState() + if (ui.currentSessionId === sessionId) ui.setCurrentSession(null) + try { + await sdk().session.delete({ sessionID: sessionId, directory }) + useGlobalSessionsStore.getState().removeSessions([sessionId]) + return true + } catch (error) { + console.error("[session-actions] deleteSessionInDirectory failed", error) + if (snapshot) store.setState({ session: snapshot }) + return false + } +} + +export async function archiveSession(sessionId: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) + const ui = useSessionUIStore.getState() + if (ui.currentSessionId === sessionId) { + ui.setCurrentSession(null) + } + try { + const archivedAt = Date.now() + await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } }) + useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt) + return true + } catch (error) { + console.error("[session-actions] archiveSession failed", error) + if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot }) + return false + } +} + +export async function updateSessionTitle(sessionId: string, title: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const result = await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, title }) + if (result.data) { + useGlobalSessionsStore.getState().upsertSession(result.data) + } +} + +export async function shareSession(sessionId: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory }) + if (result.data) { + useGlobalSessionsStore.getState().upsertSession(result.data) + } + return result.data ?? null +} + +export async function unshareSession(sessionId: string): Promise { + const sessionDirectory = getSessionDirectory(sessionId) + const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory }) + if (result.data) { + useGlobalSessionsStore.getState().upsertSession(result.data) + } + return result.data ?? null +} + +// --------------------------------------------------------------------------- +// Optimistic message send — insert user message before API call, rollback on error +// --------------------------------------------------------------------------- + +// ID generator matching OpenCode's Identifier.ascending format. +// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62. +// This ensures client-generated IDs sort correctly with server-generated ones. +let lastIdTimestamp = 0 +let idCounter = 0 + +function ascendingId(prefix: string): string { + const now = Date.now() + if (now !== lastIdTimestamp) { + lastIdTimestamp = now + idCounter = 0 + } + idCounter += 1 + + const value = BigInt(now) * BigInt(0x1000) + BigInt(idCounter) + const bytes = new Uint8Array(6) + for (let i = 0; i < 6; i++) { + bytes[i] = Number((value >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + let hex = "" + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, "0") + } + + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + let rand = "" + for (let i = 0; i < 14; i++) { + rand += chars[Math.floor(Math.random() * 62)] + } + + return `${prefix}_${hex}${rand}` +} + +/** + * Wraps an async send operation with optimistic user-message insertion. + * Uses useSync()'s optimistic infrastructure — message + parts are inserted + * into the store AND registered in the shadow Map. mergeOptimisticPage + * handles deduplication when the server echoes back the real message. + */ +export async function optimisticSend(input: { + sessionId: string + content: string + providerID: string + modelID: string + agent?: string + files?: Array<{ type: "file"; mime: string; url: string; filename: string }> + /** The actual API call — receives the optimistic messageID so the server can use the same ID */ + send: (messageID: string) => Promise +}): Promise { + if (!_optimisticAdd || !_optimisticRemove) { + throw new Error("Optimistic refs not set — is useSync() mounted?") + } + + const store = dirStore() + const messageID = ascendingId("msg") + const textPartId = ascendingId("prt") + + const optimisticParts: Part[] = [ + { id: textPartId, type: "text", text: input.content } as Part, + ] + if (input.files) { + for (const f of input.files) { + optimisticParts.push({ id: ascendingId("prt"), type: "file", mime: f.mime, url: f.url, filename: f.filename } as Part) + } + } + + const optimisticMessage = { + id: messageID, + role: "user" as const, + sessionID: input.sessionId, + parentID: "", + modelID: input.modelID, + providerID: input.providerID, + system: "", + agent: input.agent ?? "", + model: `${input.providerID}/${input.modelID}`, + metadata: {} as Record, + time: { created: Date.now(), completed: 0 }, + } as unknown as Message + + // Insert into store + register in shadow Map (for mergeOptimisticPage cleanup) + _optimisticAdd({ + sessionID: input.sessionId, + message: optimisticMessage, + parts: optimisticParts, + }) + + // Set busy status + const current = store.getState() + store.setState({ + session_status: { + ...current.session_status, + [input.sessionId]: { type: "busy" as const }, + }, + }) + + try { + await input.send(messageID) + } catch (error) { + // Rollback via optimistic infrastructure + _optimisticRemove({ + sessionID: input.sessionId, + messageID, + }) + const s = store.getState() + store.setState({ + session_status: { + ...s.session_status, + [input.sessionId]: { type: "idle" as const }, + }, + }) + throw error + } +} + +// --------------------------------------------------------------------------- +// Abort +// --------------------------------------------------------------------------- + +export async function abortCurrentOperation(sessionId: string): Promise { + try { + await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + } catch (error) { + console.error("[session-actions] abort failed", error) + } +} + +// --------------------------------------------------------------------------- +// Permissions +// --------------------------------------------------------------------------- + +export async function respondToPermission( + sessionId: string, + requestId: string, + response: "once" | "always" | "reject", +): Promise { + const result = await getSessionReplyClient(sessionId).permission.reply({ + requestID: requestId, + reply: response, + }) + if (!result.data) { + throw new Error("Permission reply failed") + } +} + +export async function dismissPermission( + sessionId: string, + requestId: string, +): Promise { + const result = await getSessionReplyClient(sessionId).permission.reply({ + requestID: requestId, + reply: "reject", + }) + if (!result.data) { + throw new Error("Permission dismissal failed") + } +} + +// --------------------------------------------------------------------------- +// Questions +// --------------------------------------------------------------------------- + +export async function respondToQuestion( + sessionId: string, + requestId: string, + answers: string[] | string[][], +): Promise { + const result = await getSessionReplyClient(sessionId).question.reply({ + requestID: requestId, + answers: answers as Array>, + }) + if (!result.data) { + throw new Error("Question reply failed") + } +} + +export async function rejectQuestion( + sessionId: string, + requestId: string, +): Promise { + const result = await getSessionReplyClient(sessionId).question.reject({ + requestID: requestId, + }) + if (!result.data) { + throw new Error("Question rejection failed") + } +} + +// --------------------------------------------------------------------------- +// Message history +// --------------------------------------------------------------------------- + +/** + * Revert to a specific user message. + * + * 1. Abort if session is busy + * 2. Extract text from the target message for prompt restoration + * 3. Optimistically set revert marker so messages hide immediately + * 4. Call SDK session.revert() and merge returned session + * 5. Set pendingInputText so the reverted message text appears in the input + */ +export async function revertToMessage(sessionId: string, messageId: string): Promise { + const store = dirStore() + const state = store.getState() + + // Abort if busy before mutating session state + const status = state.session_status[sessionId] + if (status && status.type !== "idle") { + try { + await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + } catch { + // ignore abort errors + } + } + + // Extract message text for prompt restoration + const messages = state.message[sessionId] ?? [] + const targetMsg = messages.find((m) => m.id === messageId) + let messageText = "" + if (targetMsg && targetMsg.role === "user") { + const parts = state.part[messageId] ?? [] + const textParts = parts.filter((p) => p.type === "text") + messageText = textParts + .map((p: Record) => (p as { text?: string }).text || (p as { content?: string }).content || "") + .join("\n") + .trim() + } + + // Optimistically remove reverted messages + set marker + const prevRevert = (() => { + const s = state.session.find((s) => s.id === sessionId) + return (s as Session & { revert?: unknown })?.revert + })() + const sessions = [...state.session] + const sessionIdx = sessions.findIndex((s) => s.id === sessionId) + + // Remove messages at and after the revert point from the store + const prevMessages = state.message[sessionId] ?? [] + const prevPart = { ...state.part } + const keptMessages = prevMessages.filter((m) => m.id < messageId) + const removedMessages = prevMessages.filter((m) => m.id >= messageId) + for (const m of removedMessages) { + delete prevPart[m.id] + } + + const patch: Record = { + message: { ...state.message, [sessionId]: keptMessages }, + part: prevPart, + } + + if (sessionIdx >= 0) { + sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session + patch.session = sessions + } + + store.setState(patch) + + // Restore reverted message text to input + if (messageText) { + useInputStore.setState({ + pendingInputText: messageText, + pendingInputMode: "replace" as const, + }) + } + + // Call SDK and merge authoritative result into store + try { + const result = await sdk().session.revert({ sessionID: sessionId, directory: dir(), messageID: messageId }) + if (result.data) { + const current = store.getState() + const updated = [...current.session] + const idx = updated.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + updated[idx] = result.data + store.setState({ session: updated }) + } + } + } catch (err) { + // Rollback: restore removed messages + revert marker + const current = store.getState() + const rollback = [...current.session] + const idx = rollback.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + rollback[idx] = { ...rollback[idx], revert: prevRevert } as Session + } + store.setState({ + session: rollback, + message: { ...current.message, [sessionId]: prevMessages }, + part: { ...current.part, ...Object.fromEntries(removedMessages.map((m) => [m.id, state.part[m.id] ?? []])) }, + }) + throw err + } +} + +/** + * Unrevert — restore all previously reverted messages. + * Restore all previously reverted messages. Aborts if busy, merges result. + */ +export async function unrevertSession(sessionId: string): Promise { + const store = dirStore() + const state = store.getState() + + // Abort if busy + const status = state.session_status[sessionId] + if (status && status.type !== "idle") { + try { + await sdk().session.abort({ sessionID: sessionId, directory: dir() }) + } catch { + // ignore + } + } + + const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() }) + if (result.data) { + const current = store.getState() + const sessions = [...current.session] + const idx = sessions.findIndex((s) => s.id === sessionId) + if (idx >= 0) { + sessions[idx] = result.data + store.setState({ session: sessions }) + } + } +} + +/** + * Fork from a user message. + * + * 1. Extract text from the message for input restoration + * 2. Call SDK session.fork() + * 3. Insert the new session into the child store (so sidebar updates immediately) + * 4. Switch to new session and set pending input text + */ +export async function forkFromMessage(sessionId: string, messageId: string): Promise { + const store = dirStore() + const state = store.getState() + + // Extract message text for input restoration + const parts = state.part[messageId] ?? [] + let messageText = "" + const textParts = parts.filter((p) => p.type === "text") + messageText = textParts + .map((p: Part) => ((p as Record).text as string) || ((p as Record).content as string) || "") + .join("\n") + .trim() + + const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId }) + if (!result.data) return + + const forkedSession = result.data + + // Insert new session into child store so sidebar updates immediately + const current = store.getState() + const sessions = [...current.session] + const searchResult = Binary.search(sessions, forkedSession.id, (s) => s.id) + if (!searchResult.found) { + sessions.splice(searchResult.index, 0, forkedSession) + store.setState({ session: sessions }) + } + + // Switch to new session + useSessionUIStore.getState().setCurrentSession(forkedSession.id) + + // Restore forked message text to input + if (messageText) { + useInputStore.setState({ + pendingInputText: messageText, + pendingInputMode: "replace" as const, + }) + } +} diff --git a/packages/ui/src/sync/session-cache.ts b/packages/ui/src/sync/session-cache.ts new file mode 100644 index 00000000..eb0be49f --- /dev/null +++ b/packages/ui/src/sync/session-cache.ts @@ -0,0 +1,61 @@ +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" + +type SessionCache = { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record +} + +export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { + const stale = new Set(Array.from(sessionIDs).filter(Boolean)) + if (stale.size === 0) return + + for (const key of Object.keys(store.part ?? {})) { + const parts = store.part[key] + if (!parts?.some((part) => stale.has((part as { sessionID?: string })?.sessionID ?? ""))) + continue + delete store.part[key] + } + + for (const sessionID of stale) { + delete store.message[sessionID] + delete store.todo[sessionID] + delete store.session_diff[sessionID] + delete store.session_status[sessionID] + delete store.permission[sessionID] + delete store.question[sessionID] + } +} + +export function pickSessionCacheEvictions(input: { + seen: Set + keep: string + limit: number + preserve?: Iterable +}) { + const stale: string[] = [] + const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])]) + if (input.seen.has(input.keep)) input.seen.delete(input.keep) + input.seen.add(input.keep) + for (const id of input.seen) { + if (input.seen.size - stale.length <= input.limit) break + if (keep.has(id)) continue + stale.push(id) + } + for (const id of stale) { + input.seen.delete(id) + } + return stale +} diff --git a/packages/ui/src/sync/session-prefetch-cache.ts b/packages/ui/src/sync/session-prefetch-cache.ts new file mode 100644 index 00000000..3ec66d86 --- /dev/null +++ b/packages/ui/src/sync/session-prefetch-cache.ts @@ -0,0 +1,113 @@ +/** + * Session prefetch TTL cache — prevents redundant session fetches + * within a short window. Port of OpenCode's session-prefetch.ts. + * + * Tracks: last fetch time, pagination cursor, completeness. + * Version counter invalidates stale inflight requests after eviction. + */ + +const SESSION_PREFETCH_TTL = 15_000 + +type Meta = { + limit: number + cursor?: string + complete: boolean + at: number +} + +const compositeKey = (directory: string, sessionID: string) => + `${directory}\n${sessionID}` + +const cache = new Map() +const inflight = new Map>() +const rev = new Map() + +const version = (id: string) => rev.get(id) ?? 0 + +/** Check if a prefetch/sync can be skipped (recently fetched). */ +export function shouldSkipSessionPrefetch(input: { + hasMessages: boolean + info?: Meta + pageSize: number + now?: number +}): boolean { + if (input.hasMessages) { + if (!input.info) return true + if (input.info.complete) return true + if (input.info.limit > input.pageSize) return true + } else { + if (!input.info) return false + } + return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL +} + +export function getSessionPrefetch(directory: string, sessionID: string): Meta | undefined { + return cache.get(compositeKey(directory, sessionID)) +} + +export function getSessionPrefetchPromise(directory: string, sessionID: string) { + return inflight.get(compositeKey(directory, sessionID)) +} + +export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) { + return version(compositeKey(directory, sessionID)) === value +} + +/** Run a prefetch task with inflight dedup + version tracking. */ +export function runSessionPrefetch(input: { + directory: string + sessionID: string + task: (value: number) => Promise +}) { + const id = compositeKey(input.directory, input.sessionID) + const pending = inflight.get(id) + if (pending) return pending + + const value = version(id) + + const promise = input.task(value).finally(() => { + if (inflight.get(id) === promise) inflight.delete(id) + }) + + inflight.set(id, promise) + return promise +} + +export function setSessionPrefetch(input: { + directory: string + sessionID: string + limit: number + cursor?: string + complete: boolean + at?: number +}) { + cache.set(compositeKey(input.directory, input.sessionID), { + limit: input.limit, + cursor: input.cursor, + complete: input.complete, + at: input.at ?? Date.now(), + }) +} + +/** Invalidate cache for specific sessions (e.g. after eviction). */ +export function clearSessionPrefetch(directory: string, sessionIDs: Iterable) { + for (const sessionID of sessionIDs) { + if (!sessionID) continue + const id = compositeKey(directory, sessionID) + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} + +/** Invalidate all cache entries for a directory. */ +export function clearSessionPrefetchDirectory(directory: string) { + const prefix = `${directory}\n` + const keys = new Set([...cache.keys(), ...inflight.keys()]) + for (const id of keys) { + if (!id.startsWith(prefix)) continue + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts new file mode 100644 index 00000000..0330ed68 --- /dev/null +++ b/packages/ui/src/sync/session-ui-store.ts @@ -0,0 +1,1143 @@ +/** + * Session UI Store — ephemeral UI state only. + * + * Domain data (sessions, messages, parts, permissions, questions, status) + * lives in sync child stores. This store owns ONLY transient UI concerns: + * current selection, draft state, viewport anchors, model/agent preferences, + * voice state, abort prompts, attached files, worktree metadata. + * + * SDK-calling actions that need domain data read it from sync-refs. + */ + +import { create } from "zustand" +import type { Session, Part, Message, TextPart, Agent } from "@opencode-ai/sdk/v2/client" +import type { AttachedFile, SessionContextUsage } from "@/stores/types/sessionTypes" +import type { WorktreeMetadata } from "@/types/worktree" +import { opencodeClient } from "@/lib/opencode/client" +import { useConfigStore } from "@/stores/useConfigStore" +import { useProjectsStore } from "@/stores/useProjectsStore" +import { useDirectoryStore } from "@/stores/useDirectoryStore" +import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" +import { useCommandsStore } from "@/stores/useCommandsStore" +import { getSafeStorage } from "@/stores/utils/safeStorage" +import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" +import { flattenAssistantTextParts } from "@/lib/messages/messageText" +import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta" +import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" +import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" +import type { ProjectEntry } from "@/lib/api/types" +import { + getSyncSessions, + getAllSyncSessions, + getSyncMessages, + getSyncParts, + getDirectoryState, +} from "./sync-refs" +import { markSessionViewed } from "./notification-store" +import { setActiveSession } from "./sync-context" +import { + createSession as createSessionAction, + deleteSession as deleteSessionAction, + archiveSession as archiveSessionAction, + updateSessionTitle as updateSessionTitleAction, + shareSession as shareSessionAction, + unshareSession as unshareSessionAction, + optimisticSend, +} from "./session-actions" +import { useInputStore, type SyntheticContextPart } from "./input-store" +import { useSelectionStore } from "./selection-store" +import { useViewportStore } from "./viewport-store" + +export type { AttachedFile } + +// --------------------------------------------------------------------------- +// Send routing — shell mode, slash commands, or normal prompt +// --------------------------------------------------------------------------- + +function routeMessage(params: { + sessionId: string + content: string + providerID: string + modelID: string + agent?: string + variant?: string + inputMode?: "normal" | "shell" + files?: Array<{ type: "file"; mime: string; url: string; filename: string }> + additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> +}): Promise { + const sdk = opencodeClient.getSdkClient() + + if (params.inputMode === "shell") { + const dir = opencodeClient.getDirectory() || undefined + return sdk.session.shell({ + sessionID: params.sessionId, + directory: dir, + agent: params.agent, + model: { providerID: params.providerID, modelID: params.modelID }, + command: params.content, + }).then(() => {}) + } + + // Slash commands — fire and forget, SSE delivers messages and status + if (params.content.startsWith("/")) { + const [head, ...tail] = params.content.split(" ") + const cmdName = head.slice(1) + + const dirState = getDirectoryState() + const syncCommands = dirState?.command ?? [] + const storeCommands = useCommandsStore.getState().commands + + const isCommand = syncCommands.find((c) => c.name === cmdName) + || storeCommands.find((c) => c.name === cmdName) + + if (isCommand) { + const dir = opencodeClient.getDirectory() || undefined + return sdk.session.command({ + sessionID: params.sessionId, + directory: dir, + command: cmdName, + arguments: tail.join(" "), + agent: params.agent, + model: `${params.providerID}/${params.modelID}`, + variant: params.variant, + parts: params.files, + }).then(() => {}) + } + } + + // Normal prompt — optimistic insert so message appears instantly + return optimisticSend({ + sessionId: params.sessionId, + content: params.content, + providerID: params.providerID, + modelID: params.modelID, + agent: params.agent, + files: params.files, + send: (messageID) => opencodeClient.sendMessage({ + id: params.sessionId, + providerID: params.providerID, + modelID: params.modelID, + text: params.content, + agent: params.agent, + variant: params.variant, + files: params.files, + additionalParts: params.additionalParts, + messageId: messageID, + }).then(() => {}), + }) +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type { SyntheticContextPart } from "./input-store" +export type { SessionMemoryState } from "./viewport-store" +export type { VoiceStatus, VoiceMode } from "./voice-store" + +export type NewSessionDraftState = { + open: boolean + selectedProjectId?: string | null + directoryOverride: string | null + pendingWorktreeRequestId?: string | null + bootstrapPendingDirectory?: string | null + preserveDirectoryOverride?: boolean + parentID: string | null + title?: string + initialPrompt?: string + syntheticParts?: SyntheticContextPart[] + targetFolderId?: string +} + +export type ViewportAnchor = { + sessionId: string + value: number +} + +export type SessionHistoryMeta = { + limit: number + hasMore: boolean + complete: boolean + isLoading: boolean + loading?: boolean + nextCursor?: string +} + +export type SessionUIState = { + currentSessionId: string | null + newSessionDraft: NewSessionDraftState + abortPromptSessionId: string | null + abortPromptExpiresAt: number | null + error: string | null + worktreeMetadata: Map + availableWorktrees: WorktreeMetadata[] + availableWorktreesByProject: Map + webUICreatedSessions: Set + sessionAbortFlags: Map + abortControllers: Map + isLoading: boolean + lastLoadedDirectory: string | null + + // Actions — UI state management + setCurrentSession: (id: string | null, directoryHint?: string | null) => void + openNewSessionDraft: (options?: Partial) => void + closeNewSessionDraft: () => void + setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void + setDraftPreserveDirectoryOverride: (value: boolean) => void + acknowledgeSessionAbort: (sessionId: string) => void + clearAbortPrompt: () => void + armAbortPrompt: (durationMs?: number) => number | null + clearError: () => void + markSessionAsOpenChamberCreated: (sessionId: string) => void + isOpenChamberCreatedSession: (sessionId: string) => boolean + getContextUsage: (contextLimit: number, outputLimit: number) => SessionContextUsage | null + initializeNewOpenChamberSession: (sessionId: string, agents: unknown[]) => void + setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => void + overrideNewSessionDraftTarget: (options: Record) => void + resolvePendingDraftWorktreeTarget: (requestId: string, directory: string | null, options?: Record) => void + setDraftBootstrapPendingDirectory: (directory: string | null) => void + setPendingDraftWorktreeRequest: (requestId: string | null) => void + getWorktreeMetadata: (sessionId: string) => WorktreeMetadata | undefined + + // Actions — SDK-calling operations (read domain data from sync-refs) + sendMessage: ( + content: string, + providerID: string, + modelID: string, + agent?: string, + attachments?: AttachedFile[], + agentMentionName?: string, + additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, + variant?: string, + inputMode?: "normal" | "shell", + ) => Promise + + createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise + deleteSession: (id: string, options?: Record) => Promise + deleteSessions: (ids: string[], options?: Record) => Promise<{ deletedIds: string[]; failedIds: string[] }> + archiveSession: (id: string) => Promise + archiveSessions: (ids: string[], options?: Record) => Promise<{ archivedIds: string[]; failedIds: string[] }> + updateSessionTitle: (sessionId: string, title: string) => Promise + shareSession: (sessionId: string) => Promise + unshareSession: (sessionId: string) => Promise + revertToMessage: (sessionId: string, messageId: string) => Promise + forkFromMessage: (sessionId: string, messageId: string) => Promise + handleSlashUndo: (sessionId: string) => Promise + handleSlashRedo: (sessionId: string) => Promise + createSessionFromAssistantMessage: (sourceMessageId: string) => Promise + + // Data access helpers (read from sync) + getSessionsByDirectory: (directory: string) => Session[] + getDirectoryForSession: (sessionId: string) => string | null + getLastUserChoice: (sessionId: string) => { agent?: string; providerID?: string; modelID?: string; variant?: string } | null + getCurrentAgent: (sessionId: string) => string | undefined + debugSessionMessages: (sessionId: string) => Promise + pollForTokenUpdates: () => void + setSessionDirectory: (sessionId: string, directory: string | null) => void +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const normalizePath = (value?: string | null): string | null => { + if (typeof value !== "string") return null + const trimmed = value.trim() + if (!trimmed) return null + const replaced = trimmed.replace(/\\/g, "/") + if (replaced === "/") return "/" + return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced +} + +const resolveDirectoryKey = (session: Session): string | null => { + const sessionRecord = session as Session & { + directory?: string | null + project?: { worktree?: string | null } | null + } + return normalizePath(sessionRecord.directory ?? null) + ?? normalizePath(sessionRecord.project?.worktree ?? null) +} + +const safeStorage = getSafeStorage() +const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget" + +type PersistedDraftTarget = { projectId: string | null; directory: string | null } + +const readPersistedDraftTarget = (): PersistedDraftTarget | null => { + try { + const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown } + return { + projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null, + directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null), + } + } catch { + return null + } +} + +const persistDraftTarget = (target: PersistedDraftTarget): void => { + try { + safeStorage.setItem(DRAFT_TARGET_STORAGE_KEY, JSON.stringify(target)) + } catch { /* ignored */ } +} + +const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => { + const nd = normalizePath(directory) + if (!nd) return null + let best: ProjectEntry | null = null + for (const p of projects) { + const pp = normalizePath(p.path) + if (!pp) continue + if (nd !== pp && !nd.startsWith(`${pp}/`)) continue + if (!best || pp.length > (normalizePath(best.path)?.length ?? 0)) best = p + } + return best +} + +const resolveProjectFromWorktreeDirectory = ( + projects: ProjectEntry[], + availableWorktreesByProject: Map, + directory: string | null, +): ProjectEntry | null => { + const nd = normalizePath(directory) + if (!nd) return null + let matchedWorktree: WorktreeMetadata | null = null + let matchedProjectPath: string | null = null + let bestLen = -1 + for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) { + for (const wt of worktrees) { + const wp = normalizePath(wt.path) + if (!wp) continue + if (nd !== wp && !nd.startsWith(`${wp}/`)) continue + if (wp.length > bestLen) { + bestLen = wp.length + matchedWorktree = wt + matchedProjectPath = normalizePath(projectPath) + } + } + } + if (!matchedWorktree) return null + const candidates = [normalizePath(matchedWorktree.projectDirectory), matchedProjectPath].filter((v): v is string => Boolean(v)) + for (const c of candidates) { + const exact = projects.find((p) => normalizePath(p.path) === c) ?? null + if (exact) return exact + const nested = resolveProjectForDirectory(projects, c) + if (nested) return nested + } + return null +} + +const resolveDraftProjectForDirectory = ( + projects: ProjectEntry[], + availableWorktreesByProject: Map, + directory: string | null, +): ProjectEntry | null => + resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) ?? + resolveProjectForDirectory(projects, directory) + +const resolveSessionDirectory = ( + sessionId: string | null | undefined, + getWtMeta: (id: string) => WorktreeMetadata | undefined, +): string | null => { + if (!sessionId) return null + const metaPath = getWtMeta(sessionId)?.path + if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath) + const sessions = getAllSyncSessions() + const target = sessions.find((s) => s.id === sessionId) + if (!target) return null + return resolveDirectoryKey(target) +} + +const DEFAULT_DRAFT: NewSessionDraftState = { + open: false, + directoryOverride: null, + parentID: null, +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +export const useSessionUIStore = create()((set, get) => ({ + currentSessionId: null, + newSessionDraft: { ...DEFAULT_DRAFT }, + abortPromptSessionId: null, + abortPromptExpiresAt: null, + error: null, + worktreeMetadata: new Map(), + availableWorktrees: [], + availableWorktreesByProject: new Map(), + webUICreatedSessions: new Set(), + sessionAbortFlags: new Map(), + abortControllers: new Map(), + isLoading: false, + lastLoadedDirectory: null, + + // --------------------------------------------------------------------------- + // setCurrentSession + // --------------------------------------------------------------------------- + setCurrentSession: (id, directoryHint?: string | null) => { + if (id) { + get().closeNewSessionDraft() + } + + const previousSessionId = get().currentSessionId + const directoryState = useDirectoryStore.getState() + + const sessionDir = resolveSessionDirectory( + id, + (sid) => get().worktreeMetadata.get(sid), + ) + const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null + const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir + + try { + if (resolvedDir && directoryState.currentDirectory !== resolvedDir) { + directoryState.setDirectory(resolvedDir, { showOverlay: false }) + } + opencodeClient.setDirectory(resolvedDir ?? undefined) + } catch (e) { + console.warn("Failed to set OpenCode directory for session switch:", e) + } + + // Save viewport anchor for previous session + if (previousSessionId && previousSessionId !== id) { + const memState = useViewportStore.getState().sessionMemoryState.get(previousSessionId) + if (!memState?.isStreaming) { + const prevMessages = getSyncMessages(previousSessionId) + if (prevMessages.length > 0) { + useViewportStore.getState().updateViewportAnchor(previousSessionId, prevMessages.length - 1) + } + } + } + + set({ currentSessionId: id }) + + // Mark session viewed in notification store + update active session ref + // Mark session viewed in notification store + update active session ref + if (id) { + markSessionViewed(id) + setActiveSession(resolvedDir ?? "", id) + } + }, + + // --------------------------------------------------------------------------- + // openNewSessionDraft + // --------------------------------------------------------------------------- + openNewSessionDraft: (options) => { + const projectsState = useProjectsStore.getState() + const projects = projectsState.projects + const availableWorktreesByProject = get().availableWorktreesByProject + const activeProject = projectsState.getActiveProject() + const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null) + const persistedTarget = readPersistedDraftTarget() + + const explicitDirectory = options?.directoryOverride !== undefined + ? normalizePath(options.directoryOverride) + : null + const explicitProject = options?.selectedProjectId + ? projects.find((p) => p.id === options.selectedProjectId) ?? null + : null + + const inferredProjectFromDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, explicitDirectory) + const fallbackProject = (() => { + if (activeProject) return activeProject + if (projectsState.activeProjectId) return projects.find((p) => p.id === projectsState.activeProjectId) ?? null + return projects[0] ?? null + })() + + const persistedProjectById = persistedTarget?.projectId + ? projects.find((p) => p.id === persistedTarget.projectId) ?? null + : null + const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null) + const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory) + + const selectedProject = (() => { + if (explicitProject || explicitDirectory !== null) { + return explicitProject ?? inferredProjectFromDir ?? fallbackProject + } + if (currentDirectory) return currentDirProject ?? fallbackProject + return persistedProjectByDir ?? persistedProjectById ?? fallbackProject + })() + + const directory = (() => { + if (explicitDirectory !== null) return explicitDirectory + if (explicitProject) return normalizePath(explicitProject.path ?? null) + if (currentDirectory) return currentDirectory + if (persistedTarget?.directory) return persistedTarget.directory + return normalizePath(selectedProject?.path ?? null) + })() + + persistDraftTarget({ projectId: selectedProject?.id ?? null, directory }) + + set({ + newSessionDraft: { + open: true, + selectedProjectId: selectedProject?.id ?? null, + directoryOverride: directory, + pendingWorktreeRequestId: options?.pendingWorktreeRequestId ?? null, + bootstrapPendingDirectory: normalizePath(options?.bootstrapPendingDirectory ?? null), + preserveDirectoryOverride: options?.preserveDirectoryOverride === true, + parentID: options?.parentID ?? null, + title: options?.title, + initialPrompt: options?.initialPrompt, + syntheticParts: options?.syntheticParts, + targetFolderId: options?.targetFolderId, + }, + currentSessionId: null, + error: null, + }) + + if (options?.initialPrompt) { + useInputStore.getState().setPendingInputText(options.initialPrompt) + } + + try { + const configState = useConfigStore.getState() + const visibleAgents = configState.getVisibleAgents() + let agentName: string | undefined + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a: Agent) => a.name === configState.settingsDefaultAgent) + if (settingsAgent) agentName = settingsAgent.name + } + if (!agentName) { + agentName = visibleAgents.find((a: Agent) => a.name === "build")?.name || visibleAgents[0]?.name + } + if (agentName) configState.setAgent(agentName) + } catch { /* ignored */ } + }, + + // --------------------------------------------------------------------------- + // closeNewSessionDraft + // --------------------------------------------------------------------------- + closeNewSessionDraft: () => { + set({ + newSessionDraft: { + open: false, + selectedProjectId: null, + directoryOverride: null, + pendingWorktreeRequestId: null, + bootstrapPendingDirectory: null, + preserveDirectoryOverride: false, + parentID: null, + title: undefined, + initialPrompt: undefined, + syntheticParts: undefined, + targetFolderId: undefined, + }, + }) + }, + + setNewSessionDraftTarget: (target) => + set((s) => ({ + newSessionDraft: { + ...s.newSessionDraft, + selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId, + directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride, + }, + })), + + setDraftPreserveDirectoryOverride: (value) => + set((s) => { + if (!s.newSessionDraft?.open) return s + return { newSessionDraft: { ...s.newSessionDraft, preserveDirectoryOverride: value } } + }), + + acknowledgeSessionAbort: (sessionId) => + set((s) => { + const flags = new Map(s.sessionAbortFlags) + const existing = flags.get(sessionId) + if (existing) flags.set(sessionId, { ...existing, acknowledged: true }) + return { sessionAbortFlags: flags } + }), + + clearAbortPrompt: () => set({ abortPromptSessionId: null, abortPromptExpiresAt: null }), + + armAbortPrompt: (durationMs = 5000) => { + const { currentSessionId } = get() + if (!currentSessionId) return null + const expiresAt = Date.now() + durationMs + set({ abortPromptSessionId: currentSessionId, abortPromptExpiresAt: expiresAt }) + return expiresAt + }, + + clearError: () => set({ error: null }), + + markSessionAsOpenChamberCreated: (sessionId) => + set((s) => { + const next = new Set(s.webUICreatedSessions) + next.add(sessionId) + return { webUICreatedSessions: next } + }), + + isOpenChamberCreatedSession: (sessionId) => get().webUICreatedSessions.has(sessionId), + + getContextUsage: (contextLimit: number, outputLimit: number) => { + if (get().newSessionDraft?.open) return null + const sessionId = get().currentSessionId + if (!sessionId) return null + + const messages = getSyncMessages(sessionId) + if (messages.length === 0) return null + + // Find last assistant message with token data + type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + let lastTokens: AssistantTokens | undefined + let lastMessageId: string | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "assistant") continue + const tokens = (msg as { tokens?: AssistantTokens }).tokens + if (!tokens) continue + const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0) + if (total > 0) { + lastTokens = tokens + lastMessageId = msg.id + break + } + } + + if (!lastTokens) return null + + const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0) + const thresholdLimit = contextLimit > 0 ? contextLimit : 200000 + const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0 + const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined + + return { + totalTokens, + percentage, + contextLimit: contextLimit || 0, + outputLimit: outputLimit || undefined, + normalizedOutput, + thresholdLimit, + lastMessageId, + } + }, + + initializeNewOpenChamberSession: () => { + // Stub — was a no-op in old store + }, + + setWorktreeMetadata: (sessionId, metadata) => + set((s) => { + const map = new Map(s.worktreeMetadata) + if (metadata) map.set(sessionId, metadata) + else map.delete(sessionId) + return { worktreeMetadata: map } + }), + + overrideNewSessionDraftTarget: (options) => + set((s) => ({ + newSessionDraft: { ...s.newSessionDraft, ...options }, + })), + + resolvePendingDraftWorktreeTarget: (requestId, directory, options) => + set((s) => { + if (!s.newSessionDraft?.open || s.newSessionDraft.pendingWorktreeRequestId !== requestId) return s + return { + newSessionDraft: { + ...s.newSessionDraft, + selectedProjectId: (options as Record | undefined)?.projectId as string ?? s.newSessionDraft.selectedProjectId ?? null, + directoryOverride: normalizePath(directory), + pendingWorktreeRequestId: null, + bootstrapPendingDirectory: normalizePath((options as Record | undefined)?.bootstrapPendingDirectory as string ?? s.newSessionDraft.bootstrapPendingDirectory ?? null), + preserveDirectoryOverride: ((options as Record | undefined)?.preserveDirectoryOverride ?? true) as boolean, + }, + } + }), + + setDraftBootstrapPendingDirectory: (directory) => + set((s) => { + if (!s.newSessionDraft?.open) return s + return { newSessionDraft: { ...s.newSessionDraft, bootstrapPendingDirectory: normalizePath(directory) } } + }), + + setPendingDraftWorktreeRequest: (requestId) => + set((s) => { + if (!s.newSessionDraft?.open) return s + return { newSessionDraft: { ...s.newSessionDraft, pendingWorktreeRequestId: requestId } } + }), + + getWorktreeMetadata: (sessionId) => get().worktreeMetadata.get(sessionId), + + // --------------------------------------------------------------------------- + // sendMessage — calls SDK, reads domain data from sync + // --------------------------------------------------------------------------- + sendMessage: async ( + content: string, + providerID: string, + modelID: string, + agent?: string, + attachments?: AttachedFile[], + agentMentionName?: string, + additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, + variant?: string, + inputMode?: "normal" | "shell", + ) => { + const draft = get().newSessionDraft + const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined + + // ---- New session from draft ---- + if (draft?.open) { + const draftTargetFolderId = draft.targetFolderId + let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null + const draftProjectId = draft.selectedProjectId ?? null + + if (draft.pendingWorktreeRequestId) { + draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId) + get().resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) + } + + const created = await get().createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) + if (!created?.id) throw new Error("Failed to create session") + + persistDraftTarget({ + projectId: draftProjectId, + directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), + }) + + const configState = useConfigStore.getState() + const draftAgentName = configState.currentAgentName + const effectiveDraftAgent = trimmedAgent ?? draftAgentName + + if (configState.currentProviderId && configState.currentModelId) { + useSelectionStore.getState().saveSessionModelSelection(created.id, configState.currentProviderId, configState.currentModelId) + } + + if (effectiveDraftAgent) { + useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent) + if (configState.currentProviderId && configState.currentModelId) { + useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, configState.currentProviderId, configState.currentModelId) + useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, configState.currentProviderId, configState.currentModelId, variant) + } + } + + get().initializeNewOpenChamberSession(created.id, configState.agents ?? []) + + const draftSyntheticParts = draft.syntheticParts + const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null) + + get().closeNewSessionDraft() + get().setCurrentSession(created.id, createdDirectory) + + if (draftTargetFolderId) { + const scopeKey = draftDirectoryOverride || created.directory || null + if (scopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id) + } + } + + const mergedAdditionalParts = draftSyntheticParts?.length + ? [...(additionalParts || []), ...draftSyntheticParts] + : additionalParts + + if (createdDirectory) { + await waitForWorktreeBootstrap(createdDirectory) + } + + markPendingUserSendAnimation(created.id) + + const files = attachments?.map((a) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })) + + await routeMessage({ + sessionId: created.id, + content, + providerID, + modelID, + agent: effectiveDraftAgent, + variant, + inputMode, + files, + additionalParts: mergedAdditionalParts?.map((p) => ({ + text: p.text, + synthetic: p.synthetic, + files: p.attachments?.map((a: AttachedFile) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })), + })), + }) + return + } + + // ---- Existing session ---- + const currentSessionId = get().currentSessionId + const sessionAgentSelection = currentSessionId + ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) + : null + const configAgentName = useConfigStore.getState().currentAgentName + const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined + + if (currentSessionId && effectiveAgent) { + useSelectionStore.getState().saveSessionAgentSelection(currentSessionId, effectiveAgent) + useSelectionStore.getState().saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant) + } + + if (currentSessionId) { + const viewportState = useViewportStore.getState() + const memState = viewportState.sessionMemoryState.get(currentSessionId) + if (!memState || !memState.lastUserMessageAt) { + const newMemState = new Map(viewportState.sessionMemoryState) + newMemState.set(currentSessionId, { + viewportAnchor: memState?.viewportAnchor ?? 0, + isStreaming: memState?.isStreaming ?? false, + lastAccessedAt: Date.now(), + backgroundMessageCount: memState?.backgroundMessageCount ?? 0, + lastUserMessageAt: Date.now(), + }) + useViewportStore.setState({ sessionMemoryState: newMemState }) + } + } + + const currentSessionDirectory = currentSessionId + ? normalizePath(get().getDirectoryForSession(currentSessionId)) + : null + if (currentSessionDirectory) { + await waitForWorktreeBootstrap(currentSessionDirectory) + } + + if (currentSessionId) { + fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: "POST" }) + .catch(() => { /* ignore */ }) + } + + if (currentSessionId) { + markPendingUserSendAnimation(currentSessionId) + } + + const files = attachments?.map((a) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })) + + await routeMessage({ + sessionId: currentSessionId || "", + content, + providerID, + modelID, + agent: effectiveAgent, + variant, + inputMode, + files, + additionalParts: additionalParts?.map((p) => ({ + text: p.text, + synthetic: p.synthetic, + files: p.attachments?.map((a) => ({ + type: "file" as const, + mime: a.mimeType, + url: a.dataUrl, + filename: a.filename, + })), + })), + }) + }, + + // --------------------------------------------------------------------------- + // createSession + // --------------------------------------------------------------------------- + createSession: async (title, directoryOverride, parentID) => { + const draft = get().newSessionDraft + const targetFolderId = draft.targetFolderId + get().closeNewSessionDraft() + + try { + const dir = directoryOverride ?? opencodeClient.getDirectory() + const session = await createSessionAction(title, dir, parentID ?? null) + if (!session) return null + + if (targetFolderId) { + const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory + if (scopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id) + } + } + + return session + } catch (e) { + console.error("[session-ui-store] createSession failed", e) + return null + } + }, + + // --------------------------------------------------------------------------- + // deleteSession — calls SDK, SSE event updates child store + // --------------------------------------------------------------------------- + deleteSession: (id) => deleteSessionAction(id), + + deleteSessions: async (ids) => { + const deletedIds: string[] = [] + const failedIds: string[] = [] + for (const id of ids) { + const ok = await deleteSessionAction(id) + if (ok) deletedIds.push(id) + else failedIds.push(id) + } + return { deletedIds, failedIds } + }, + + archiveSession: (id) => archiveSessionAction(id), + + archiveSessions: async (ids) => { + const archivedIds: string[] = [] + const failedIds: string[] = [] + for (const id of ids) { + const ok = await archiveSessionAction(id) + if (ok) archivedIds.push(id) + else failedIds.push(id) + } + return { archivedIds, failedIds } + }, + + // --------------------------------------------------------------------------- + // updateSessionTitle — calls SDK, SSE event updates child store + // --------------------------------------------------------------------------- + updateSessionTitle: async (sessionId, title) => { + await updateSessionTitleAction(sessionId, title) + }, + + shareSession: async (sessionId) => { + return shareSessionAction(sessionId) + }, + + unshareSession: async (sessionId) => { + return unshareSessionAction(sessionId) + }, + + // --------------------------------------------------------------------------- + // revertToMessage — delegates to session-actions (single implementation) + // --------------------------------------------------------------------------- + revertToMessage: async (sessionId, messageId) => { + const { revertToMessage: revert } = await import("./session-actions") + await revert(sessionId, messageId) + }, + + // --------------------------------------------------------------------------- + // handleSlashUndo — reads from sync + // --------------------------------------------------------------------------- + handleSlashUndo: async (sessionId) => { + const messages = getSyncMessages(sessionId) + const sessions = getSyncSessions() + const currentSession = sessions.find((s) => s.id === sessionId) + + const userMessages = messages.filter((m) => m.role === "user") + if (userMessages.length === 0) return + + const revertToId = currentSession?.revert?.messageID + let targetMessage: typeof messages[number] | undefined + if (revertToId) { + const revertIndex = userMessages.findIndex((m) => m.id === revertToId) + targetMessage = userMessages[revertIndex + 1] + } else { + targetMessage = userMessages[userMessages.length - 1] + } + + if (!targetMessage) return + + const targetParts = getSyncParts(targetMessage.id) + const textPart = targetParts.find((p: Part) => p.type === "text") as TextPart | undefined + const preview = textPart?.text + ? String(textPart.text).slice(0, 50) + (textPart.text.length > 50 ? "..." : "") + : "[No text]" + + await get().revertToMessage(sessionId, targetMessage.id) + + const { toast } = await import("sonner") + toast.success(`Undid to: ${preview}`) + }, + + // --------------------------------------------------------------------------- + // handleSlashRedo — reads from sync + // --------------------------------------------------------------------------- + handleSlashRedo: async (sessionId) => { + const sessions = getSyncSessions() + const currentSession = sessions.find((s) => s.id === sessionId) + const revertToId = currentSession?.revert?.messageID + if (!revertToId) return + + const messages = getSyncMessages(sessionId) + const userMessages = messages.filter((m) => m.role === "user") + const revertIndex = userMessages.findIndex((m) => m.id === revertToId) + const targetMessage = userMessages[revertIndex - 1] + + if (targetMessage) { + const targetParts = getSyncParts(targetMessage.id) + const textPart = targetParts.find((p: Part) => p.type === "text") as TextPart | undefined + const preview = textPart?.text + ? String(textPart.text).slice(0, 50) + (textPart.text.length > 50 ? "..." : "") + : "[No text]" + + await get().revertToMessage(sessionId, targetMessage.id) + + const { toast } = await import("sonner") + toast.success(`Redid to: ${preview}`) + } else { + // Full unrevert + const { unrevertSession } = await import("./session-actions") + await unrevertSession(sessionId) + + const { toast } = await import("sonner") + toast.success("Restored all messages") + } + }, + + // --------------------------------------------------------------------------- + // forkFromMessage — delegates to session-actions (handles text + sidebar) + // --------------------------------------------------------------------------- + forkFromMessage: async (sessionId, messageId) => { + const sessions = getSyncSessions() + const existingSession = sessions.find((s) => s.id === sessionId) + if (!existingSession) return + + try { + const { forkFromMessage: fork } = await import("./session-actions") + await fork(sessionId, messageId) + + const { toast } = await import("sonner") + toast.success(`Forked from ${existingSession.title}`) + } catch (error) { + console.error("Failed to fork session:", error) + const { toast } = await import("sonner") + toast.error("Failed to fork session") + } + }, + + // --------------------------------------------------------------------------- + // createSessionFromAssistantMessage — reads from sync + // --------------------------------------------------------------------------- + createSessionFromAssistantMessage: async (sourceMessageId) => { + if (!sourceMessageId) return + + // Find which session this message belongs to by scanning sync state + const state = getDirectoryState() + if (!state) return + + let sourceSessionId: string | undefined + let sourceMessage: Message | undefined + + for (const [sid, msgs] of Object.entries(state.message ?? {})) { + const found = msgs.find((m) => m.id === sourceMessageId) + if (found) { + sourceSessionId = sid + sourceMessage = found + break + } + } + + if (!sourceMessage || sourceMessage.role !== "assistant") return + + const sourceParts = getSyncParts(sourceMessageId) + const assistantPlanText = flattenAssistantTextParts(sourceParts) + if (!assistantPlanText.trim()) return + + const directory = resolveSessionDirectory( + sourceSessionId ?? null, + (sid) => get().worktreeMetadata.get(sid), + ) + + const session = await get().createSession(undefined, directory ?? null, null) + if (!session) return + + const { currentProviderId, currentModelId, currentAgentName } = useConfigStore.getState() + const pID = currentProviderId || useSelectionStore.getState().lastUsedProvider?.providerID + const mID = currentModelId || useSelectionStore.getState().lastUsedProvider?.modelID + + if (!pID || !mID) return + + await opencodeClient.sendMessage({ + id: session.id, + providerID: pID, + modelID: mID, + text: assistantPlanText, + prefaceText: EXECUTION_FORK_META_TEXT, + agent: currentAgentName ?? undefined, + }) + }, + + // --------------------------------------------------------------------------- + // Data access helpers — read from sync + // --------------------------------------------------------------------------- + getSessionsByDirectory: (directory) => { + const nd = normalizePath(directory) + if (!nd) return [] + const sessions = getAllSyncSessions() + return sessions.filter((s) => resolveDirectoryKey(s) === nd) + }, + + getDirectoryForSession: (sessionId) => { + const sessions = getAllSyncSessions() + const session = sessions.find((s) => s.id === sessionId) + if (!session) return null + return resolveDirectoryKey(session) + }, + + getLastUserChoice: (sessionId) => { + const directory = get().getDirectoryForSession(sessionId) ?? undefined + const messages = getSyncMessages(sessionId, directory) + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] as Message & { + model?: { providerID?: string; modelID?: string } + variant?: string + mode?: string + } + if (message.role !== "user") { + continue + } + + const providerID = typeof message.model?.providerID === "string" && message.model.providerID.trim().length > 0 + ? message.model.providerID + : undefined + const modelID = typeof message.model?.modelID === "string" && message.model.modelID.trim().length > 0 + ? message.model.modelID + : undefined + const agent = typeof message.agent === "string" && message.agent.trim().length > 0 + ? message.agent + : (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined) + const variant = typeof message.variant === "string" && message.variant.trim().length > 0 + ? message.variant + : undefined + + return { agent, providerID, modelID, variant } + } + return null + }, + + getCurrentAgent: (sessionId) => { + return useSelectionStore.getState().sessionAgentSelections.get(sessionId) ?? undefined + }, + + debugSessionMessages: async (sessionId) => { + const msgs = getSyncMessages(sessionId) + const sessions = getSyncSessions() + const session = sessions.find((s) => s.id === sessionId) + console.log(`Debug session ${sessionId}:`, { + session, + messageCount: msgs.length, + messages: msgs.map((m) => ({ + id: m.id, + role: m.role, + tokens: m.role === "assistant" ? m.tokens : undefined, + })), + }) + }, + + pollForTokenUpdates: () => { + // Handled by sync system's SSE stream + }, + + setSessionDirectory: () => { + // Session directory is owned by sync child stores via SSE events. + // This is now a no-op — kept for interface compatibility during migration. + }, +})) diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts new file mode 100644 index 00000000..d178a1dc --- /dev/null +++ b/packages/ui/src/sync/streaming.ts @@ -0,0 +1,131 @@ +/** + * Streaming lifecycle tracking. + * + * Derives streaming state from the sync child store's session_status and + * message/part updates. Components read this to know which messages are + * currently streaming and their lifecycle phase. + */ + +import { create } from "zustand" +import type { Message, SessionStatus } from "@opencode-ai/sdk/v2/client" +import type { State } from "./types" + +export type StreamPhase = "streaming" | "cooldown" | "completed" + +export type MessageStreamState = { + phase: StreamPhase + startedAt: number + lastUpdateAt: number + completedAt?: number +} + +export type StreamingStore = { + /** Currently streaming message per session */ + streamingMessageIds: Map + /** Lifecycle phase per message */ + messageStreamStates: Map +} + +export const useStreamingStore = create()(() => ({ + streamingMessageIds: new Map(), + messageStreamStates: new Map(), +})) + +/** + * Called from the SyncBridge/flush handler when child store state changes. + * Derives streaming state from session_status + messages. + */ +export function updateStreamingState(state: State) { + const now = Date.now() + const nextStreamingIds = new Map() + const nextStreamStates = new Map(useStreamingStore.getState().messageStreamStates) + let changed = false + + for (const [sessionID, status] of Object.entries(state.session_status ?? {})) { + const isBusy = (status as SessionStatus).type === "busy" + const messages = state.message[sessionID] + + if (isBusy && messages && messages.length > 0) { + // Find the last assistant message — that's the one streaming + let streamingMsg: Message | null = null + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + streamingMsg = messages[i] + break + } + } + + if (streamingMsg) { + const prevId = nextStreamingIds.get(sessionID) + if (prevId !== streamingMsg.id) changed = true + nextStreamingIds.set(sessionID, streamingMsg.id) + + const existing = nextStreamStates.get(streamingMsg.id) + if (!existing || existing.phase !== "streaming") { + nextStreamStates.set(streamingMsg.id, { + phase: "streaming", + startedAt: existing?.startedAt ?? now, + lastUpdateAt: now, + }) + changed = true + } else if (existing.lastUpdateAt !== now) { + nextStreamStates.set(streamingMsg.id, { + ...existing, + lastUpdateAt: now, + }) + changed = true + } + } + } else { + // Session is idle — check if we had a streaming message + const prev = useStreamingStore.getState().streamingMessageIds.get(sessionID) + if (prev) { + nextStreamingIds.set(sessionID, null) + const existing = nextStreamStates.get(prev) + if (existing && existing.phase === "streaming") { + // Transition to cooldown then completed + nextStreamStates.set(prev, { + ...existing, + phase: "completed", + completedAt: now, + }) + changed = true + } + } + } + } + + // Also mark completed any streaming messages for sessions no longer in status + const currentIds = useStreamingStore.getState().streamingMessageIds + for (const [sessionID, msgId] of currentIds) { + if (msgId && !state.session_status?.[sessionID]) { + const existing = nextStreamStates.get(msgId) + if (existing && existing.phase === "streaming") { + nextStreamStates.set(msgId, { + ...existing, + phase: "completed", + completedAt: now, + }) + changed = true + } + nextStreamingIds.set(sessionID, null) + } + } + + if (changed) { + useStreamingStore.setState({ + streamingMessageIds: nextStreamingIds, + messageStreamStates: nextStreamStates, + }) + } +} + +// Selectors +export const selectStreamingMessageId = (sessionID: string) => + (state: StreamingStore) => state.streamingMessageIds.get(sessionID) ?? null + +export const selectMessageStreamState = (messageID: string) => + (state: StreamingStore) => state.messageStreamStates.get(messageID) ?? null + +export const selectIsStreaming = (sessionID: string) => + (state: StreamingStore) => state.streamingMessageIds.get(sessionID) != null diff --git a/packages/ui/src/sync/submit.ts b/packages/ui/src/sync/submit.ts new file mode 100644 index 00000000..943f9e66 --- /dev/null +++ b/packages/ui/src/sync/submit.ts @@ -0,0 +1,143 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { useCallback } from "react" +import { useSyncSDK } from "./sync-context" +import { useDirectoryStore } from "./sync-context" +import { useSync } from "./use-sync" + +// --------------------------------------------------------------------------- +// Ascending ID generator — monotonic timestamp + sequence counter +// --------------------------------------------------------------------------- + +let counter = 0 + +function ascending(prefix: string): string { + const now = Date.now() + const seq = (counter++ % 1000).toString().padStart(3, "0") + return `${prefix}_${now}${seq}` +} + +// --------------------------------------------------------------------------- +// Prompt submission with optimistic updates +// Prompt submission with optimistic message insertion +// --------------------------------------------------------------------------- + +export type SubmitInput = { + sessionID: string + text: string + parts?: Part[] + agent: string + model: { providerID: string; modelID: string } + variant?: string + command?: { name: string; arguments: string } + images?: Array<{ id?: string; type: "file"; mime: string; url: string; filename: string }> +} + +export function usePromptSubmit() { + const sdk = useSyncSDK() + const store = useDirectoryStore() + const sync = useSync() + + const submit = useCallback( + async (input: SubmitInput) => { + const messageID = ascending("message") + + // Build optimistic user message + const message: Message = { + id: messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: input.model, + variant: input.variant, + } as Message + + // Build optimistic parts + const textPart: Part = { + id: ascending("part"), + sessionID: input.sessionID, + messageID, + type: "text", + text: input.text, + } as Part + + const optimisticParts: Part[] = [textPart, ...(input.parts ?? [])] + + // Set busy status optimistically + store.setState((prev) => ({ + ...prev, + session_status: { + ...prev.session_status, + [input.sessionID]: { type: "busy" }, + }, + })) + + // Add optimistic message immediately + sync.optimistic.add({ + sessionID: input.sessionID, + message, + parts: optimisticParts, + }) + + try { + if (input.command) { + // Slash command + await sdk.session.command({ + sessionID: input.sessionID, + command: input.command.name, + arguments: input.command.arguments, + agent: input.agent, + model: `${input.model.providerID}/${input.model.modelID}`, + variant: input.variant, + parts: input.images, + }) + } else { + // Regular prompt + const requestParts: Array<{ id: string; type: "text"; text: string } + | { id: string; type: "file"; mime: string; url: string; filename?: string }> = [ + { id: textPart.id, type: "text" as const, text: input.text }, + ] + if (input.images) { + for (const img of input.images) { + requestParts.push({ + id: img.id ?? ascending("part"), + type: "file" as const, + mime: img.mime, + url: img.url, + filename: img.filename, + }) + } + } + + await sdk.session.promptAsync({ + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + messageID, + parts: requestParts, + variant: input.variant, + }) + } + return true + } catch (error) { + // Revert optimistic on failure + sync.optimistic.remove({ + sessionID: input.sessionID, + messageID, + }) + // Reset status + store.setState((prev) => ({ + ...prev, + session_status: { + ...prev.session_status, + [input.sessionID]: { type: "idle" }, + }, + })) + throw error + } + }, + [sdk, store, sync], + ) + + return submit +} diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx new file mode 100644 index 00000000..afeb34c8 --- /dev/null +++ b/packages/ui/src/sync/sync-context.tsx @@ -0,0 +1,639 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react" +import type { Event, Message, Part } from "@opencode-ai/sdk/v2/client" +import type { StoreApi } from "zustand" +import { useStore } from "zustand" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { createEventPipeline } from "./event-pipeline" +import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" +import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" +import { ChildStoreManager, type DirectoryStore } from "./child-store" +import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap" +import { retry } from "./retry" +import { updateStreamingState } from "./streaming" +import { setActionRefs } from "./session-actions" +import { setSyncRefs } from "./sync-refs" +import { opencodeClient } from "@/lib/opencode/client" +import { usePermissionStore } from "@/stores/permissionStore" +import { autoRespondsPermission, normalizeDirectory } from "@/stores/utils/permissionAutoAccept" +import { appendNotification } from "./notification-store" +import type { State } from "./types" +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest } from "@/types/permission" +import type { QuestionRequest } from "@/types/question" +import { create } from "zustand" +import * as sessionActions from "./session-actions" + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +type SyncSystem = { + childStores: ChildStoreManager + sdk: OpencodeClient + directory: string +} + +const SyncContext = createContext(null) + +function useSyncSystem() { + const ctx = useContext(SyncContext) + if (!ctx) throw new Error("useSyncSystem must be used within ") + return ctx +} + +// --------------------------------------------------------------------------- +// Event handler — applies one SSE event at a time to the live store. +// Each event reads live state, creates a shallow draft, applies, writes back. +// React 18 batches synchronous setState calls automatically. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Global session status store — cross-directory status tracking. +// +// OpenCode isolates sessions behind project navrails, so per-directory +// session_status is sufficient. OpenChamber shows all sessions in one sidebar, +// so we need a global view. Updated from handleEvent on every session.status. +// --------------------------------------------------------------------------- + +interface GlobalSessionStatusStore { + statuses: Record +} + +const useGlobalSessionStatusStore = create(() => ({ + statuses: {}, +})) + +function setGlobalSessionStatus(sessionId: string, status: SessionStatus) { + const current = useGlobalSessionStatusStore.getState().statuses + if (current[sessionId] === status) return + useGlobalSessionStatusStore.setState({ + statuses: { ...current, [sessionId]: status }, + }) +} + +/** Read status for a session across all directories */ +export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined { + return useGlobalSessionStatusStore((s) => s.statuses[sessionId]) +} + +/** Read all session statuses (for sidebar) */ +export function useAllSessionStatuses(): Record { + return useGlobalSessionStatusStore((s) => s.statuses) +} + +// Boot debounce — suppresses redundant refresh/re-bootstrap events during startup. +let bootingRoot = false +let bootedAt = 0 +const BOOT_DEBOUNCE_MS = 1500 + +// Module-level refs for notification viewed check. +// Used to determine if user is currently viewing the session when a notification arrives. +let _activeDirectory = "" +let _activeSession = "" + +export function setActiveSession(directory: string, sessionId: string) { + _activeDirectory = directory + _activeSession = sessionId +} + +function isViewedInCurrentSession(directory: string, sessionId?: string): boolean { + if (!_activeDirectory || !_activeSession || !sessionId) return false + if (directory !== _activeDirectory) return false + return sessionId === _activeSession +} + +function isRecentBoot() { + return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS +} + +function handleEvent( + directory: string, + payload: Event, + childStores: ChildStoreManager, +) { + // Global events + if (directory === "global" || !directory) { + const recent = isRecentBoot() + const result = reduceGlobalEvent(payload) + if (!result) return + if (result.type === "refresh") { + // Suppress refresh during/shortly after bootstrap + if (!recent) { + useGlobalSyncStore.setState({ reload: "pending" }) + } + } else if (result.type === "project") { + const current = useGlobalSyncStore.getState() + useGlobalSyncStore.setState({ + projects: applyGlobalProject(current, result.project).projects, + }) + } + // On server.connected / global.disposed, re-bootstrap all directories + // but only if not during recent boot + if (payload.type === "server.connected" || payload.type === "global.disposed") { + if (!recent) { + for (const dir of childStores.children.keys()) { + const store = childStores.getChild(dir) + if (store && store.getState().status !== "loading") { + // Mark as loading to trigger re-bootstrap + store.setState({ status: "loading" as const }) + childStores.ensureChild(dir) + } + } + } + } + return + } + + // Directory events + const store = childStores.getChild(directory) + if (!store) { + // Try as global event for unknown directories + const result = reduceGlobalEvent(payload) + if (result?.type === "refresh") { + useGlobalSyncStore.setState({ reload: "pending" }) + } else if (result?.type === "project") { + const current = useGlobalSyncStore.getState() + useGlobalSyncStore.setState({ + projects: applyGlobalProject(current, result.project).projects, + }) + } + return + } + + childStores.mark(directory) + + // Notification dispatch for session turn-complete and error events. + // These are NOT handled by the event reducer — only the notification store. + if (payload.type === "session.idle" || payload.type === "session.error") { + const props = payload.properties as { sessionID?: string; error?: { message?: string; code?: string } } + const sessionID = props.sessionID + // Skip subtask sessions — only top-level sessions generate notifications + const storeState = store.getState() + const session = storeState.session.find((s) => s.id === sessionID) + if (session && (session as { parentID?: string }).parentID) { + // subtask — skip notification + } else if (sessionID) { + appendNotification({ + directory, + session: sessionID, + time: Date.now(), + viewed: isViewedInCurrentSession(directory, sessionID), + ...(payload.type === "session.error" + ? { type: "error" as const, error: props.error } + : { type: "turn-complete" as const }), + }) + } + } + + // Read live state, create targeted draft cloning ONLY fields the event + // type will mutate. This preserves reference identity for untouched slices + // so Zustand selectors skip re-renders for unrelated subscribers. + const current = store.getState() + const draft: State = { ...current } + + switch (payload.type) { + case "session.created": + case "session.updated": + case "session.deleted": + draft.session = [...current.session] + draft.permission = { ...current.permission } + draft.todo = { ...current.todo } + draft.part = { ...current.part } + break + case "session.diff": + draft.session_diff = { ...current.session_diff } + break + case "session.status": + draft.session_status = { ...(current.session_status ?? {}) } + break + case "todo.updated": + draft.todo = { ...current.todo } + break + case "message.updated": + draft.message = { ...current.message } + break + case "message.removed": + draft.message = { ...current.message } + draft.part = { ...current.part } + break + case "message.part.updated": + case "message.part.removed": + case "message.part.delta": + draft.part = { ...current.part } + break + case "vcs.branch.updated": + break + case "permission.asked": + case "permission.replied": + draft.permission = { ...current.permission } + break + case "question.asked": + case "question.replied": + case "question.rejected": + draft.question = { ...current.question } + break + case "lsp.updated": + draft.lsp = [...current.lsp] + break + default: + break + } + + if (applyDirectoryEvent(draft, payload)) { + store.setState(draft) + } + + // Update global session status for cross-directory sidebar visibility + if (payload.type === "session.status") { + const props = payload.properties as { sessionID: string; status: SessionStatus } + setGlobalSessionStatus(props.sessionID, props.status) + } + + if (payload.type === "permission.asked") { + const normalizedDirectory = normalizeDirectory(directory) + if (!normalizedDirectory) { + return + } + + const permission = payload.properties as PermissionRequest + const sessions = store.getState().session + const autoAccept = usePermissionStore.getState().autoAccept + if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: normalizedDirectory })) { + void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined) + } + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function SyncProvider(props: { + sdk: OpencodeClient + directory: string + children: React.ReactNode +}) { + const childStoresRef = useRef(null) + if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager() + const childStores = childStoresRef.current + + const system = useMemo( + () => ({ + childStores, + sdk: props.sdk, + directory: props.directory, + }), + [childStores, props.sdk, props.directory], + ) + + // Configure child store manager + useEffect(() => { + const bootingDirs = new Set() + + childStores.configure({ + onBootstrap: (directory) => { + if (bootingDirs.has(directory)) return + bootingDirs.add(directory) + + const store = childStores.getChild(directory) + if (!store) return + + const runBootstrap = async (attempt: number) => { + const globalState = useGlobalSyncStore.getState() + await bootstrapDirectory({ + directory, + sdk: props.sdk, + getState: () => store.getState(), + set: (patch) => { + store.setState(patch) + if (patch.session_status) { + const current = useGlobalSessionStatusStore.getState().statuses + const merged = { ...current, ...patch.session_status } + useGlobalSessionStatusStore.setState({ statuses: merged }) + } + }, + global: { + config: globalState.config, + projects: globalState.projects, + providers: globalState.providers, + }, + loadSessions: (dir) => retry(async () => { + const result = await props.sdk.session.list({ + directory: dir, + roots: true, + limit: 50, + }) + // SDK returns { error } instead of { data } on non-ok responses (503). + // Throw so retry() retries and allSettled marks it as rejected. + if ((result as { error?: unknown }).error) { + throw new Error("session.list failed: " + String((result as { error?: unknown }).error)) + } + const sessions = (result.data ?? []) + .filter((s) => !!s?.id) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) }) + }), + }) + + // VS Code race: if sessions are still empty after bootstrap, OpenCode + // wasn't ready yet (bridge returned 503). Retry a few times. + const state = store.getState() + if (state.session.length === 0 && attempt < 5) { + await new Promise((r) => setTimeout(r, 2000)) + store.setState({ status: "loading" as const }) + await runBootstrap(attempt + 1) + } + } + + runBootstrap(0).finally(() => { + bootingDirs.delete(directory) + }) + }, + onDispose: (directory) => { + bootingDirs.delete(directory) + }, + isBooting: (directory) => bootingDirs.has(directory), + isLoadingSessions: () => false, + }) + }, [childStores, props.sdk]) + + // Bootstrap global state — set bootingRoot/bootedAt to suppress + // redundant refresh events during startup + useEffect(() => { + bootingRoot = true + const globalActions = useGlobalSyncStore.getState().actions + bootstrapGlobal(props.sdk, globalActions.set) + .then(() => { + bootedAt = Date.now() + }) + .finally(() => { + bootingRoot = false + }) + }, [props.sdk]) + + // Event pipeline — created once per mount. No class, no start/stop. + // Abort controller owned by the pipeline closure. Cleanup aborts + flushes. + useEffect(() => { + const { cleanup } = createEventPipeline({ + sdk: props.sdk, + onEvent: (directory, payload) => { + handleEvent(directory, payload, childStores) + }, + }) + return cleanup + }, [props.sdk, childStores]) + + // Ensure current directory's child store exists + useEffect(() => { + if (props.directory) { + childStores.ensureChild(props.directory) + } + }, [props.directory, childStores]) + + // Set refs so non-React code (session-actions, session-ui-store) can access sync state + useEffect(() => { + setSyncRefs(props.sdk, childStores, props.directory) + setActionRefs( + props.sdk, + childStores, + () => opencodeClient.getDirectory() || props.directory, + ) + }, [props.sdk, props.directory, childStores]) + + // Subscribe to child store for streaming state derivation + useEffect(() => { + if (!props.directory) return + const store = childStores.getChild(props.directory) + if (!store) return + const unsubscribe = store.subscribe((state) => { + updateStreamingState(state) + }) + return unsubscribe + }, [props.directory, childStores]) + + return {props.children} +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +/** Access the global sync store */ +export function useGlobalSync() { + return useGlobalSyncStore() +} + +/** Access the global sync store with a selector */ +export function useGlobalSyncSelector(selector: (state: GlobalSyncStore) => T): T { + return useGlobalSyncStore(selector) +} + +/** Get the child store for a directory (defaults to current) */ +export function useDirectoryStore(directory?: string): StoreApi { + const system = useSyncSystem() + const dir = directory ?? system.directory + return system.childStores.ensureChild(dir) +} + +/** Select from the current directory's store */ +export function useDirectorySync(selector: (state: State) => T, directory?: string): T { + const store = useDirectoryStore(directory) + return useStore(store, selector) +} + +/** Get the revert messageID for a session (if reverted) */ +export function useSessionRevertMessageID(sessionID: string, directory?: string): string | undefined { + return useDirectorySync( + useCallback((state: State) => { + const session = state.session.find((s) => s.id === sessionID) + return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID + }, [sessionID]), + directory, + ) +} + +/** Get session messages for a specific session */ +export function useSessionMessages(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.message[sessionID] ?? EMPTY_MESSAGES, [sessionID]), + directory, + ) +} + +/** + * Get visible session messages — filters out reverted messages. + * Filters out reverted messages (id >= session.revert.messageID). + */ +export function useVisibleSessionMessages(sessionID: string, directory?: string) { + const messages = useSessionMessages(sessionID, directory) + const revertMessageID = useSessionRevertMessageID(sessionID, directory) + return useMemo(() => { + if (!revertMessageID) return messages + return messages.filter((m) => m.id < revertMessageID) + }, [messages, revertMessageID]) +} + +/** Get parts for a specific message */ +export function useSessionParts(messageID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.part[messageID] ?? EMPTY_PARTS, [messageID]), + directory, + ) +} + +/** Get status for a specific session */ +export function useSessionStatus(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.session_status?.[sessionID], [sessionID]), + directory, + ) +} + +/** Get permissions for a specific session */ +export function useSessionPermissions(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS, [sessionID]), + directory, + ) +} + +/** Get questions for a specific session */ +export function useSessionQuestions(sessionID: string, directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.question[sessionID] ?? EMPTY_QUESTION_REQUESTS, [sessionID]), + directory, + ) +} + +/** Get sessions list for a directory */ +export function useSessions(directory?: string) { + return useDirectorySync( + useCallback((state: State) => state.session, []), + directory, + ) +} + +/** Get the SDK client */ +export function useSyncSDK() { + return useSyncSystem().sdk +} + +/** Get the current directory */ +export function useSyncDirectory() { + return useSyncSystem().directory +} + +/** Get the child store manager (for advanced operations) */ +export function useChildStoreManager() { + return useSyncSystem().childStores +} + +/** + * Get messages for a session in the old {info, parts}[] format. + * Uses visible messages (filtered by revert state). + * + * Uses a ref-stable parts lookup that only triggers re-renders when + * a part array for one of our displayed messages actually changes. + */ +export function useSessionMessageRecords(sessionID: string, directory?: string) { + const messages = useVisibleSessionMessages(sessionID, directory) + const store = useDirectoryStore(directory) + + // Track parts with a ref to avoid subscribing to entire state.part map. + // Re-derive only when messages list changes or on store subscription. + const prevPartsRef = useRef>({}) + const [partsSnapshot, setPartsSnapshot] = React.useState>({}) + + React.useEffect(() => { + const messageIds = messages.map((m) => m.id) + let timer: ReturnType | null = null + let pending = false + + const flush = () => { + timer = null + pending = false + const state = store.getState() + const prev = prevPartsRef.current + let changed = false + const next: Record = {} + for (const id of messageIds) { + const parts = state.part[id] ?? EMPTY_PARTS + // Preserve existing reference if parts haven't changed in the store + next[id] = prev[id] === parts ? prev[id] : parts + if (next[id] !== prev[id]) changed = true + } + if (changed || Object.keys(prev).length !== messageIds.length) { + prevPartsRef.current = next + setPartsSnapshot(next) + } + } + + // Initial sync + flush() + + // Throttled subscription — batch rapid delta events into ~100ms updates + const unsub = store.subscribe(() => { + if (timer) { + pending = true + return + } + timer = setTimeout(() => { + flush() + if (pending) { + pending = false + timer = setTimeout(flush, 100) + } + }, 100) + }) + + return () => { + unsub() + if (timer) clearTimeout(timer) + } + }, [messages, store]) + + return useMemo( + () => messages.map((msg) => ({ + info: msg, + parts: partsSnapshot[msg.id] ?? EMPTY_PARTS, + })), + [messages, partsSnapshot], + ) +} + +/** + * Determines if a session is actively working. + * Checks session_status AND incomplete assistant messages as fallback. + * Returns false when permissions are pending (permission indicator takes priority). + */ +export function useIsSessionWorking(sessionID: string, directory?: string): boolean { + const status = useSessionStatus(sessionID, directory) + const permissions = useSessionPermissions(sessionID, directory) + const messages = useSessionMessages(sessionID, directory) + + return useMemo(() => { + // Permissions pending → not "working" (show permission indicator instead) + if (permissions.length > 0) return false + + // Check session_status + const statusWorking = status !== undefined && status.type !== "idle" + + // Check for incomplete assistant message (fallback if status event delayed) + let hasPendingAssistant = false + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m.role === "assistant" && typeof (m as { time?: { completed?: number } }).time?.completed !== "number") { + hasPendingAssistant = true + break + } + } + + return statusWorking || hasPendingAssistant + }, [status, permissions, messages]) +} + +const EMPTY_MESSAGES: Message[] = [] +const EMPTY_PARTS: Part[] = [] +const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = [] +const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = [] diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts new file mode 100644 index 00000000..a5d21c19 --- /dev/null +++ b/packages/ui/src/sync/sync-refs.ts @@ -0,0 +1,92 @@ +/** + * Sync refs — imperative access to sync state from non-React code. + * + * SyncProvider sets these refs on mount. Store actions (session-ui-store, + * session-actions) use them to read child-store domain data without hooks. + */ + +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { ChildStoreManager } from "./child-store" +import type { State } from "./types" + +let _sdk: OpencodeClient | null = null +let _childStores: ChildStoreManager | null = null +let _directory: string = "" + +export function setSyncRefs( + sdk: OpencodeClient, + childStores: ChildStoreManager, + directory: string, +) { + _sdk = sdk + _childStores = childStores + _directory = directory +} + +export function getSyncSDK(): OpencodeClient { + if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?") + return _sdk +} + +export function getSyncChildStores(): ChildStoreManager { + if (!_childStores) throw new Error("ChildStoreManager not initialized — is SyncProvider mounted?") + return _childStores +} + +export function getSyncDirectory(): string { + return _directory +} + +/** Read current directory's child store state. Returns undefined if not bootstrapped. */ +export function getDirectoryState(directory?: string): State | undefined { + const stores = _childStores + if (!stores) return undefined + const dir = directory || _directory + if (!dir) return undefined + return stores.getState(dir) +} + +/** Read sessions from current directory's child store */ +export function getSyncSessions(directory?: string) { + return getDirectoryState(directory)?.session ?? [] +} + +/** Read sessions across all initialized child stores */ +export function getAllSyncSessions() { + const stores = _childStores + if (!stores) return [] + + const deduped = new Map() + for (const store of stores.children.values()) { + for (const session of store.getState().session) { + if (!session?.id) continue + deduped.set(session.id, session) + } + } + return Array.from(deduped.values()) +} + +/** Read messages for a session from current directory's child store */ +export function getSyncMessages(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.message[sessionId] ?? [] +} + +/** Read parts for a message from current directory's child store */ +export function getSyncParts(messageId: string, directory?: string) { + return getDirectoryState(directory)?.part[messageId] ?? [] +} + +/** Read session status from current directory's child store */ +export function getSyncSessionStatus(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.session_status[sessionId] +} + +/** Read permissions for a session from current directory's child store */ +export function getSyncPermissions(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.permission[sessionId] ?? [] +} + +/** Read questions for a session from current directory's child store */ +export function getSyncQuestions(sessionId: string, directory?: string) { + return getDirectoryState(directory)?.question[sessionId] ?? [] +} diff --git a/packages/ui/src/sync/types.ts b/packages/ui/src/sync/types.ts new file mode 100644 index 00000000..99262737 --- /dev/null +++ b/packages/ui/src/sync/types.ts @@ -0,0 +1,142 @@ +import type { + Agent, + Command, + Config, + FileDiff, + LspStatus, + McpStatus, + Message, + Part, + Path, + PermissionRequest, + Project, + ProviderAuthResponse, + ProviderListResponse, + QuestionRequest, + Session, + SessionStatus, + Todo, + VcsInfo, +} from "@opencode-ai/sdk/v2/client" + +export type ProjectMeta = { + name?: string + icon?: { + override?: string + color?: string + } + commands?: { + start?: string + } +} + +/** Per-directory store state */ +export type State = { + status: "loading" | "partial" | "complete" + agent: Agent[] + command: Command[] + project: string + projectMeta: ProjectMeta | undefined + icon: string | undefined + provider: ProviderListResponse + config: Config + path: Path + session: Session[] + sessionTotal: number + session_status: Record + session_diff: Record + todo: Record + permission: Record + question: Record + mcp: Record + lsp: LspStatus[] + vcs: VcsInfo | undefined + limit: number + message: Record + part: Record +} + +/** Global store state */ +export type GlobalState = { + ready: boolean + error?: InitError + path: Path + projects: Project[] + providers: ProviderListResponse + providerAuth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" + sessionTodo: Record +} + +export type InitError = { + type: "init" + message: string +} + +export type DirState = { + lastAccessAt: number +} + +export type EvictPlan = { + stores: string[] + state: Map + pins: Set + max: number + ttl: number + now: number +} + +export type DisposeCheck = { + directory: string + hasStore: boolean + pinned: boolean + booting: boolean + loadingSessions: boolean +} + +export type ChildOptions = { + bootstrap?: boolean +} + +export const MAX_DIR_STORES = 30 +export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 +export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 +export const SESSION_RECENT_LIMIT = 50 +export const SESSION_CACHE_LIMIT = 8 + +export const INITIAL_STATE: State = { + project: "", + projectMeta: undefined, + icon: undefined, + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + status: "loading", + agent: [], + command: [], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: undefined, + limit: 5, + message: {}, + part: {}, +} + +export const INITIAL_GLOBAL_STATE: GlobalState = { + ready: false, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + projects: [], + providers: { all: [], connected: [], default: {} }, + providerAuth: {}, + config: {}, + reload: undefined, + sessionTodo: {}, +} diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts new file mode 100644 index 00000000..eda18483 --- /dev/null +++ b/packages/ui/src/sync/use-sync.ts @@ -0,0 +1,413 @@ +import { useCallback, useRef, useMemo } from "react" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { Binary } from "./binary" +import { retry } from "./retry" +import { SESSION_CACHE_LIMIT } from "./types" +import { pickSessionCacheEvictions } from "./session-cache" +import { + mergeOptimisticPage, + mergeMessages, + type OptimisticItem, +} from "./optimistic" +import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context" +import { dropSessionCaches } from "./session-cache" +import { stripMessageDiffSnapshots } from "./sanitize" +import { + shouldSkipSessionPrefetch, + getSessionPrefetch, + setSessionPrefetch, + clearSessionPrefetch, +} from "./session-prefetch-cache" + +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +const MESSAGE_PAGE_SIZE = 200 +const MAX_SEEN_DIRS = 30 +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +function sortParts(parts: Part[]) { + return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) +} + +// --------------------------------------------------------------------------- +// useSync — message loading, pagination, optimistic updates +// Message loading, pagination, optimistic updates +// --------------------------------------------------------------------------- + +export function useSync() { + const sdk = useSyncSDK() + const directory = useSyncDirectory() + const store = useDirectoryStore() + const childStores = useChildStoreManager() + + // Refs for mutable tracking (no re-renders) + const inflight = useRef(new Map>()) + const optimistic = useRef(new Map>()) + const seen = useRef(new Map>()) + const meta = useRef(new Map()) + + const keyFor = useCallback( + (sessionID: string) => `${directory}\n${sessionID}`, + [directory], + ) + + const getMetaFor = useCallback( + (sessionID: string) => { + const key = keyFor(sessionID) + return meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false } + }, + [keyFor], + ) + + const setMetaFor = useCallback( + (sessionID: string, patch: Partial<{ limit: number; cursor: string | undefined; complete: boolean; loading: boolean }>) => { + const key = keyFor(sessionID) + const current = meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false } + meta.current.set(key, { ...current, ...patch }) + }, + [keyFor], + ) + + // Session cache eviction — two levels of LRU: + // (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT). + + // Evict all cached session data for given IDs from a directory's store + const evict = useCallback( + (dir: string, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + const dirStore = childStores.getChild(dir) + if (!dirStore) return + + const current = dirStore.getState() + const draft = { + message: { ...current.message }, + part: { ...current.part }, + session_status: { ...current.session_status }, + session_diff: { ...current.session_diff }, + todo: { ...current.todo }, + permission: { ...current.permission }, + question: { ...current.question }, + } + dropSessionCaches(draft, sessionIDs) + dirStore.setState(draft) + + // Clear meta + optimistic + prefetch cache for evicted sessions + for (const id of sessionIDs) { + optimistic.current.delete(`${dir}\n${id}`) + meta.current.delete(`${dir}\n${id}`) + } + clearSessionPrefetch(dir, sessionIDs) + }, + [childStores], + ) + + // Get or create the seen-set for a directory. LRU reorder on access. + // When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches. + // LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS. + const seenFor = useCallback(() => { + const existing = seen.current.get(directory) + if (existing) { + // LRU reorder: delete + re-insert moves to end (most recent) + seen.current.delete(directory) + seen.current.set(directory, existing) + return existing + } + const created = new Set() + seen.current.set(directory, created) + + // Evict oldest directories if over limit + while (seen.current.size > MAX_SEEN_DIRS) { + const first = seen.current.keys().next().value + if (!first) break + const staleSessionIds = [...(seen.current.get(first) ?? [])] + seen.current.delete(first) + evict(first, staleSessionIds) + } + + return created + }, [directory, evict]) + + // Touch a session — triggers both directory-level and session-level eviction + const touch = useCallback( + (sessionID: string) => { + const s = seenFor() + const stale = pickSessionCacheEvictions({ + seen: s, + keep: sessionID, + limit: SESSION_CACHE_LIMIT, + }) + evict(directory, stale) + }, + [directory, seenFor, evict], + ) + + // Optimistic operations + const getOptimistic = useCallback( + (sessionID: string): OptimisticItem[] => { + const key = `${directory}\n${sessionID}` + return [...(optimistic.current.get(key)?.values() ?? [])] + }, + [directory], + ) + + const setOptimistic = useCallback( + (sessionID: string, item: OptimisticItem) => { + const key = `${directory}\n${sessionID}` + const list = optimistic.current.get(key) + const sorted: OptimisticItem = { message: item.message, parts: sortParts(item.parts) } + if (list) { + list.set(item.message.id, sorted) + } else { + optimistic.current.set(key, new Map([[item.message.id, sorted]])) + } + }, + [directory], + ) + + const clearOptimistic = useCallback( + (sessionID: string, messageID?: string) => { + const key = `${directory}\n${sessionID}` + if (!messageID) { + optimistic.current.delete(key) + return + } + const list = optimistic.current.get(key) + if (!list) return + list.delete(messageID) + if (list.size === 0) optimistic.current.delete(key) + }, + [directory], + ) + + // Fetch messages from API + const fetchMessages = useCallback( + async (sessionID: string, limit: number, before?: string) => { + const result = await retry(() => + sdk.session.messages({ sessionID, limit, before }), + ) + const items = (result.data ?? []).filter((x: { info?: { id?: string } }) => !!x?.info?.id) + const session = items + .map((x: { info: Message }) => stripMessageDiffSnapshots(x.info)) + .sort((a: Message, b: Message) => cmp(a.id, b.id)) + const part = items.map((x: { info: { id: string }; parts: Part[] }) => ({ + id: x.info.id, + part: sortParts(x.parts), + })) + const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined + return { session, part, cursor, complete: !cursor } + }, + [sdk], + ) + + // Load messages for a session + const loadMessages = useCallback( + async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => { + const m = getMetaFor(sessionID) + if (m.loading) return + setMetaFor(sessionID, { loading: true }) + + try { + const limit = m.limit + const page = await fetchMessages(sessionID, limit, options?.before) + + // Merge optimistic items + const items = getOptimistic(sessionID) + const merged = mergeOptimisticPage(page, items) + for (const messageID of merged.confirmed) { + clearOptimistic(sessionID, messageID) + } + + const current = store.getState() + const cached = options?.mode === "prepend" ? (current.message[sessionID] ?? []) : [] + const messages = options?.mode === "prepend" + ? mergeMessages(cached, merged.session) + : merged.session + + // Build part updates — preserve existing references on prepend to avoid flicker + const isPrepend = options?.mode === "prepend" + let partsChanged = false + const partUpdate: Record = { ...current.part } + for (const p of merged.part) { + if (isPrepend && partUpdate[p.id]) continue // already loaded + const filtered = p.part.filter((x: Part) => !SKIP_PARTS.has(x.type)) + if (filtered.length) { + partUpdate[p.id] = filtered + partsChanged = true + } + } + + const patch: Record = { + message: messages !== cached ? { ...current.message, [sessionID]: messages } : current.message, + } + if (!isPrepend || partsChanged) { + patch.part = partUpdate + } + store.setState(patch) + setMetaFor(sessionID, { + limit: messages.length, + cursor: merged.cursor, + complete: merged.complete, + loading: false, + }) + setSessionPrefetch({ + directory, + sessionID, + limit: messages.length, + cursor: merged.cursor, + complete: merged.complete, + }) + } catch { + setMetaFor(sessionID, { loading: false }) + } + }, + [store, fetchMessages, getMetaFor, setMetaFor, getOptimistic, clearOptimistic, directory], + ) + + // Sync a session (load if not cached) + const syncSession = useCallback( + async (sessionID: string, force?: boolean) => { + touch(sessionID) + const key = keyFor(sessionID) + + // Dedup inflight requests + const existing = inflight.current.get(key) + if (existing) return existing + + const current = store.getState() + const m = getMetaFor(sessionID) + const cached = current.message[sessionID] !== undefined && m.limit > 0 + const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found + if (cached && hasSession && !force) return + + // Skip if recently fetched (TTL) + if (!force) { + const prefetchInfo = getSessionPrefetch(directory, sessionID) + if (shouldSkipSessionPrefetch({ + hasMessages: cached, + info: prefetchInfo, + pageSize: MESSAGE_PAGE_SIZE, + })) return + } + + const promise = (async () => { + // Fetch session info if needed + if (!hasSession || force) { + try { + const result = await retry(() => sdk.session.get({ sessionID })) + if (result.data) { + const s = store.getState() + const sessions = [...s.session] + const idx = Binary.search(sessions, sessionID, (s) => s.id) + if (idx.found) { + sessions[idx.index] = result.data + } else { + sessions.splice(idx.index, 0, result.data) + } + store.setState({ session: sessions }) + } + } catch (e) { + console.error("[sync] failed to fetch session", sessionID, e) + } + } + + // Load messages if needed + if (!cached || force) { + await loadMessages(sessionID) + } + })() + + inflight.current.set(key, promise) + promise.finally(() => inflight.current.delete(key)) + return promise + }, + [store, sdk, keyFor, touch, getMetaFor, loadMessages, directory], + ) + + // Load more (pagination) + const loadMore = useCallback( + async (sessionID: string) => { + touch(sessionID) + const m = getMetaFor(sessionID) + if (m.loading || m.complete || !m.cursor) return + await loadMessages(sessionID, { before: m.cursor, mode: "prepend" }) + }, + [touch, getMetaFor, loadMessages], + ) + + const hasMore = useCallback( + (sessionID: string) => { + const m = getMetaFor(sessionID) + return !m.complete && !!m.cursor + }, + [getMetaFor], + ) + + const isLoading = useCallback( + (sessionID: string) => getMetaFor(sessionID).loading, + [getMetaFor], + ) + + // Optimistic add (for prompt submission) + const optimisticAdd = useCallback( + (input: { sessionID: string; message: Message; parts: Part[] }) => { + setOptimistic(input.sessionID, { message: input.message, parts: input.parts }) + const current = store.getState() + const message = { ...current.message } + const part = { ...current.part } + + // Insert message + const messages = message[input.sessionID] ? [...message[input.sessionID]] : [] + const result = Binary.search(messages, input.message.id, (m) => m.id) + if (!result.found) messages.splice(result.index, 0, input.message) + message[input.sessionID] = messages + + // Insert parts + part[input.message.id] = sortParts(input.parts) + + store.setState({ message, part }) + }, + [store, setOptimistic], + ) + + // Optimistic remove (for rollback on error) + const optimisticRemove = useCallback( + (input: { sessionID: string; messageID: string }) => { + clearOptimistic(input.sessionID, input.messageID) + const current = store.getState() + const message = { ...current.message } + const part = { ...current.part } + + const messages = message[input.sessionID] + if (messages) { + const next = [...messages] + const result = Binary.search(next, input.messageID, (m) => m.id) + if (result.found) { + next.splice(result.index, 1) + message[input.sessionID] = next + } + } + delete part[input.messageID] + + store.setState({ message, part }) + }, + [store, clearOptimistic], + ) + + return useMemo( + () => ({ + syncSession, + loadMore, + hasMore, + isLoading, + optimistic: { + add: optimisticAdd, + remove: optimisticRemove, + }, + }), + [syncSession, loadMore, hasMore, isLoading, optimisticAdd, optimisticRemove], + ) +} diff --git a/packages/ui/src/sync/viewport-store.ts b/packages/ui/src/sync/viewport-store.ts new file mode 100644 index 00000000..2a7a03d9 --- /dev/null +++ b/packages/ui/src/sync/viewport-store.ts @@ -0,0 +1,49 @@ +/** + * Viewport Store — per-session scroll anchors, streaming state, memory. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" + +export type SessionMemoryState = { + viewportAnchor: number + isStreaming: boolean + streamStartTime?: number + lastAccessedAt: number + backgroundMessageCount: number + loadedTurnCount?: number + hasMoreAbove?: boolean + hasMoreTurnsAbove?: boolean + historyLoading?: boolean + historyComplete?: boolean + historyLimit?: number + totalAvailableMessages?: number + streamingCooldownUntil?: number + isZombie?: boolean + lastUserMessageAt?: number +} + +export type ViewportState = { + sessionMemoryState: Map + isSyncing: boolean + + updateViewportAnchor: (sessionId: string, anchor: number) => void +} + +export const useViewportStore = create()((set) => ({ + sessionMemoryState: new Map(), + isSyncing: false, + + updateViewportAnchor: (sessionId, anchor) => + set((s) => { + const map = new Map(s.sessionMemoryState) + const existing = map.get(sessionId) ?? { + viewportAnchor: 0, + isStreaming: false, + lastAccessedAt: Date.now(), + backgroundMessageCount: 0, + } + map.set(sessionId, { ...existing, viewportAnchor: anchor, lastAccessedAt: Date.now() }) + return { sessionMemoryState: map } + }), +})) diff --git a/packages/ui/src/sync/voice-store.ts b/packages/ui/src/sync/voice-store.ts new file mode 100644 index 00000000..494abfce --- /dev/null +++ b/packages/ui/src/sync/voice-store.ts @@ -0,0 +1,23 @@ +/** + * Voice Store — voice connection and activity state. + * Extracted from session-ui-store for subscription isolation. + */ + +import { create } from "zustand" + +export type VoiceStatus = "disconnected" | "connecting" | "connected" | "error" +export type VoiceMode = "idle" | "speaking" | "listening" + +export type VoiceState = { + voiceStatus: VoiceStatus + voiceMode: VoiceMode + setVoiceStatus: (status: VoiceStatus) => void + setVoiceMode: (mode: VoiceMode) => void +} + +export const useVoiceStore = create()((set) => ({ + voiceStatus: "disconnected", + voiceMode: "idle", + setVoiceStatus: (status) => set({ voiceStatus: status }), + setVoiceMode: (mode) => set({ voiceMode: mode }), +})) diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 702e3a32..062d93d5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -243,7 +243,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md new file mode 100644 index 00000000..9f8dd3dd --- /dev/null +++ b/packages/vscode/src/DOCUMENTATION.md @@ -0,0 +1,61 @@ +# VS Code Backend Modules + +This document describes backend runtime modules used by the VS Code extension bridge (`packages/vscode/src/bridge.ts`). + +## Purpose + +Keep `bridge.ts` as a thin orchestration layer that delegates message handling to cohesive domain runtimes while preserving API behavior. + +## Runtime modules + +- `bridge.ts` + - Entry orchestration layer for bridge messages. + - Delegates to specialized runtimes in order and handles only unmatched fallthrough cases. + +- `bridge-git-runtime.ts` + - Standard Git message handlers. + +- `bridge-git-special-runtime.ts` + - Specialized Git flows (`pr-description`, `conflict-details`) and generation helpers. + +- `bridge-git-process-runtime.ts` + - Git process execution and environment setup (`execGit`), including SSH agent socket resolution. + +- `bridge-fs-runtime.ts` + - Bridge handlers for filesystem-related message routes. + - Uses shared FS helpers via injected dependencies. + +- `bridge-fs-helpers-runtime.ts` + - Filesystem/path/search helper functions: + - path normalization and resolution + - directory listing + - file search + - file read path safety checks + - dropped-file parsing and attachment reading + - models metadata fetch helper + +- `bridge-localfs-proxy-runtime.ts` + - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. + +- `bridge-proxy-runtime.ts` + - Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies. + +- `bridge-config-runtime.ts` + - Config and skills message handlers (`api:config/*`). + - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). + +- `bridge-settings-runtime.ts` + - Settings read/write and OpenCode skills discovery via API for bridge consumers. + +- `bridge-system-runtime.ts` + - System/editor/provider/quota/notification/update-check message handlers. + - Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`). + - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). + +## Extension guideline + +When adding new bridge route families: + +1. Prefer creating or extending a domain runtime module under `packages/vscode/src/bridge-*-runtime.ts`. +2. Keep `bridge.ts` focused on delegation order and minimal fallthrough behavior. +3. Inject dependencies into runtimes instead of reaching into unrelated modules directly. diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts new file mode 100644 index 00000000..e28a9bea --- /dev/null +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -0,0 +1,595 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { + createAgent, + createCommand, + deleteAgent, + deleteCommand, + getAgentSources, + getCommandSources, + updateAgent, + updateCommand, + type AgentScope, + type CommandScope, + AGENT_SCOPE, + COMMAND_SCOPE, + discoverSkills, + getSkillSources, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + type SkillScope, + type DiscoveredSkill, + SKILL_SCOPE, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, +} from './opencodeConfig'; +import { + getSkillsCatalog, + scanSkillsRepository as scanSkillsRepositoryFromGit, + installSkillsFromRepository as installSkillsFromGit, + type SkillsCatalogSourceConfig, +} from './skillsCatalog'; +import type { BridgeContext, BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type ConfigRuntimeDeps = { + readSettings: (ctx?: BridgeContext) => Record; + persistSettings: (changes: Record, ctx?: BridgeContext) => Promise>; + fetchOpenCodeSkillsFromApi: (ctx: BridgeContext | undefined, workingDirectory?: string) => Promise; + clientReloadDelayMs: number; +}; + +const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: string): string | undefined => ( + (typeof directory === 'string' && directory.trim()) + ? directory.trim() + : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath) +); + +const parseSkillsCatalogSources = (settings: Record): SkillsCatalogSourceConfig[] => { + const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; + if (!Array.isArray(rawCatalogs)) { + return []; + } + + return rawCatalogs + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const candidate = entry as Record; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; + const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; + if (!id || !label || !source) return null; + const normalized: SkillsCatalogSourceConfig = { + id, + label, + description: source, + source, + ...(subpath ? { defaultSubpath: subpath } : {}), + }; + return normalized; + }) + .filter((value): value is SkillsCatalogSourceConfig => value !== null); +}; + +export async function handleConfigBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: ConfigRuntimeDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:config/opencode-resolution:get': { + const debugInfo = ctx?.manager?.getDebugInfo(); + const configuredFromWorkspace = vscode.workspace.getConfiguration('openchamber').get('opencodeBinary'); + const configured = typeof configuredFromWorkspace === 'string' && configuredFromWorkspace.trim().length > 0 + ? configuredFromWorkspace.trim() + : null; + const resolved = debugInfo?.cliPath ?? null; + const source = (() => { + if (!resolved) return null; + if (configured && configured === resolved) return 'settings'; + const envBinary = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; + if (envBinary && envBinary === resolved) return 'env'; + return 'path'; + })(); + + return { + id, + type, + success: true, + data: { + configured, + resolved, + resolvedDir: resolved ? path.dirname(resolved) : null, + source, + detectedNow: resolved, + detectedSourceNow: source, + shim: null, + viaWsl: false, + wslBinary: null, + wslPath: null, + wslDistro: null, + node: process.execPath || null, + bun: null, + }, + }; + } + + case 'api:config/settings:get': { + const settings = deps.readSettings(ctx); + return { id, type, success: true, data: settings }; + } + + case 'api:config/settings:save': { + const changes = (payload as Record) || {}; + const updated = await deps.persistSettings(changes, ctx); + return { id, type, success: true, data: updated }; + } + + case 'api:config/reload': { + await ctx?.manager?.restart(); + return { id, type, success: true, data: { restarted: true } }; + } + + case 'api:config/agents': { + const { method, name, body, directory } = (payload || {}) as { + method?: string; + name?: string; + body?: Record; + directory?: string; + }; + const agentName = typeof name === 'string' ? name.trim() : ''; + if (!agentName) { + return { id, type, success: false, error: 'Agent name is required' }; + } + + const workingDirectory = resolveWorkingDirectory(ctx, directory); + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const sources = getAgentSources(agentName, workingDirectory); + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + return { + id, + type, + success: true, + data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, + }; + } + + if (normalizedMethod === 'POST') { + const scopeValue = body?.scope as string | undefined; + const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined; + createAgent(agentName, (body || {}) as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} created successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateAgent(agentName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} updated successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteAgent(agentName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Agent ${agentName} deleted successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/commands': { + const { method, name, body, directory } = (payload || {}) as { + method?: string; + name?: string; + body?: Record; + directory?: string; + }; + const commandName = typeof name === 'string' ? name.trim() : ''; + if (!commandName) { + return { id, type, success: false, error: 'Command name is required' }; + } + + const workingDirectory = resolveWorkingDirectory(ctx, directory); + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const sources = getCommandSources(commandName, workingDirectory); + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + return { + id, + type, + success: true, + data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, + }; + } + + if (normalizedMethod === 'POST') { + const scopeValue = body?.scope as string | undefined; + const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined; + createCommand(commandName, (body || {}) as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} created successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateCommand(commandName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} updated successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteCommand(commandName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Command ${commandName} deleted successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/mcp': { + const { method, name, body, directory } = (payload || {}) as { + method?: string; + name?: string; + body?: Record; + directory?: string; + }; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + const mcpName = typeof name === 'string' ? name.trim() : ''; + const workingDirectory = resolveWorkingDirectory(ctx, directory); + + if (normalizedMethod === 'GET' && !mcpName) { + const configs = listMcpConfigs(workingDirectory); + return { id, type, success: true, data: configs }; + } + + if (!mcpName) { + return { id, type, success: false, error: 'MCP server name is required' }; + } + + if (normalizedMethod === 'GET') { + const config = getMcpConfig(mcpName, workingDirectory); + if (!config) { + return { id, type, success: false, error: `MCP server "${mcpName}" not found` }; + } + return { id, type, success: true, data: config }; + } + + if (normalizedMethod === 'POST') { + const scope = body?.scope as 'user' | 'project' | undefined; + createMcpConfig(mcpName, (body || {}) as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `MCP server "${mcpName}" created. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateMcpConfig(mcpName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `MCP server "${mcpName}" updated. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteMcpConfig(mcpName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `MCP server "${mcpName}" deleted. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/skills': { + const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (!name && normalizedMethod === 'GET') { + const skills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory); + return { id, type, success: true, data: { skills } }; + } + + const skillName = typeof name === 'string' ? name.trim() : ''; + if (!skillName) { + return { id, type, success: false, error: 'Skill name is required' }; + } + + if (normalizedMethod === 'GET') { + const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) + .find((skill) => skill.name === skillName); + const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); + return { + id, + type, + success: true, + data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source }, + }; + } + + if (normalizedMethod === 'POST') { + const scopeValue = body?.scope as string | undefined; + const sourceValue = body?.source as string | undefined; + const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined; + const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode'; + createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record, workingDirectory, scope); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Skill ${skillName} created successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'PATCH') { + updateSkill(skillName, (body || {}) as Record, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Skill ${skillName} updated successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + if (normalizedMethod === 'DELETE') { + deleteSkill(skillName, workingDirectory); + await ctx?.manager?.restart(); + return { + id, + type, + success: true, + data: { + success: true, + requiresReload: true, + message: `Skill ${skillName} deleted successfully. Reloading interface…`, + reloadDelayMs: deps.clientReloadDelayMs, + }, + }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:config/skills:catalog': { + const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh); + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const settings = deps.readSettings(ctx); + const additionalSources = parseSkillsCatalogSources(settings); + const installedSkills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined; + const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills); + return { id, type, success: true, data }; + } + + case 'api:config/skills:scan': { + const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string }; + const data = await scanSkillsRepositoryFromGit({ + source: String(body.source || ''), + subpath: body.subpath, + }); + return { id, type, success: true, data }; + } + + case 'api:config/skills:install': { + const body = (payload || {}) as { + source?: string; + subpath?: string; + scope?: 'user' | 'project'; + targetSource?: 'opencode' | 'agents'; + selections?: Array<{ skillDir: string }>; + conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; + conflictDecisions?: Record; + }; + + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + const data = await installSkillsFromGit({ + source: String(body.source || ''), + subpath: body.subpath, + scope: body.scope === 'project' ? 'project' : 'user', + targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode', + workingDirectory: body.scope === 'project' ? workingDirectory : undefined, + selections: Array.isArray(body.selections) ? body.selections : [], + conflictPolicy: body.conflictPolicy, + conflictDecisions: body.conflictDecisions, + }); + + if (data.ok) { + const installed = data.installed || []; + const skipped = data.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await ctx?.manager?.restart(); + } + + return { + id, + type, + success: true, + data: { + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined, + }, + }; + } + + return { id, type, success: true, data }; + } + + case 'api:config/skills/files': { + const { method, name, filePath, content } = (payload || {}) as { + method?: string; + name?: string; + filePath?: string; + content?: string; + }; + const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + const skillName = typeof name === 'string' ? name.trim() : ''; + if (!skillName) { + return { id, type, success: false, error: 'Skill name is required' }; + } + + const relativePath = typeof filePath === 'string' ? filePath.trim() : ''; + if (!relativePath) { + return { id, type, success: false, error: 'File path is required' }; + } + + const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) + .find((skill) => skill.name === skillName); + const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); + if (!sources.md.dir) { + return { id, type, success: false, error: `Skill "${skillName}" not found` }; + } + + const skillDir = sources.md.dir; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const fileContent = readSkillSupportingFile(skillDir, relativePath); + if (fileContent === null) { + return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` }; + } + return { id, type, success: true, data: { content: fileContent } }; + } + + if (normalizedMethod === 'PUT') { + writeSkillSupportingFile(skillDir, relativePath, content || ''); + return { id, type, success: true, data: { success: true } }; + } + + if (normalizedMethod === 'DELETE') { + deleteSkillSupportingFile(skillDir, relativePath); + return { id, type, success: true, data: { success: true } }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-fs-helpers-runtime.ts b/packages/vscode/src/bridge-fs-helpers-runtime.ts new file mode 100644 index 00000000..847c0b3d --- /dev/null +++ b/packages/vscode/src/bridge-fs-helpers-runtime.ts @@ -0,0 +1,563 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { execGit } from './bridge-git-process-runtime'; + +const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; + +const guessMimeTypeFromExtension = (ext: string) => { + switch (ext) { + case '.png': + case '.jpg': + case '.jpeg': + case '.gif': + case '.bmp': + case '.webp': + return `image/${ext.replace('.', '')}`; + case '.pdf': + return 'application/pdf'; + case '.txt': + case '.log': + return 'text/plain'; + case '.json': + return 'application/json'; + case '.md': + case '.markdown': + return 'text/markdown'; + default: + return 'application/octet-stream'; + } +}; + +const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value); + +export const parseDroppedFileReference = (rawReference: string): + | { uri: vscode.Uri } + | { skipped: { name: string; reason: string } } => { + const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, ''); + if (!trimmed) { + return { skipped: { name: rawReference, reason: 'Empty drop reference' } }; + } + + if (hasUriScheme(trimmed)) { + try { + const parsed = vscode.Uri.parse(trimmed, true); + if (parsed.scheme !== 'file') { + return { + skipped: { + name: trimmed, + reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`, + }, + }; + } + return { uri: parsed }; + } catch (error) { + return { + skipped: { + name: trimmed, + reason: error instanceof Error ? error.message : 'Invalid URI', + }, + }; + } + } + + if (!path.isAbsolute(trimmed)) { + return { + skipped: { + name: trimmed, + reason: 'Drop reference is not an absolute file path', + }, + }; + } + + return { uri: vscode.Uri.file(trimmed) }; +}; + +export const readUriAsAttachment = async ( + uri: vscode.Uri, + fallbackName?: string, +): Promise< + | { file: { name: string; mimeType: string; size: number; dataUrl: string } } + | { skipped: { name: string; reason: string } } +> => { + const name = path.basename(uri.fsPath || uri.path || fallbackName || 'file'); + + try { + const stat = await vscode.workspace.fs.stat(uri); + if ((stat.type & vscode.FileType.Directory) !== 0) { + return { skipped: { name, reason: 'Folders are not supported' } }; + } + + const size = stat.size ?? 0; + if (size > MAX_FILE_ATTACH_SIZE_BYTES) { + return { skipped: { name, reason: 'File exceeds 10MB limit' } }; + } + + const bytes = await vscode.workspace.fs.readFile(uri); + const ext = path.extname(name).toLowerCase(); + const mimeType = guessMimeTypeFromExtension(ext); + const base64 = Buffer.from(bytes).toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64}`; + + return { file: { name, mimeType, size, dataUrl } }; + } catch (error) { + return { skipped: { name, reason: error instanceof Error ? error.message : 'Failed to read file' } }; + } +}; + +const isPathInside = (candidatePath: string, parentPath: string): boolean => { + const normalizedCandidate = path.resolve(candidatePath); + const normalizedParent = path.resolve(parentPath); + return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); +}; + +export const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); + +const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise> => { + if (names.length === 0) { + return new Set(); + } + + const result = await execGit(['check-ignore', '--', ...names], cwd); + if (result.exitCode !== 0 || !result.stdout) { + return new Set(); + } + + return new Set( + result.stdout + .split('\n') + .map((name: string) => name.trim()) + .filter(Boolean), + ); +}; + +const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise> => { + if (paths.length === 0) { + return new Set(); + } + + const result = await execGit(['check-ignore', '--', ...paths], cwd); + if (result.exitCode !== 0 || !result.stdout) { + return new Set(); + } + + return new Set( + result.stdout + .split('\n') + .map((name: string) => name.trim()) + .filter(Boolean), + ); +}; + +const expandTildePath = (value: string) => { + const trimmed = (value || '').trim(); + if (!trimmed) { + return trimmed; + } + + if (trimmed === '~') { + return os.homedir(); + } + + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(os.homedir(), trimmed.slice(2)); + } + + return trimmed; +}; + +export const resolveUserPath = (value: string, baseDirectory: string) => { + const expanded = expandTildePath(value); + if (!expanded) { + return expanded; + } + if (path.isAbsolute(expanded)) { + return expanded; + } + return path.resolve(baseDirectory, expanded); +}; + +export const listDirectoryEntries = async (dirPath: string) => { + const uri = vscode.Uri.file(dirPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + return entries.map(([name, fileType]) => ({ + name, + path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath), + isDirectory: fileType === vscode.FileType.Directory, + })); +}; + +const FILE_SEARCH_EXCLUDED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', + 'coverage', + 'tmp', + 'logs', +]); + +const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => { + if (!name) { + return false; + } + if (!includeHidden && name.startsWith('.')) { + return true; + } + return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); +}; + +const fuzzyMatchScore = (query: string, candidate: string): number | null => { + if (!query) return 0; + + const q = query.toLowerCase(); + const c = candidate.toLowerCase(); + + if (c.includes(q)) { + const idx = c.indexOf(q); + let bonus = 0; + if (idx === 0) { + bonus = 20; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + bonus = 15; + } + } + return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); + } + + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i++) { + const ch = q[i]; + if (!ch || ch === ' ') continue; + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive++; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); + score -= Math.min(gap, 10); + + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; + lastIndex = idx; + } + + score += Math.max(0, 24 - Math.floor(c.length / 3)); + + return score; +}; + +const searchFilesystemFiles = async ( + rootPath: string, + query: string, + limit: number, + includeHidden: boolean, + respectGitignore: boolean, + timeBudgetMs?: number, +) => { + const normalizedQuery = (query || '').trim().toLowerCase(); + const matchAll = normalizedQuery.length === 0; + const deadline = typeof timeBudgetMs === 'number' && timeBudgetMs > 0 ? Date.now() + timeBudgetMs : null; + + const rootUri = vscode.Uri.file(rootPath); + const queue: vscode.Uri[] = [rootUri]; + const visited = new Set([normalizeFsPath(rootUri.fsPath)]); + const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); + const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = []; + const MAX_CONCURRENCY = 10; + + while (queue.length > 0 && candidates.length < collectLimit) { + if (deadline && Date.now() > deadline) { + break; + } + const batch = queue.splice(0, MAX_CONCURRENCY); + const dirLists = await Promise.all( + batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])), + ); + + for (let index = 0; index < batch.length; index += 1) { + if (deadline && Date.now() > deadline) { + break; + } + const currentDir = batch[index]; + const dirents = dirLists[index]; + + const ignoredNames = respectGitignore + ? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name)) + : new Set(); + + for (const [entryName, entryType] of dirents) { + if (!entryName || (!includeHidden && entryName.startsWith('.'))) { + continue; + } + + if (respectGitignore && ignoredNames.has(entryName)) { + continue; + } + + const entryUri = vscode.Uri.joinPath(currentDir, entryName); + const absolute = normalizeFsPath(entryUri.fsPath); + + if (entryType === vscode.FileType.Directory) { + if (shouldSkipSearchDirectory(entryName, includeHidden)) { + continue; + } + if (!visited.has(absolute)) { + visited.add(absolute); + queue.push(entryUri); + } + continue; + } + + if (entryType !== vscode.FileType.File) { + continue; + } + + const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute)); + const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; + + if (matchAll) { + candidates.push({ + name: entryName, + path: absolute, + relativePath, + extension, + score: 0, + }); + } else { + const score = fuzzyMatchScore(normalizedQuery, relativePath); + if (score !== null) { + candidates.push({ + name: entryName, + path: absolute, + relativePath, + extension, + score, + }); + } + } + + if (candidates.length >= collectLimit) { + queue.length = 0; + break; + } + } + + if (candidates.length >= collectLimit) { + break; + } + } + } + + if (!matchAll) { + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.relativePath.length !== b.relativePath.length) { + return a.relativePath.length - b.relativePath.length; + } + return a.relativePath.localeCompare(b.relativePath); + }); + } + + return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ + name, + path: filePath, + relativePath, + extension, + })); +}; + +export const searchDirectory = async ( + directory: string, + query: string, + limit = 60, + includeHidden = false, + respectGitignore = true, +) => { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const rootPath = directory + ? resolveUserPath(directory, workspaceRoot) + : vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + if (!rootPath) return []; + + const sanitizedQuery = query?.trim() || ''; + if (!sanitizedQuery) { + return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore); + } + + const escapeGlob = (value: string) => value + .replace(/[\\{}()?*]/g, '\\$&') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]'); + const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; + const mapResults = (results: vscode.Uri[]) => results.map((file) => { + const absolute = normalizeFsPath(file.fsPath); + const relative = normalizeFsPath(path.relative(rootPath, absolute)); + const name = path.basename(absolute); + return { + name, + path: absolute, + relativePath: relative || name, + extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, + }; + }); + const filterGitIgnored = async (results: vscode.Uri[]) => { + if (!respectGitignore || results.length === 0) { + return results; + } + + const relativePaths = results.map((file) => { + const relative = normalizeFsPath(path.relative(rootPath, file.fsPath)); + return relative || path.basename(file.fsPath); + }); + + const ignored = await gitCheckIgnorePaths(rootPath, relativePaths); + if (ignored.size === 0) { + return results; + } + + return results.filter((_, index) => !ignored.has(relativePaths[index])); + }; + + try { + const escapedQuery = escapeGlob(sanitizedQuery); + const pattern = `**/*${escapedQuery}*`; + const results = await vscode.workspace.findFiles( + new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), + exclude, + limit, + ); + + if (Array.isArray(results) && results.length > 0) { + const visible = includeHidden ? results : results.filter((file) => !path.basename(file.fsPath).startsWith('.')); + const filtered = await filterGitIgnored(visible); + if (filtered.length > 0) { + return mapResults(filtered); + } + } + + if (sanitizedQuery.length >= 2 && sanitizedQuery.length <= 32) { + const fuzzyPattern = `**/*${escapedQuery.split('').join('*')}*`; + const fuzzyResults = await vscode.workspace.findFiles( + new vscode.RelativePattern(vscode.Uri.file(rootPath), fuzzyPattern), + exclude, + limit, + ); + + if (Array.isArray(fuzzyResults) && fuzzyResults.length > 0) { + const visible = includeHidden ? fuzzyResults : fuzzyResults.filter((file) => !path.basename(file.fsPath).startsWith('.')); + const filtered = await filterGitIgnored(visible); + if (filtered.length > 0) { + return mapResults(filtered); + } + } + } + } catch { + // Fall through to filesystem traversal. + } + + return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore, 1500); +}; + +export const fetchModelsMetadata = async () => { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined; + try { + const response = await fetch('https://models.dev/api.json', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`models.dev responded with ${response.status}`); + } + return await response.json(); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; + +const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + +export const getFsMimeType = (filePath: string): string => { + const ext = path.extname(filePath).toLowerCase(); + const mimeMap: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.markdown': 'text/markdown; charset=utf-8', + '.mmd': 'text/plain; charset=utf-8', + '.mermaid': 'text/plain; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.pdf': 'application/pdf', + }; + return mimeMap[ext] || 'application/octet-stream'; +}; + +export type FsReadPathResolution = + | { ok: true; resolvedPath: string } + | { ok: false; status: number; error: string }; + +export const resolveFileReadPath = async (targetPath: string): Promise => { + const trimmed = targetPath.trim(); + if (!trimmed) { + return { ok: false, status: 400, error: 'Path is required' }; + } + + const baseRoot = getFsAccessRoot(); + const resolved = resolveUserPath(trimmed, baseRoot); + if (!resolved) { + return { ok: false, status: 400, error: 'Path is required' }; + } + + try { + const [canonicalPath, canonicalBase] = await Promise.all([ + fs.promises.realpath(resolved), + fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)), + ]); + + if (!isPathInside(canonicalPath, canonicalBase)) { + return { ok: false, status: 403, error: 'Access to file denied' }; + } + + return { ok: true, resolvedPath: canonicalPath }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') { + return { ok: false, status: 404, error: 'File not found' }; + } + return { ok: false, status: 500, error: 'Failed to resolve file path' }; + } +}; diff --git a/packages/vscode/src/bridge-fs-runtime.ts b/packages/vscode/src/bridge-fs-runtime.ts new file mode 100644 index 00000000..fece387c --- /dev/null +++ b/packages/vscode/src/bridge-fs-runtime.ts @@ -0,0 +1,419 @@ +import * as vscode from 'vscode'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import type { BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type FsAttachment = { + name: string; + mimeType: string; + size: number; + dataUrl: string; +}; + +type SkippedAttachment = { + name: string; + reason: string; +}; + +type DroppedReferenceParse = + | { uri: vscode.Uri } + | { skipped: SkippedAttachment }; + +type ReadUriAsAttachmentResult = + | { file: FsAttachment } + | { skipped: SkippedAttachment }; + +type DirectoryEntry = { + name: string; + path: string; + isDirectory: boolean; +}; + +type FsDeps = { + resolveUserPath: (value: string, baseDirectory: string) => string; + listDirectoryEntries: (directoryPath: string) => Promise; + normalizeFsPath: (value: string) => string; + execGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>; + searchDirectory: ( + directory: string, + query: string, + limit: number | undefined, + includeHidden: boolean, + respectGitignore: boolean, + ) => Promise>; + resolveFileReadPath: (inputPath: string) => Promise< + | { ok: true; resolvedPath: string } + | { ok: false; status: number; error: string } + >; + parseDroppedFileReference: (rawReference: string) => DroppedReferenceParse; + readUriAsAttachment: (uri: vscode.Uri, name: string) => Promise; +}; + +export async function handleFsBridgeMessage( + message: BridgeMessageInput, + deps: FsDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'files:list': { + const { path: dirPath } = payload as { path: string }; + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(dirPath, workspaceRoot); + const uri = vscode.Uri.file(resolvedPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + const result = entries.map(([name, fileType]) => ({ + name, + path: vscode.Uri.joinPath(uri, name).fsPath, + isDirectory: fileType === vscode.FileType.Directory, + })); + return { id, type, success: true, data: { directory: deps.normalizeFsPath(resolvedPath), entries: result } }; + } + + case 'files:search': { + const { query, maxResults = 50 } = payload as { query: string; maxResults?: number }; + const pattern = `**/*${query}*`; + const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults); + const results = files.map((file) => ({ + path: file.fsPath, + })); + return { id, type, success: true, data: results }; + } + + case 'workspace:folder': { + const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + return { id, type, success: true, data: { folder } }; + } + + case 'config:get': { + const { key } = payload as { key: string }; + const config = vscode.workspace.getConfiguration('openchamber'); + const value = config.get(key); + return { id, type, success: true, data: { value } }; + } + + case 'api:fs:list': { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean }; + const target = targetPath || workspaceRoot; + const resolvedPath = deps.resolveUserPath(target, workspaceRoot) || workspaceRoot; + + const entries = await deps.listDirectoryEntries(resolvedPath); + const normalized = deps.normalizeFsPath(resolvedPath); + + if (!respectGitignore) { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + + const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean); + if (pathsToCheck.length === 0) { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + + try { + const result = await deps.execGit(['check-ignore', '--', ...pathsToCheck], normalized); + const ignoredNames = new Set( + result.stdout + .split('\n') + .map((name) => name.trim()) + .filter(Boolean) + ); + + const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name)); + return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } }; + } catch { + return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; + } + } + + case 'api:fs:search': { + const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as { + directory?: string; + query?: string; + limit?: number; + includeHidden?: boolean; + respectGitignore?: boolean; + }; + const files = await deps.searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false); + return { id, type, success: true, data: { files } }; + } + + case 'api:fs:mkdir': { + const target = (payload as { path: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(target, workspaceRoot); + await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath)); + return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } }; + } + + case 'api:fs/home': { + return { id, type, success: true, data: { home: deps.normalizeFsPath(os.homedir()) } }; + } + + case 'api:fs:read': { + const target = (payload as { path: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + + const resolution = await deps.resolveFileReadPath(target); + if (!resolution.ok) { + return { id, type, success: false, error: resolution.error }; + } + + try { + const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); + return { id, type, success: true, data: { content, path: deps.normalizeFsPath(resolution.resolvedPath) } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to read file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:write': { + const { path: targetPath, content } = (payload as { path: string; content: string }) || {}; + if (!targetPath) { + return { id, type, success: false, error: 'Path is required' }; + } + if (typeof content !== 'string') { + return { id, type, success: false, error: 'Content is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot); + const uri = vscode.Uri.file(resolvedPath); + const parentUri = vscode.Uri.file(path.dirname(resolvedPath)); + try { + await vscode.workspace.fs.createDirectory(parentUri); + } catch { + // Directory may already exist + } + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); + return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedPath) } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to write file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:delete': { + const targetPath = (payload as { path: string })?.path; + if (!targetPath) { + return { id, type, success: false, error: 'Path is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(targetPath, workspaceRoot); + const uri = vscode.Uri.file(resolvedPath); + await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false }); + return { id, type, success: true, data: { success: true } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:rename': { + const { oldPath, newPath } = (payload as { oldPath: string; newPath: string }) || {}; + if (!oldPath) { + return { id, type, success: false, error: 'oldPath is required' }; + } + if (!newPath) { + return { id, type, success: false, error: 'newPath is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedOld = deps.resolveUserPath(oldPath, workspaceRoot); + const resolvedNew = deps.resolveUserPath(newPath, workspaceRoot); + const oldUri = vscode.Uri.file(resolvedOld); + const newUri = vscode.Uri.file(resolvedNew); + await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: false }); + return { id, type, success: true, data: { success: true, path: deps.normalizeFsPath(resolvedNew) } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to rename file'; + return { id, type, success: false, error: message }; + } + } + + case 'api:fs:exec': { + const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {}; + if (!Array.isArray(commands) || commands.length === 0) { + return { id, type, success: false, error: 'Commands array is required' }; + } + if (!cwd) { + return { id, type, success: false, error: 'Working directory (cwd) is required' }; + } + try { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedCwd = deps.resolveUserPath(cwd, workspaceRoot); + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); + const shellFlag = process.platform === 'win32' ? '/c' : '-c'; + + const augmentedEnv = { + ...process.env, + PATH: process.env.PATH, + }; + + const results: Array<{ + command: string; + success: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; + }> = []; + + for (const cmd of commands) { + if (typeof cmd !== 'string' || !cmd.trim()) { + results.push({ command: cmd, success: false, error: 'Invalid command' }); + continue; + } + try { + const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, { + cwd: resolvedCwd, + env: augmentedEnv, + timeout: 300000, + }); + results.push({ + command: cmd, + success: true, + exitCode: 0, + stdout: (stdout || '').trim(), + stderr: (stderr || '').trim(), + }); + } catch (execError) { + const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string }; + results.push({ + command: cmd, + success: false, + exitCode: typeof err.code === 'number' ? err.code : 1, + stdout: (err.stdout || '').trim(), + stderr: (err.stderr || '').trim(), + error: err.message, + }); + } + } + + const allSucceeded = results.every((r) => r.success); + return { id, type, success: true, data: { success: allSucceeded, results } }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to execute commands'; + return { id, type, success: false, error: message }; + } + } + + case 'api:files/pick': { + const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; + const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; + + const picks = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: allowMany, + defaultUri, + openLabel: 'Attach', + }); + + if (!picks || picks.length === 0) { + return { id, type, success: true, data: { files: [], skipped: [] } }; + } + + const files: FsAttachment[] = []; + const skipped: SkippedAttachment[] = []; + for (const uri of picks) { + const result = await deps.readUriAsAttachment(uri, path.basename(uri.fsPath || uri.path || uri.toString())); + if ('file' in result) { + files.push(result.file); + } else { + skipped.push(result.skipped); + } + } + + return { id, type, success: true, data: { files, skipped } }; + } + + case 'api:files/drop': { + const references = (payload as { uris?: string[] })?.uris; + const sourceUris = Array.isArray(references) ? references.filter((entry) => typeof entry === 'string') : []; + if (sourceUris.length === 0) { + return { id, type, success: true, data: { files: [], skipped: [] } }; + } + + const files: FsAttachment[] = []; + const skipped: SkippedAttachment[] = []; + const dedupedUris = Array.from(new Set(sourceUris.map((entry) => entry.trim()).filter(Boolean))); + + for (const rawUri of dedupedUris) { + const parsed = deps.parseDroppedFileReference(rawUri); + if ('skipped' in parsed) { + skipped.push(parsed.skipped); + continue; + } + + const uri = parsed.uri; + const name = path.basename(uri.fsPath || uri.path || rawUri); + + const result = await deps.readUriAsAttachment(uri, name); + if ('file' in result) { + files.push(result.file); + } else { + skipped.push(result.skipped); + } + } + + return { id, type, success: true, data: { files, skipped } }; + } + + case 'api:files/save-image': { + const rawFileName = (payload as { fileName?: unknown })?.fileName; + const rawDataUrl = (payload as { dataUrl?: unknown })?.dataUrl; + const dataUrl = typeof rawDataUrl === 'string' ? rawDataUrl.trim() : ''; + if (!dataUrl.startsWith('data:image/')) { + return { id, type, success: false, error: 'Invalid image payload' }; + } + + const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0 + ? rawFileName.trim() + : `message-${Date.now()}.png`; + + const saveUri = await vscode.window.showSaveDialog({ + saveLabel: 'Save image', + defaultUri: vscode.workspace.workspaceFolders?.[0] + ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName) + : undefined, + filters: { Images: ['png'] }, + }); + + if (!saveUri) { + return { id, type, success: true, data: { saved: false, canceled: true } }; + } + + const commaIndex = dataUrl.indexOf(','); + if (commaIndex === -1) { + return { id, type, success: false, error: 'Invalid image data URL' }; + } + + const base64 = dataUrl.slice(commaIndex + 1); + const bytes = Buffer.from(base64, 'base64'); + await vscode.workspace.fs.writeFile(saveUri, bytes); + + return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-git-process-runtime.ts b/packages/vscode/src/bridge-git-process-runtime.ts new file mode 100644 index 00000000..81d30522 --- /dev/null +++ b/packages/vscode/src/bridge-git-process-runtime.ts @@ -0,0 +1,105 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; + +const isSocketPath = async (candidate: string): Promise => { + if (!candidate) { + return false; + } + try { + const stat = await fs.promises.stat(candidate); + return typeof stat.isSocket === 'function' && stat.isSocket(); + } catch { + return false; + } +}; + +const resolveSshAuthSock = async (): Promise => { + const existing = (process.env.SSH_AUTH_SOCK || '').trim(); + if (existing) { + return existing; + } + + if (process.platform === 'win32') { + return undefined; + } + + const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh'); + if (await isSocketPath(gpgSock)) { + return gpgSock; + } + + const runGpgconf = async (args: string[]): Promise => { + for (const candidate of gpgconfCandidates) { + try { + const { stdout } = await execFileAsync(candidate, args); + return String(stdout || ''); + } catch { + continue; + } + } + return ''; + }; + + const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); + if (candidate && await isSocketPath(candidate)) { + return candidate; + } + + if (candidate) { + await runGpgconf(['--launch', 'gpg-agent']); + const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); + if (retried && await isSocketPath(retried)) { + return retried; + } + } + + return undefined; +}; + +const buildGitEnv = async (): Promise => { + const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; + if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) { + const resolved = await resolveSshAuthSock(); + if (resolved) { + env.SSH_AUTH_SOCK = resolved; + } + } + return env; +}; + +export const execGit = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => { + const env = await buildGitEnv(); + return new Promise((resolve) => { + const proc = spawn('git', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env, + windowsHide: true, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + resolve({ stdout, stderr, exitCode: code ?? 0 }); + }); + + proc.on('error', (error) => { + resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }); + }); + }); +}; diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts new file mode 100644 index 00000000..11c9322b --- /dev/null +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -0,0 +1,422 @@ +import * as gitService from './gitService'; +import type { BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +const requireDirectory = (id: string, type: string, directory?: string): BridgeResponse | null => { + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + return null; +}; + +export async function handleStandardGitBridgeMessage(message: BridgeMessageInput): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:git/check': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const isRepo = await gitService.checkIsGitRepository(directory!); + return { id, type, success: true, data: isRepo }; + } + + case 'api:git/worktree-type': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const isLinked = await gitService.isLinkedWorktree(directory!); + return { id, type, success: true, data: isLinked }; + } + + case 'api:git/status': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const status = await gitService.getGitStatus(directory!); + return { id, type, success: true, data: status }; + } + + case 'api:git/branches': { + const { directory, method, name, startPoint, force } = (payload || {}) as { + directory?: string; + method?: string; + name?: string; + startPoint?: string; + force?: boolean; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const branches = await gitService.getGitBranches(directory!); + return { id, type, success: true, data: branches }; + } + + if (normalizedMethod === 'POST') { + if (!name) { + return { id, type, success: false, error: 'Branch name is required' }; + } + const result = await gitService.createBranch(directory!, name, startPoint); + return { id, type, success: true, data: result }; + } + + if (normalizedMethod === 'DELETE') { + if (!name) { + return { id, type, success: false, error: 'Branch name is required' }; + } + const result = await gitService.deleteGitBranch(directory!, name, force); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/remote-branches': { + const { directory, branch, remote } = (payload || {}) as { + directory?: string; + branch?: string; + remote?: string; + }; + if (!directory || !branch) { + return { id, type, success: false, error: 'Directory and branch are required' }; + } + const result = await gitService.deleteRemoteBranch(directory, branch, remote); + return { id, type, success: true, data: result }; + } + + case 'api:git/checkout': { + const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; + if (!directory || !branch) { + return { id, type, success: false, error: 'Directory and branch are required' }; + } + const result = await gitService.checkoutBranch(directory, branch); + return { id, type, success: true, data: result }; + } + + case 'api:git/worktrees': { + const { directory, method } = (payload || {}) as { + directory?: string; + method?: string; + body?: unknown; + directoryPath?: string; + deleteLocalBranch?: boolean; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const worktrees = await gitService.listGitWorktrees(directory!); + return { id, type, success: true, data: worktrees }; + } + + if (normalizedMethod === 'POST') { + const created = await gitService.createWorktree(directory!, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: created }; + } + + if (normalizedMethod === 'DELETE') { + const removePayload = payload as { + body?: { directory?: string; deleteLocalBranch?: boolean }; + directory?: string; + deleteLocalBranch?: boolean; + }; + const bodyDirectory = typeof removePayload?.body?.directory === 'string' + ? removePayload.body.directory + : ''; + const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : ''; + const worktreeDirectory = bodyDirectory || legacyDirectory || ''; + + if (!worktreeDirectory) { + return { id, type, success: false, error: 'Worktree directory is required' }; + } + const removed = await gitService.removeWorktree(directory!, { + directory: worktreeDirectory, + deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true, + }); + return { id, type, success: true, data: { success: Boolean(removed) } }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/worktrees/validate': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.validateWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: result }; + } + + case 'api:git/worktrees/bootstrap-status': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.getWorktreeBootstrapStatus(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/worktrees/preview': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.previewWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload); + return { id, type, success: true, data: result }; + } + + case 'api:git/diff': { + const { directory, path: filePath, staged, contextLines } = (payload || {}) as { + directory?: string; + path?: string; + staged?: boolean; + contextLines?: number; + }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + const result = await gitService.getGitDiff(directory, filePath, staged, contextLines); + return { id, type, success: true, data: result }; + } + + case 'api:git/file-diff': { + const { directory, path: filePath, staged } = (payload || {}) as { + directory?: string; + path?: string; + staged?: boolean; + }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + const result = await gitService.getGitFileDiff(directory, filePath, staged); + return { id, type, success: true, data: result }; + } + + case 'api:git/revert': { + const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string }; + if (!directory || !filePath) { + return { id, type, success: false, error: 'Directory and path are required' }; + } + await gitService.revertGitFile(directory, filePath); + return { id, type, success: true, data: { success: true } }; + } + + case 'api:git/commit': { + const { directory, message, addAll, files } = (payload || {}) as { + directory?: string; + message?: string; + addAll?: boolean; + files?: string[]; + }; + if (!directory || !message) { + return { id, type, success: false, error: 'Directory and message are required' }; + } + const result = await gitService.createGitCommit(directory, message, { addAll, files }); + return { id, type, success: true, data: result }; + } + + case 'api:git/push': { + const { directory, remote, branch, options } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + options?: string[] | Record; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.gitPush(directory!, { remote, branch, options }); + return { id, type, success: true, data: result }; + } + + case 'api:git/pull': { + const { directory, remote, branch } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.gitPull(directory!, { remote, branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/fetch': { + const { directory, remote, branch } = (payload || {}) as { + directory?: string; + remote?: string; + branch?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.gitFetch(directory!, { remote, branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/remotes': { + const { directory, method, remote } = (payload || {}) as { + directory?: string; + method?: string; + remote?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + if (normalizedMethod === 'GET') { + const result = await gitService.getRemotes(directory!); + return { id, type, success: true, data: result }; + } + + if (normalizedMethod === 'DELETE') { + if (!remote) { + return { id, type, success: false, error: 'Remote name is required' }; + } + const result = await gitService.removeRemote(directory!, remote); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/rebase': { + const { directory, onto } = (payload || {}) as { directory?: string; onto?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!onto) { + return { id, type, success: false, error: 'onto is required' }; + } + const result = await gitService.rebase(directory!, { onto }); + return { id, type, success: true, data: result }; + } + + case 'api:git/rebase/abort': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.abortRebase(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge': { + const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!branch) { + return { id, type, success: false, error: 'branch is required' }; + } + const result = await gitService.merge(directory!, { branch }); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge/abort': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.abortMerge(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/rebase/continue': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.continueRebase(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/merge/continue': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.continueMerge(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/stash': { + const { directory, message, includeUntracked } = (payload || {}) as { + directory?: string; + message?: string; + includeUntracked?: boolean; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.stash(directory!, { message, includeUntracked }); + return { id, type, success: true, data: result }; + } + + case 'api:git/stash/pop': { + const { directory } = (payload || {}) as { directory?: string }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.stashPop(directory!); + return { id, type, success: true, data: result }; + } + + case 'api:git/log': { + const { directory, maxCount, from, to, file } = (payload || {}) as { + directory?: string; + maxCount?: number; + from?: string; + to?: string; + file?: string; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + const result = await gitService.getGitLog(directory!, { maxCount, from, to, file }); + return { id, type, success: true, data: result }; + } + + case 'api:git/commit-files': { + const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; + if (!directory || !hash) { + return { id, type, success: false, error: 'Directory and hash are required' }; + } + const result = await gitService.getCommitFiles(directory, hash); + return { id, type, success: true, data: result }; + } + + case 'api:git/identity': { + const { directory, method, userName, userEmail, sshKey } = (payload || {}) as { + directory?: string; + method?: string; + userName?: string; + userEmail?: string; + sshKey?: string | null; + }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + + const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; + + if (normalizedMethod === 'GET') { + const identity = await gitService.getCurrentGitIdentity(directory!); + return { id, type, success: true, data: identity }; + } + + if (normalizedMethod === 'POST') { + if (!userName || !userEmail) { + return { id, type, success: false, error: 'userName and userEmail are required' }; + } + const result = await gitService.setGitIdentity(directory!, userName, userEmail, sshKey); + return { id, type, success: true, data: result }; + } + + return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + } + + case 'api:git/ignore-openchamber': { + return { id, type, success: true, data: { success: true } }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-git-special-runtime.ts b/packages/vscode/src/bridge-git-special-runtime.ts new file mode 100644 index 00000000..6fc60e51 --- /dev/null +++ b/packages/vscode/src/bridge-git-special-runtime.ts @@ -0,0 +1,455 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as gitService from './gitService'; +import type { BridgeContext, BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type ExecGitResult = { stdout: string; stderr: string; exitCode: number }; + +type SpecialGitDeps = { + readSettings: (ctx?: BridgeContext) => Record; + execGit: (args: string[], cwd: string) => Promise; +}; + +const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano'; +const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; +const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500; +const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000; + +let bridgeGitModelCatalogCache: Set | null = null; +let bridgeGitModelCatalogCacheAt = 0; + +const sleep = (ms: number) => new Promise((resolve) => { + setTimeout(resolve, ms); +}); + +const readStringField = (value: unknown, key: string): string => { + if (!value || typeof value !== 'object') return ''; + const record = value as Record; + const candidate = record[key]; + return typeof candidate === 'string' ? candidate.trim() : ''; +}; + +const fetchBridgeGitModelCatalog = async ( + apiUrl: string, + authHeaders?: Record +): Promise> => { + const now = Date.now(); + if (bridgeGitModelCatalogCache && now - bridgeGitModelCatalogCacheAt < BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS) { + return bridgeGitModelCatalogCache; + } + + const headers = authHeaders || {}; + const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`); + const response = await fetch(modelsUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + throw new Error('Failed to fetch model catalog'); + } + + const payload = await response.json().catch(() => null) as unknown; + const refs = new Set(); + if (Array.isArray(payload)) { + for (const item of payload) { + if (!item || typeof item !== 'object') { + continue; + } + const record = item as Record; + const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : ''; + const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : ''; + if (providerID && modelID) { + refs.add(`${providerID}/${modelID}`); + } + } + } + + bridgeGitModelCatalogCache = refs; + bridgeGitModelCatalogCacheAt = now; + return refs; +}; + +const resolveBridgeGitGenerationModel = async ( + payloadModel: { providerId?: string; modelId?: string; zenModel?: string }, + settings: Record, + apiUrl: string, + authHeaders?: Record +): Promise<{ providerID: string; modelID: string }> => { + let catalog: Set | null = null; + try { + catalog = await fetchBridgeGitModelCatalog(apiUrl, authHeaders); + } catch { + catalog = null; + } + + const hasModel = (providerID: string, modelID: string): boolean => { + if (!catalog) { + return false; + } + return catalog.has(`${providerID}/${modelID}`); + }; + + const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : ''; + const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : ''; + if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { + return { providerID: requestProviderId, modelID: requestModelId }; + } + + const settingsProviderId = readStringField(settings, 'gitProviderId'); + const settingsModelId = readStringField(settings, 'gitModelId'); + if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { + return { providerID: settingsProviderId, modelID: settingsModelId }; + } + + const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : ''; + const settingsZenModel = readStringField(settings, 'zenModel'); + return { + providerID: 'zen', + modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL, + }; +}; + +const extractTextFromMessageParts = (parts: unknown): string => { + if (!Array.isArray(parts)) { + return ''; + } + + const textParts = parts + .filter((part) => { + if (!part || typeof part !== 'object') return false; + const record = part as Record; + return record.type === 'text' && typeof record.text === 'string'; + }) + .map((part) => (part as Record).text as string) + .map((text) => text.trim()) + .filter((text) => text.length > 0); + + return textParts.join('\n').trim(); +}; + +const generateBridgeTextWithSessionFlow = async ({ + apiUrl, + directory, + prompt, + providerID, + modelID, + authHeaders, +}: { + apiUrl: string; + directory: string; + prompt: string; + providerID: string; + modelID: string; + authHeaders?: Record; +}): Promise => { + const headers = authHeaders || {}; + const apiBase = apiUrl.replace(/\/+$/, ''); + const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS; + const remainingMs = () => Math.max(1_000, deadlineAt - Date.now()); + let sessionId: string | null = null; + + try { + const sessionUrl = new URL(`${apiBase}/session`); + if (directory) { + sessionUrl.searchParams.set('directory', directory); + } + + const createResponse = await fetch(sessionUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ title: 'Git Generation' }), + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!createResponse.ok) { + throw new Error('Failed to create OpenCode session'); + } + + const session = await createResponse.json().catch(() => null) as unknown; + const sessionObj = session && typeof session === 'object' ? session as Record : null; + const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : ''; + if (!createdSessionId) { + throw new Error('Invalid session response'); + } + sessionId = createdSessionId; + + const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`); + if (directory) { + promptUrl.searchParams.set('directory', directory); + } + + const promptResponse = await fetch(promptUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ + model: { + providerID, + modelID, + }, + parts: [{ type: 'text', text: prompt }], + }), + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!promptResponse.ok) { + throw new Error('Failed to send prompt'); + } + + const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`); + if (directory) { + messagesUrl.searchParams.set('directory', directory); + } + messagesUrl.searchParams.set('limit', '10'); + + while (Date.now() < deadlineAt) { + await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS); + + const messagesResponse = await fetch(messagesUrl.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(remainingMs()), + }); + + if (!messagesResponse.ok) { + continue; + } + + const messages = await messagesResponse.json().catch(() => null) as unknown; + if (!Array.isArray(messages)) { + continue; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] as Record | null; + if (!message || typeof message !== 'object') { + continue; + } + const info = message.info as Record | undefined; + if (info?.role !== 'assistant' || info?.finish !== 'stop') { + continue; + } + + const text = extractTextFromMessageParts(message.parts); + if (text) { + return text; + } + } + } + + throw new Error('Timeout waiting for generation to complete'); + } finally { + if (sessionId) { + const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`); + try { + await fetch(deleteUrl.toString(), { + method: 'DELETE', + headers, + signal: AbortSignal.timeout(5_000), + }); + } catch { + // ignore cleanup failures + } + } + } +}; + +const parseJsonObjectSafe = (value: string): Record | null => { + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +}; + +export async function handleSpecialGitBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: SpecialGitDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:git/pr-description': { + const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as { + directory?: string; + base?: string; + head?: string; + context?: string; + providerId?: string; + modelId?: string; + zenModel?: string; + }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + if (!base || !head) { + return { id, type, success: false, error: 'base and head are required' }; + } + + let files: string[] = []; + try { + const listed = await gitService.getGitRangeFiles(directory, base, head); + files = Array.isArray(listed) ? listed : []; + } catch { + files = []; + } + + if (files.length === 0) { + return { id, type, success: false, error: 'No diffs available for base...head' }; + } + + let diffSummaries = ''; + for (const file of files) { + try { + const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3); + const raw = typeof diff?.diff === 'string' ? diff.diff : ''; + if (!raw.trim()) continue; + diffSummaries += `FILE: ${file}\n${raw}\n\n`; + } catch { + // ignore + } + } + + if (!diffSummaries.trim()) { + return { id, type, success: false, error: 'No diffs available for selected files' }; + } + + const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}${context?.trim() ? `\n- Additional context: ${context.trim()}` : ''}\n\nDiff summary:\n${diffSummaries}`; + + try { + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + return { id, type, success: false, error: 'OpenCode API unavailable' }; + } + + const settings = deps.readSettings(ctx) as Record; + const { providerID, modelID } = await resolveBridgeGitGenerationModel( + { providerId, modelId, zenModel: payloadZenModel }, + settings, + apiUrl, + ctx?.manager?.getOpenCodeAuthHeaders() + ); + const raw = await generateBridgeTextWithSessionFlow({ + apiUrl, + directory, + prompt, + providerID, + modelID, + authHeaders: ctx?.manager?.getOpenCodeAuthHeaders(), + }); + if (!raw) { + return { id, type, success: false, error: 'No PR description returned by generator' }; + } + + const cleaned = String(raw) + .trim() + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + + const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw); + if (parsed) { + const title = typeof parsed.title === 'string' ? parsed.title : ''; + const body = typeof parsed.body === 'string' ? parsed.body : ''; + return { id, type, success: true, data: { title, body } }; + } + + return { id, type, success: true, data: { title: '', body: String(raw) } }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: message }; + } + } + + case 'api:git/conflict-details': { + const { directory } = (payload || {}) as { directory?: string }; + if (!directory) { + return { id, type, success: false, error: 'Directory is required' }; + } + + try { + const statusResult = await deps.execGit(['status', '--porcelain'], directory); + const statusPorcelain = statusResult.stdout; + + const unmergedResult = await deps.execGit(['diff', '--name-only', '--diff-filter=U'], directory); + const unmergedFiles = unmergedResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + const diffResult = await deps.execGit(['diff'], directory); + const diff = diffResult.stdout; + + let operation: 'merge' | 'rebase' = 'merge'; + let headInfo = ''; + + const mergeHeadResult = await deps.execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], directory); + const mergeHeadExists = mergeHeadResult.exitCode === 0; + + if (mergeHeadExists) { + operation = 'merge'; + const mergeHead = mergeHeadResult.stdout.trim(); + let mergeMsg = ''; + try { + const mergeMsgPath = path.join(directory, '.git', 'MERGE_MSG'); + mergeMsg = await fs.promises.readFile(mergeMsgPath, 'utf8'); + } catch { + // MERGE_MSG may not exist + } + headInfo = `MERGE_HEAD: ${mergeHead}${mergeMsg ? '\n' + mergeMsg : ''}`; + } else { + const rebaseHeadResult = await deps.execGit(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'], directory); + const rebaseHeadExists = rebaseHeadResult.exitCode === 0; + + if (rebaseHeadExists) { + operation = 'rebase'; + const rebaseHead = rebaseHeadResult.stdout.trim(); + headInfo = `REBASE_HEAD: ${rebaseHead}`; + } + } + + return { + id, + type, + success: true, + data: { + statusPorcelain: statusPorcelain.trim(), + unmergedFiles, + diff: diff.trim(), + headInfo: headInfo.trim(), + operation, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: message }; + } + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts new file mode 100644 index 00000000..66618ffb --- /dev/null +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts @@ -0,0 +1,99 @@ +import * as fs from 'fs'; +import { getFsMimeType, resolveFileReadPath, type FsReadPathResolution } from './bridge-fs-helpers-runtime'; + +type ApiProxyResponsePayload = { + status: number; + headers: Record; + bodyBase64: string; +}; + +export const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); + +export const collectHeaders = (headers: Headers): Record => { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +}; + +export const buildUnavailableApiResponse = (): ApiProxyResponsePayload => { + const body = JSON.stringify({ error: 'OpenCode API unavailable' }); + return { + status: 503, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(body), + }; +}; + +export const sanitizeForwardHeaders = (input: Record | undefined): Record => { + const headers: Record = { ...(input || {}) }; + delete headers['content-length']; + delete headers['host']; + delete headers['connection']; + return headers; +}; + +const buildProxyJsonError = (status: number, error: string): ApiProxyResponsePayload => ({ + status, + headers: { 'content-type': 'application/json' }, + bodyBase64: base64EncodeUtf8(JSON.stringify({ error })), +}); + +export const tryHandleLocalFsProxy = async (method: string, requestPath: string): Promise => { + let parsed: URL; + try { + parsed = new URL(requestPath, 'https://openchamber.local'); + } catch { + return buildProxyJsonError(400, 'Invalid request path'); + } + + if (parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') { + return null; + } + + if (method !== 'GET' && method !== 'HEAD') { + return buildProxyJsonError(405, 'Method not allowed'); + } + + const targetPath = parsed.searchParams.get('path') || ''; + const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath); + if (!resolution.ok) { + return buildProxyJsonError(resolution.status, resolution.error); + } + + try { + const stats = await fs.promises.stat(resolution.resolvedPath); + if (!stats.isFile()) { + return buildProxyJsonError(400, 'Specified path is not a file'); + } + + if (parsed.pathname === '/api/fs/read') { + const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); + return { + status: 200, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + }, + bodyBase64: base64EncodeUtf8(content), + }; + } + + const raw = await fs.promises.readFile(resolution.resolvedPath); + return { + status: 200, + headers: { + 'content-type': getFsMimeType(resolution.resolvedPath), + 'cache-control': 'no-store', + }, + bodyBase64: Buffer.from(raw).toString('base64'), + }; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') { + return buildProxyJsonError(404, 'File not found'); + } + return buildProxyJsonError(500, 'Unable to read file'); + } +}; diff --git a/packages/vscode/src/bridge-proxy-runtime.ts b/packages/vscode/src/bridge-proxy-runtime.ts new file mode 100644 index 00000000..6de2808a --- /dev/null +++ b/packages/vscode/src/bridge-proxy-runtime.ts @@ -0,0 +1,180 @@ +import type { BridgeContext, BridgeResponse } from './bridge'; +import { waitForApiUrl } from './opencode-ready'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type ApiProxyRequestPayload = { + method?: string; + path?: string; + headers?: Record; + bodyBase64?: string; +}; + +type ApiSessionMessageRequestPayload = { + path?: string; + headers?: Record; + bodyText?: string; +}; + +type ApiProxyResponsePayload = { + status: number; + headers: Record; + bodyBase64: string; +}; + +type ProxyRuntimeDeps = { + tryHandleLocalFsProxy: (method: string, requestPath: string) => Promise; + buildUnavailableApiResponse: () => ApiProxyResponsePayload; + sanitizeForwardHeaders: (input: Record | undefined) => Record; + collectHeaders: (headers: Headers) => Record; + base64EncodeUtf8: (text: string) => string; +}; + +export async function handleProxyBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: ProxyRuntimeDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:proxy': { + const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload; + const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; + const normalizedPath = + typeof requestPath === 'string' && requestPath.trim().length > 0 + ? requestPath.trim().startsWith('/') + ? requestPath.trim() + : `/${requestPath.trim()}` + : '/'; + + const localFsResponse = await deps.tryHandleLocalFsProxy(normalizedMethod, normalizedPath); + if (localFsResponse) { + return { id, type, success: true, data: localFsResponse }; + } + + const apiUrl = await waitForApiUrl(ctx?.manager); + if (!apiUrl) { + const data = deps.buildUnavailableApiResponse(); + return { id, type, success: true, data }; + } + + const base = `${apiUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + const requestHeaders: Record = { + ...deps.sanitizeForwardHeaders(headers), + ...ctx?.manager?.getOpenCodeAuthHeaders(), + }; + + if (normalizedPath === '/event' || normalizedPath === '/global/event') { + if (!requestHeaders.Accept) { + requestHeaders.Accept = 'text/event-stream'; + } + requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache'; + requestHeaders.Connection = requestHeaders.Connection || 'keep-alive'; + } + + try { + const response = await fetch(targetUrl, { + method: normalizedMethod, + headers: requestHeaders, + body: + typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD' + ? Buffer.from(bodyBase64, 'base64') + : undefined, + }); + + const arrayBuffer = await response.arrayBuffer(); + const data: ApiProxyResponsePayload = { + status: response.status, + headers: deps.collectHeaders(response.headers), + bodyBase64: Buffer.from(arrayBuffer).toString('base64'), + }; + + return { id, type, success: true, data }; + } catch (error) { + const body = JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to reach OpenCode API', + }); + const data: ApiProxyResponsePayload = { + status: 502, + headers: { 'content-type': 'application/json' }, + bodyBase64: deps.base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + } + + case 'api:session:message': { + const apiUrl = await waitForApiUrl(ctx?.manager); + if (!apiUrl) { + const data = deps.buildUnavailableApiResponse(); + return { id, type, success: true, data }; + } + + const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload; + const normalizedPath = + typeof requestPath === 'string' && requestPath.trim().length > 0 + ? requestPath.trim().startsWith('/') + ? requestPath.trim() + : `/${requestPath.trim()}` + : '/'; + + if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) { + const body = JSON.stringify({ error: 'Invalid session message proxy path' }); + const data: ApiProxyResponsePayload = { + status: 400, + headers: { 'content-type': 'application/json' }, + bodyBase64: deps.base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + + const base = `${apiUrl.replace(/\/+$/, '')}/`; + const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); + const requestHeaders: Record = { + ...deps.sanitizeForwardHeaders(headers), + ...ctx?.manager?.getOpenCodeAuthHeaders(), + }; + + try { + const response = await fetch(targetUrl, { + method: 'POST', + headers: requestHeaders, + body: typeof bodyText === 'string' ? bodyText : '', + signal: AbortSignal.timeout(45000), + }); + + const arrayBuffer = await response.arrayBuffer(); + const data: ApiProxyResponsePayload = { + status: response.status, + headers: deps.collectHeaders(response.headers), + bodyBase64: Buffer.from(arrayBuffer).toString('base64'), + }; + + return { id, type, success: true, data }; + } catch (error) { + const isTimeout = + error instanceof Error && + ((error as Error & { name?: string }).name === 'TimeoutError' || + (error as Error & { name?: string }).name === 'AbortError'); + const body = JSON.stringify({ + error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed', + }); + const data: ApiProxyResponsePayload = { + status: isTimeout ? 504 : 503, + headers: { 'content-type': 'application/json' }, + bodyBase64: deps.base64EncodeUtf8(body), + }; + return { id, type, success: true, data }; + } + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts new file mode 100644 index 00000000..cc01948c --- /dev/null +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -0,0 +1,231 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig'; +import type { BridgeContext } from './bridge'; + +const SETTINGS_KEY = 'openchamber.settings'; +const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); + +const isPathInside = (candidatePath: string, parentPath: string): boolean => { + const relative = path.relative(parentPath, candidatePath); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +}; + +const findWorktreeRootForSkills = (workingDirectory?: string): string | null => { + if (!workingDirectory) return null; + let current = path.resolve(workingDirectory); + while (true) { + const gitPath = path.join(current, '.git'); + try { + const stat = fs.statSync(gitPath); + if (stat.isFile()) { + return current; + } + } catch { + // Continue climbing. + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +const getProjectAncestors = (workingDirectory?: string): string[] => { + if (!workingDirectory) return []; + const result: string[] = []; + let current = path.resolve(workingDirectory); + const stop = findWorktreeRootForSkills(workingDirectory) || current; + while (true) { + result.push(current); + if (current === stop) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return result; +}; + +const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory?: string): { scope: SkillScope; source: SkillSource } => { + const resolvedPath = path.resolve(location); + const source: SkillSource = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) + ? 'agents' + : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) + ? 'claude' + : 'opencode'; + + const projectAncestors = getProjectAncestors(workingDirectory); + const isProjectScoped = projectAncestors.some((ancestor) => { + const candidates = [ + path.join(ancestor, '.opencode'), + path.join(ancestor, '.claude', 'skills'), + path.join(ancestor, '.agents', 'skills'), + ]; + return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); + }); + + if (isProjectScoped) { + return { scope: 'project', source }; + } + + const home = os.homedir(); + const userRoots = [ + path.join(home, '.config', 'opencode'), + path.join(home, '.opencode'), + path.join(home, '.claude', 'skills'), + path.join(home, '.agents', 'skills'), + process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, + ].filter((value): value is string => Boolean(value)); + + if (userRoots.some((root) => isPathInside(resolvedPath, root))) { + return { scope: 'user', source }; + } + + return { scope: 'user', source }; +}; + +export const fetchOpenCodeSkillsFromApi = async ( + ctx: BridgeContext | undefined, + workingDirectory?: string, +): Promise => { + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + return null; + } + + try { + const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; + const url = new URL('skill', base); + if (workingDirectory) { + url.searchParams.set('directory', workingDirectory); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...(ctx?.manager?.getOpenCodeAuthHeaders() || {}), + }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + if (!Array.isArray(payload)) { + return null; + } + + return payload + .map((item) => { + const name = typeof item?.name === 'string' ? item.name.trim() : ''; + const location = typeof item?.location === 'string' ? item.location : ''; + const description = typeof item?.description === 'string' ? item.description : ''; + if (!name || !location) { + return null; + } + const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory); + return { + name, + path: location, + scope: inferred.scope, + source: inferred.source, + description, + } as DiscoveredSkill; + }) + .filter((item): item is DiscoveredSkill => item !== null); + } catch { + return null; + } +}; + +const readSharedSettingsFromDisk = (): Record => { + try { + const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +}; + +const writeSharedSettingsToDisk = async (changes: Record): Promise => { + try { + await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true }); + const current = readSharedSettingsFromDisk(); + const next: Record = { ...current, ...changes }; + await fs.promises.writeFile(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(next, null, 2), 'utf8'); + } catch { + // ignore + } +}; + +export const readSettings = (ctx?: BridgeContext): Record => { + const stored = ctx?.context?.globalState.get>(SETTINGS_KEY) || {}; + const restStored = { ...stored }; + delete (restStored as Record).lastDirectory; + const shared = readSharedSettingsFromDisk(); + const sharedOpencodeBinary = typeof shared.opencodeBinary === 'string' ? shared.opencodeBinary.trim() : ''; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + const themeVariant = + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight + ? 'light' + : 'dark'; + + return { + themeVariant, + lastDirectory: workspaceFolder, + ...restStored, + opencodeBinary: + typeof restStored.opencodeBinary === 'string' + ? String(restStored.opencodeBinary).trim() + : (sharedOpencodeBinary || undefined), + }; +}; + +export const persistSettings = async (changes: Record, ctx?: BridgeContext): Promise> => { + const current = readSettings(ctx); + const restChanges = { ...(changes || {}) }; + delete restChanges.lastDirectory; + + const keysToClear = new Set(); + + for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) { + const value = restChanges[key]; + if (typeof value === 'string' && value.trim().length === 0) { + keysToClear.add(key); + delete restChanges[key]; + } + } + + if (typeof restChanges.usageAutoRefresh !== 'boolean') { + delete restChanges.usageAutoRefresh; + } + + if (typeof restChanges.usageRefreshIntervalMs === 'number' && Number.isFinite(restChanges.usageRefreshIntervalMs)) { + restChanges.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(restChanges.usageRefreshIntervalMs))); + } else { + delete restChanges.usageRefreshIntervalMs; + } + + const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record; + for (const key of keysToClear) { + delete merged[key]; + } + await ctx?.context?.globalState.update(SETTINGS_KEY, merged); + + if (keysToClear.has('opencodeBinary')) { + await writeSharedSettingsToDisk({ opencodeBinary: '' }); + } else if (typeof restChanges.opencodeBinary === 'string') { + await writeSharedSettingsToDisk({ opencodeBinary: restChanges.opencodeBinary.trim() }); + } + + return merged; +}; diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts new file mode 100644 index 00000000..c50d9be7 --- /dev/null +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -0,0 +1,558 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { randomUUID } from 'crypto'; +import { removeProviderConfig, getProviderSources } from './opencodeConfig'; +import { getProviderAuth, removeProviderAuth } from './opencodeAuth'; +import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders'; +import { getSessionActivitySnapshot } from './sessionActivityWatcher'; +import type { BridgeContext, BridgeResponse } from './bridge'; + +type BridgeMessageInput = { + id: string; + type: string; + payload?: unknown; +}; + +type SystemRuntimeDeps = { + resolveUserPath: (value: string, baseDirectory: string) => string; + fetchModelsMetadata: () => Promise; + updateCheckUrl: string; + clientReloadDelayMs: number; +}; + +type NotificationBridgePayload = { + title?: string; + body?: string; + tag?: string; +}; + +type NotificationsNotifyRequestPayload = { + payload?: NotificationBridgePayload; +}; + +const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; +const ZEN_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; +let cachedZenModels: { models: Array<{ id: string; owned_by?: string }>; at: number } | null = null; + +const getOpenChamberConfigDir = (): string => { + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + if (appData) return path.join(appData, 'openchamber'); + } + return path.join(os.homedir(), '.config', 'openchamber'); +}; + +const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => { + if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; + return 'web'; +}; + +const getOrCreateInstallId = (scope: string): string => { + const configDir = getOpenChamberConfigDir(); + const normalizedScope = sanitizeInstallScope(scope); + const idPath = path.join(configDir, `install-id-${normalizedScope}`); + + try { + const existing = fs.readFileSync(idPath, 'utf8').trim(); + if (existing) return existing; + } catch { + // Generate new id. + } + + const installId = randomUUID(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 }); + return installId; +}; + +const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => { + if (value === 'darwin') return 'macos'; + if (value === 'win32') return 'windows'; + if (value === 'linux') return 'linux'; + return 'web'; +}; + +const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => { + if (value === 'arm64' || value === 'aarch64') return 'arm64'; + if (value === 'x64' || value === 'amd64') return 'x64'; + return 'unknown'; +}; + +type ParsedDiffHunk = { + newStart: number; + oldLines: string[]; + newLines: string[]; +}; + +const VIRTUAL_DIFF_SCHEME = 'openchamber-diff'; +const virtualDiffContents = new Map(); +let virtualDiffCounter = 0; +let virtualDiffProviderDisposable: vscode.Disposable | null = null; + +const asObject = (value: unknown): Record | null => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + +const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => { + if (virtualDiffProviderDisposable) { + return; + } + + virtualDiffProviderDisposable = vscode.workspace.registerTextDocumentContentProvider( + VIRTUAL_DIFF_SCHEME, + { + provideTextDocumentContent: (uri: vscode.Uri) => { + const key = new URLSearchParams(uri.query).get('key') || ''; + return virtualDiffContents.get(key) ?? ''; + }, + }, + ); + + if (ctx?.context) { + ctx.context.subscriptions.push(virtualDiffProviderDisposable); + } +}; + +const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vscode.Uri => { + const key = `${Date.now()}-${++virtualDiffCounter}`; + virtualDiffContents.set(key, content); + + if (virtualDiffContents.size > 100) { + const firstKey = virtualDiffContents.keys().next().value; + if (firstKey) { + virtualDiffContents.delete(firstKey); + } + } + + return vscode.Uri.from({ + scheme: VIRTUAL_DIFF_SCHEME, + path: `/${path.basename(modifiedPath) || 'original'}`, + query: `key=${encodeURIComponent(key)}`, + }); +}; + +const parseUnifiedDiffHunks = (patch: string): ParsedDiffHunk[] => { + const lines = patch.split(/\r?\n/); + const hunks: ParsedDiffHunk[] = []; + + let current: ParsedDiffHunk | null = null; + + for (const line of lines) { + const headerMatch = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (headerMatch) { + if (current) { + hunks.push(current); + } + current = { + newStart: Number(headerMatch[1] || 1), + oldLines: [], + newLines: [], + }; + continue; + } + + if (!current) continue; + + if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('\\ No newline')) { + continue; + } + + if (line.startsWith('-')) { + current.oldLines.push(line.slice(1)); + continue; + } + + if (line.startsWith('+')) { + current.newLines.push(line.slice(1)); + continue; + } + + if (line.startsWith(' ')) { + const content = line.slice(1); + current.oldLines.push(content); + current.newLines.push(content); + } + } + + if (current) { + hunks.push(current); + } + + return hunks; +}; + +const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: string): string | null => { + const hunks = parseUnifiedDiffHunks(patch); + if (hunks.length === 0) { + return null; + } + + const lines = modifiedContent.split('\n'); + for (let index = hunks.length - 1; index >= 0; index -= 1) { + const hunk = hunks[index]; + if (!hunk) { + continue; + } + const startIndex = Math.max(0, hunk.newStart - 1); + const replaceCount = hunk.newLines.length; + lines.splice(startIndex, replaceCount, ...hunk.oldLines); + } + + return lines.join('\n'); +}; + +const fetchFreeZenModels = async (): Promise> => { + const now = Date.now(); + if (cachedZenModels && now - cachedZenModels.at < ZEN_MODELS_CACHE_TTL_MS) { + return cachedZenModels.models; + } + + const response = await fetch(ZEN_MODELS_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + throw new Error(`zen models request failed (${response.status})`); + } + + const rawPayload = await response.json().catch(() => null); + const payload = asObject(rawPayload); + const rows = Array.isArray(payload?.data) ? payload.data : []; + const models = rows + .map((entry) => { + const id = typeof (entry as { id?: unknown })?.id === 'string' + ? (entry as { id: string }).id.trim() + : ''; + const ownedBy = typeof (entry as { owned_by?: unknown })?.owned_by === 'string' + ? (entry as { owned_by: string }).owned_by + : undefined; + if (!id || !id.endsWith('-free')) return null; + return ownedBy ? { id, owned_by: ownedBy } : { id }; + }) + .filter((entry): entry is { id: string; owned_by?: string } => entry !== null); + + cachedZenModels = { models, at: Date.now() }; + return models; +}; + +export async function handleSystemBridgeMessage( + message: BridgeMessageInput, + ctx: BridgeContext | undefined, + deps: SystemRuntimeDeps, +): Promise { + const { id, type, payload } = message; + + switch (type) { + case 'api:opencode/directory': { + const target = (payload as { path?: string })?.path; + if (!target) { + return { id, type, success: false, error: 'Path is required' }; + } + const baseDirectory = + ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); + const resolvedPath = deps.resolveUserPath(target, baseDirectory); + const result = await ctx?.manager?.setWorkingDirectory(resolvedPath); + if (!result) { + return { id, type, success: false, error: 'OpenCode manager unavailable' }; + } + return { id, type, success: true, data: result }; + } + + case 'api:models/metadata': { + try { + const data = await deps.fetchModelsMetadata(); + return { id, type, success: true, data }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:session-activity:get': { + return { id, type, success: true, data: getSessionActivitySnapshot() }; + } + + case 'api:zen:models': { + try { + const models = await fetchFreeZenModels(); + return { id, type, success: true, data: { models } }; + } catch (error) { + if (cachedZenModels) { + return { id, type, success: true, data: { models: cachedZenModels.models } }; + } + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:openchamber:update-check': { + try { + const body = (payload && typeof payload === 'object' ? payload : {}) as Record; + const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0 + ? body.currentVersion.trim() + : 'unknown'; + const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0 + ? body.instanceMode.trim() + : 'local'; + const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0 + ? body.deviceClass.trim() + : 'desktop'; + const platformRaw = typeof body.platform === 'string' && body.platform.trim().length > 0 + ? body.platform.trim() + : os.platform(); + const archRaw = typeof body.arch === 'string' && body.arch.trim().length > 0 + ? body.arch.trim() + : os.arch(); + const reportUsage = body.reportUsage !== false; + + const installId = getOrCreateInstallId('vscode'); + const requestBody = { + appType: 'vscode', + deviceClass, + platform: mapNodePlatformToApiPlatform(platformRaw), + arch: mapNodeArchToApiArch(archRaw), + channel: 'stable', + currentVersion, + installId, + instanceMode, + reportUsage, + }; + + const response = await fetch(deps.updateCheckUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'update check failed'); + return { id, type, success: false, error: text || `Update check failed with ${response.status}` }; + } + + const data = await response.json(); + return { id, type, success: true, data }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'editor:openFile': { + const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number }; + try { + const doc = await vscode.workspace.openTextDocument(filePath); + const options: vscode.TextDocumentShowOptions = {}; + if (typeof line === 'number') { + const pos = new vscode.Position(Math.max(0, line - 1), column || 0); + options.selection = new vscode.Range(pos, pos); + } + await vscode.window.showTextDocument(doc, options); + return { id, type, success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'editor:openDiff': { + const { original, modified, label, line, patch } = payload as { + original: string; + modified: string; + label?: string; + line?: number; + patch?: string; + }; + try { + const modifiedUri = vscode.Uri.file(modified); + const modifiedDoc = await vscode.workspace.openTextDocument(modifiedUri); + let originalUri = original ? vscode.Uri.file(original) : modifiedUri; + + if (typeof patch === 'string' && patch.trim().length > 0) { + const originalContent = reconstructOriginalContentFromPatch(modifiedDoc.getText(), patch); + if (typeof originalContent === 'string') { + ensureVirtualDiffProviderRegistered(ctx); + originalUri = createVirtualOriginalDiffUri(modified, originalContent); + } + } + + const leftLabel = original ? path.basename(original) : `${path.basename(modified)} (before)`; + const title = label || `${leftLabel} ↔ ${path.basename(modified)}`; + + await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title); + + if (typeof line === 'number' && Number.isFinite(line)) { + const targetLine = Math.max(0, Math.trunc(line) - 1); + await new Promise((resolve) => setTimeout(resolve, 0)); + const targetEditor = vscode.window.visibleTextEditors.find( + (editor) => editor.document.uri.toString() === modifiedUri.toString(), + ); + if (targetEditor) { + const target = new vscode.Position(targetLine, 0); + targetEditor.selection = new vscode.Selection(target, target); + targetEditor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenter); + } + } + + return { id, type, success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:provider/auth:delete': { + const { providerId, scope, directory } = (payload || {}) as { providerId?: string; scope?: string; directory?: string }; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + const normalizedScope = typeof scope === 'string' ? scope : 'auth'; + const workingDirectory = typeof directory === 'string' && directory.trim().length > 0 + ? directory.trim() + : ctx?.manager?.getWorkingDirectory(); + try { + let removed = false; + if (normalizedScope === 'auth') { + removed = removeProviderAuth(providerId); + } else if (normalizedScope === 'user' || normalizedScope === 'project' || normalizedScope === 'custom') { + removed = removeProviderConfig(providerId, workingDirectory, normalizedScope); + } else if (normalizedScope === 'all') { + const authRemoved = removeProviderAuth(providerId); + const userRemoved = removeProviderConfig(providerId, workingDirectory, 'user'); + const projectRemoved = workingDirectory + ? removeProviderConfig(providerId, workingDirectory, 'project') + : false; + const customRemoved = removeProviderConfig(providerId, workingDirectory, 'custom'); + removed = authRemoved || userRemoved || projectRemoved || customRemoved; + } else { + return { id, type, success: false, error: 'Invalid scope' }; + } + + if (removed) { + await ctx?.manager?.restart(); + } + return { + id, + type, + success: true, + data: { + success: true, + removed, + requiresReload: removed, + message: removed + ? `Provider ${providerId} disconnected successfully. Reloading interface…` + : `Provider ${providerId} was not configured.`, + reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined, + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:provider/source:get': { + const { providerId, directory } = (payload || {}) as { providerId?: string; directory?: string }; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + try { + const workingDirectory = typeof directory === 'string' && directory.trim().length > 0 + ? directory.trim() + : ctx?.manager?.getWorkingDirectory(); + const sources = getProviderSources(providerId, workingDirectory); + const auth = getProviderAuth(providerId); + sources.auth.exists = Boolean(auth); + return { id, type, success: true, data: { providerId, sources } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:quota:providers': { + try { + const providers = listConfiguredQuotaProviders(); + return { id, type, success: true, data: { providers } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'api:quota:get': { + const { providerId } = (payload || {}) as { providerId?: string }; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + try { + const result = await fetchQuotaForProvider(providerId); + return { id, type, success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'vscode:command': { + const { command, args } = (payload || {}) as { command?: string; args?: unknown[] }; + if (!command) { + return { id, type, success: false, error: 'Command is required' }; + } + try { + const result = await vscode.commands.executeCommand(command, ...(args || [])); + return { id, type, success: true, data: { result } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'vscode:openExternalUrl': { + const { url } = (payload || {}) as { url?: string }; + const target = typeof url === 'string' ? url.trim() : ''; + if (!target) { + return { id, type, success: false, error: 'URL is required' }; + } + try { + await vscode.env.openExternal(vscode.Uri.parse(target)); + return { id, type, success: true, data: { opened: true } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + + case 'notifications:can-notify': { + return { id, type, success: true, data: true }; + } + + case 'notifications:notify': { + const request = (payload || {}) as NotificationsNotifyRequestPayload; + const notification = request.payload || {}; + const title = typeof notification.title === 'string' ? notification.title.trim() : ''; + const body = typeof notification.body === 'string' ? notification.body.trim() : ''; + + const message = title && body + ? `${title}: ${body}` + : title || body; + + if (!message) { + return { id, type, success: true, data: { shown: false } }; + } + + void vscode.window.showInformationMessage(message); + return { id, type, success: true, data: { shown: true } }; + } + + default: + return null; + } +} diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 03ecc34b..c72ef9f1 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -1,51 +1,30 @@ import * as vscode from 'vscode'; -import * as os from 'os'; -import * as path from 'path'; -import * as fs from 'fs'; -import { randomUUID } from 'crypto'; -import { spawn, execFile } from 'child_process'; -import { promisify } from 'util'; import { type OpenCodeManager } from './opencode'; -import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, type SkillSource, type DiscoveredSkill, SKILL_SCOPE, getProviderSources, removeProviderConfig, listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './opencodeConfig'; -import { getProviderAuth, removeProviderAuth } from './opencodeAuth'; -import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders'; -import * as gitService from './gitService'; +import { handleStandardGitBridgeMessage } from './bridge-git-runtime'; +import { handleSpecialGitBridgeMessage } from './bridge-git-special-runtime'; +import { handleFsBridgeMessage } from './bridge-fs-runtime'; +import { handleConfigBridgeMessage } from './bridge-config-runtime'; +import { handleSystemBridgeMessage } from './bridge-system-runtime'; +import { handleProxyBridgeMessage } from './bridge-proxy-runtime'; +import { fetchOpenCodeSkillsFromApi, persistSettings, readSettings } from './bridge-settings-runtime'; +import { execGit } from './bridge-git-process-runtime'; import { - getSkillsCatalog, - scanSkillsRepository as scanSkillsRepositoryFromGit, - installSkillsFromRepository as installSkillsFromGit, - type SkillsCatalogSourceConfig, -} from './skillsCatalog'; + parseDroppedFileReference, + readUriAsAttachment, + resolveUserPath, + listDirectoryEntries, + normalizeFsPath, + searchDirectory, + resolveFileReadPath, + fetchModelsMetadata, +} from './bridge-fs-helpers-runtime'; import { - DEFAULT_GITHUB_CLIENT_ID, - DEFAULT_GITHUB_SCOPES, - activateGitHubAuth, - clearGitHubAuth, - exchangeDeviceCode, - fetchMe, - readGitHubAuth, - readGitHubAuthList, - startDeviceFlow, - writeGitHubAuth, -} from './githubAuth'; -import { - createPullRequest, - getPullRequestStatus, - markPullRequestReady, - mergePullRequest, - updatePullRequest, -} from './githubPr'; - -import { - getIssue, - listIssueComments, - listIssues, -} from './githubIssues'; - -import { - getPullRequestContext, - listPullRequests, -} from './githubPulls'; + tryHandleLocalFsProxy, + buildUnavailableApiResponse, + sanitizeForwardHeaders, + collectHeaders, + base64EncodeUtf8, +} from './bridge-localfs-proxy-runtime'; export interface BridgeRequest { id: string; @@ -61,3762 +40,108 @@ export interface BridgeResponse { error?: string; } -type ApiProxyRequestPayload = { - method?: string; - path?: string; - headers?: Record; - bodyBase64?: string; -}; - -type ApiSessionMessageRequestPayload = { - path?: string; - headers?: Record; - bodyText?: string; -}; - -type ApiProxyResponsePayload = { - status: number; - headers: Record; - bodyBase64: string; -}; - -type NotificationBridgePayload = { - title?: string; - body?: string; - tag?: string; -}; - -type NotificationsNotifyRequestPayload = { - payload?: NotificationBridgePayload; -}; - -interface FileEntry { - name: string; - path: string; - isDirectory: boolean; -} - -interface FileSearchResult { - path: string; - score?: number; -} - export interface BridgeContext { manager?: OpenCodeManager; context?: vscode.ExtensionContext; } -const SETTINGS_KEY = 'openchamber.settings'; const CLIENT_RELOAD_DELAY_MS = 800; -const MAX_FILE_ATTACH_SIZE_BYTES = 10 * 1024 * 1024; -const execFileAsync = promisify(execFile); -const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; -const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check'; +const GITHUB_BACKEND_DISABLED_ERROR = 'OpenChamber VS Code backend GitHub integration is disabled. Use native VS Code GitHub integrations.'; -const getOpenChamberConfigDir = (): string => { - if (process.platform === 'win32') { - const appData = process.env.APPDATA; - if (appData) return path.join(appData, 'openchamber'); - } - return path.join(os.homedir(), '.config', 'openchamber'); -}; - -const sanitizeInstallScope = (scope: string): 'desktop-tauri' | 'vscode' | 'web' => { - if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; - return 'web'; -}; - -const getOrCreateInstallId = (scope: string): string => { - const configDir = getOpenChamberConfigDir(); - const normalizedScope = sanitizeInstallScope(scope); - const idPath = path.join(configDir, `install-id-${normalizedScope}`); - - try { - const existing = fs.readFileSync(idPath, 'utf8').trim(); - if (existing) return existing; - } catch { - // Generate new id. - } - - const installId = randomUUID(); - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 }); - return installId; -}; - -const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => { - if (value === 'darwin') return 'macos'; - if (value === 'win32') return 'windows'; - if (value === 'linux') return 'linux'; - return 'web'; -}; - -const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => { - if (value === 'arm64' || value === 'aarch64') return 'arm64'; - if (value === 'x64' || value === 'amd64') return 'x64'; - return 'unknown'; -}; - -const guessMimeTypeFromExtension = (ext: string) => { - switch (ext) { - case '.png': - case '.jpg': - case '.jpeg': - case '.gif': - case '.bmp': - case '.webp': - return `image/${ext.replace('.', '')}`; - case '.pdf': - return 'application/pdf'; - case '.txt': - case '.log': - return 'text/plain'; - case '.json': - return 'application/json'; - case '.md': - case '.markdown': - return 'text/markdown'; - default: - return 'application/octet-stream'; - } -}; - -const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value); - -const parseDroppedFileReference = (rawReference: string): - | { uri: vscode.Uri } - | { skipped: { name: string; reason: string } } => { - const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, ''); - if (!trimmed) { - return { skipped: { name: rawReference, reason: 'Empty drop reference' } }; - } - - if (hasUriScheme(trimmed)) { - try { - const parsed = vscode.Uri.parse(trimmed, true); - if (parsed.scheme !== 'file') { - return { - skipped: { - name: trimmed, - reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`, - }, - }; - } - return { uri: parsed }; - } catch (error) { - return { - skipped: { - name: trimmed, - reason: error instanceof Error ? error.message : 'Invalid URI', - }, - }; - } - } - - if (!path.isAbsolute(trimmed)) { - return { - skipped: { - name: trimmed, - reason: 'Drop reference is not an absolute file path', - }, - }; - } - - return { uri: vscode.Uri.file(trimmed) }; -}; - -const readUriAsAttachment = async ( - uri: vscode.Uri, - fallbackName?: string, -): Promise< - | { file: { name: string; mimeType: string; size: number; dataUrl: string } } - | { skipped: { name: string; reason: string } } -> => { - const name = path.basename(uri.fsPath || uri.path || fallbackName || 'file'); - - try { - const stat = await vscode.workspace.fs.stat(uri); - if ((stat.type & vscode.FileType.Directory) !== 0) { - return { skipped: { name, reason: 'Folders are not supported' } }; - } - - const size = stat.size ?? 0; - if (size > MAX_FILE_ATTACH_SIZE_BYTES) { - return { skipped: { name, reason: 'File exceeds 10MB limit' } }; - } - - const bytes = await vscode.workspace.fs.readFile(uri); - const ext = path.extname(name).toLowerCase(); - const mimeType = guessMimeTypeFromExtension(ext); - const base64 = Buffer.from(bytes).toString('base64'); - const dataUrl = `data:${mimeType};base64,${base64}`; - - return { file: { name, mimeType, size, dataUrl } }; - } catch (error) { - return { skipped: { name, reason: error instanceof Error ? error.message : 'Failed to read file' } }; - } -}; - -type ParsedDiffHunk = { - newStart: number; - oldLines: string[]; - newLines: string[]; -}; - -const VIRTUAL_DIFF_SCHEME = 'openchamber-diff'; -const virtualDiffContents = new Map(); -let virtualDiffCounter = 0; -let virtualDiffProviderDisposable: vscode.Disposable | null = null; - -const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => { - if (virtualDiffProviderDisposable) { - return; - } - - virtualDiffProviderDisposable = vscode.workspace.registerTextDocumentContentProvider( - VIRTUAL_DIFF_SCHEME, - { - provideTextDocumentContent: (uri: vscode.Uri) => { - const key = new URLSearchParams(uri.query).get('key') || ''; - return virtualDiffContents.get(key) ?? ''; - }, - }, - ); - - if (ctx?.context) { - ctx.context.subscriptions.push(virtualDiffProviderDisposable); - } -}; - -const createVirtualOriginalDiffUri = (modifiedPath: string, content: string): vscode.Uri => { - const key = `${Date.now()}-${++virtualDiffCounter}`; - virtualDiffContents.set(key, content); - - if (virtualDiffContents.size > 100) { - const firstKey = virtualDiffContents.keys().next().value; - if (typeof firstKey === 'string') { - virtualDiffContents.delete(firstKey); - } - } - - const fileName = path.basename(modifiedPath) || 'file'; - return vscode.Uri.from({ - scheme: VIRTUAL_DIFF_SCHEME, - // Keep real filename (incl extension) so VS Code can infer language for syntax highlighting. - path: `/${fileName}`, - query: `key=${encodeURIComponent(key)}`, - }); -}; - -const parseUnifiedDiffHunks = (patch: string): ParsedDiffHunk[] => { - if (typeof patch !== 'string' || patch.trim().length === 0) { - return []; - } - - const lines = patch.split('\n'); - const hunks: ParsedDiffHunk[] = []; - let current: ParsedDiffHunk | null = null; - - for (const rawLine of lines) { - const line = rawLine.replace(/\r$/, ''); - const headerMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); - if (headerMatch) { - if (current) { - hunks.push(current); - } - const newStart = Number.parseInt(headerMatch[1] ?? '', 10); - current = { - newStart: Number.isFinite(newStart) ? Math.max(1, newStart) : 1, - oldLines: [], - newLines: [], - }; - continue; - } - - if (!current) { - continue; - } - - if (line.startsWith(' ')) { - const text = line.slice(1); - current.oldLines.push(text); - current.newLines.push(text); - continue; - } - - if (line.startsWith('+') && !line.startsWith('+++')) { - current.newLines.push(line.slice(1)); - continue; - } - - if (line.startsWith('-') && !line.startsWith('---')) { - current.oldLines.push(line.slice(1)); - continue; - } - } - - if (current) { - hunks.push(current); - } - - return hunks; -}; - -const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: string): string | null => { - const hunks = parseUnifiedDiffHunks(patch); - if (hunks.length === 0) { - return null; - } - - const lines = modifiedContent.split('\n'); - for (let index = hunks.length - 1; index >= 0; index -= 1) { - const hunk = hunks[index]; - if (!hunk) { - continue; - } - const startIndex = Math.max(0, hunk.newStart - 1); - const replaceCount = hunk.newLines.length; - lines.splice(startIndex, replaceCount, ...hunk.oldLines); - } - - return lines.join('\n'); -}; - -const isPathInside = (candidatePath: string, parentPath: string): boolean => { - const normalizedCandidate = path.resolve(candidatePath); - const normalizedParent = path.resolve(parentPath); - return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); -}; - -const findWorktreeRootForSkills = (workingDirectory?: string): string | null => { - if (!workingDirectory) return null; - let current = path.resolve(workingDirectory); - while (true) { - if (fs.existsSync(path.join(current, '.git'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) return null; - current = parent; - } -}; - -const getProjectAncestors = (workingDirectory?: string): string[] => { - if (!workingDirectory) return []; - const result: string[] = []; - let current = path.resolve(workingDirectory); - const stop = findWorktreeRootForSkills(workingDirectory) || current; - while (true) { - result.push(current); - if (current === stop) break; - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - return result; -}; - -const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory?: string): { scope: SkillScope; source: SkillSource } => { - const resolvedPath = path.resolve(location); - const source: SkillSource = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) - ? 'agents' - : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) - ? 'claude' - : 'opencode'; - - const projectAncestors = getProjectAncestors(workingDirectory); - const isProjectScoped = projectAncestors.some((ancestor) => { - const candidates = [ - path.join(ancestor, '.opencode'), - path.join(ancestor, '.claude', 'skills'), - path.join(ancestor, '.agents', 'skills'), - ]; - return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); - }); - - if (isProjectScoped) { - return { scope: 'project', source }; - } - - const home = os.homedir(); - const userRoots = [ - path.join(home, '.config', 'opencode'), - path.join(home, '.opencode'), - path.join(home, '.claude', 'skills'), - path.join(home, '.agents', 'skills'), - process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, - ].filter((value): value is string => Boolean(value)); - - if (userRoots.some((root) => isPathInside(resolvedPath, root))) { - return { scope: 'user', source }; - } - - return { scope: 'user', source }; -}; - -const fetchOpenCodeSkillsFromApi = async (ctx: BridgeContext | undefined, workingDirectory?: string): Promise => { - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - return null; - } - - try { - const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; - const url = new URL('skill', base); - if (workingDirectory) { - url.searchParams.set('directory', workingDirectory); - } - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...(ctx?.manager?.getOpenCodeAuthHeaders() || {}), - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - return null; - } - - const payload = await response.json(); - if (!Array.isArray(payload)) { - return null; - } - - return payload - .map((item) => { - const name = typeof item?.name === 'string' ? item.name.trim() : ''; - const location = typeof item?.location === 'string' ? item.location : ''; - const description = typeof item?.description === 'string' ? item.description : ''; - if (!name || !location) { - return null; - } - const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory); - return { - name, - path: location, - scope: inferred.scope, - source: inferred.source, - description, - } as DiscoveredSkill; - }) - .filter((item): item is DiscoveredSkill => item !== null); - } catch { - return null; - } -}; - -const readSharedSettingsFromDisk = (): Record => { - try { - const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; - } - return {}; - } catch { - return {}; - } -}; - -const writeSharedSettingsToDisk = async (changes: Record): Promise => { - try { - await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true }); - const current = readSharedSettingsFromDisk(); - const next: Record = { ...current, ...changes }; - // Keep empty-string sentinel (""), so other runtimes can detect explicit clears. - await fs.promises.writeFile(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(next, null, 2), 'utf8'); - } catch { - // ignore - } -}; - -const readSettings = (ctx?: BridgeContext) => { - const stored = ctx?.context?.globalState.get>(SETTINGS_KEY) || {}; - const restStored = { ...stored }; - delete (restStored as Record).lastDirectory; - const shared = readSharedSettingsFromDisk(); - const sharedOpencodeBinary = typeof shared.opencodeBinary === 'string' ? shared.opencodeBinary.trim() : ''; - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - const themeVariant = - vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || - vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight - ? 'light' - : 'dark'; - - return { - themeVariant, - lastDirectory: workspaceFolder, - ...restStored, - opencodeBinary: - typeof restStored.opencodeBinary === 'string' - ? String(restStored.opencodeBinary).trim() - : (sharedOpencodeBinary || undefined), - }; -}; - -const readStringField = (value: unknown, key: string): string => { - if (!value || typeof value !== 'object') return ''; - const record = value as Record; - const candidate = record[key]; - return typeof candidate === 'string' ? candidate.trim() : ''; -}; - -const readBooleanField = (value: unknown, key: string): boolean | undefined => { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - const candidate = record[key]; - return typeof candidate === 'boolean' ? candidate : undefined; -}; - -const readNumberField = (value: unknown, key: string): number | undefined => { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - const candidate = record[key]; - return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined; -}; - -const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => { - const trimmed = value.trim(); - if (trimmed === 'merge' || trimmed === 'squash' || trimmed === 'rebase') return trimmed; - return 'merge'; -}; - -const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano'; -const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000; -const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500; -let bridgeGitModelCatalogCache: Set | null = null; -let bridgeGitModelCatalogCacheAt = 0; -const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000; - -const sleep = (ms: number) => new Promise((resolve) => { - setTimeout(resolve, ms); -}); - -const fetchBridgeGitModelCatalog = async ( - apiUrl: string, - authHeaders?: Record -): Promise> => { - const now = Date.now(); - if (bridgeGitModelCatalogCache && now - bridgeGitModelCatalogCacheAt < BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS) { - return bridgeGitModelCatalogCache; - } - - const headers = authHeaders || {}; - const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`); - const response = await fetch(modelsUrl.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...headers, - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - throw new Error('Failed to fetch model catalog'); - } - - const payload = await response.json().catch(() => null) as unknown; - const refs = new Set(); - if (Array.isArray(payload)) { - for (const item of payload) { - if (!item || typeof item !== 'object') { - continue; - } - const record = item as Record; - const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : ''; - const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : ''; - if (providerID && modelID) { - refs.add(`${providerID}/${modelID}`); - } - } - } - - bridgeGitModelCatalogCache = refs; - bridgeGitModelCatalogCacheAt = now; - return refs; -}; - -const resolveBridgeGitGenerationModel = async ( - payloadModel: { providerId?: string; modelId?: string; zenModel?: string }, - settings: Record, - apiUrl: string, - authHeaders?: Record -): Promise<{ providerID: string; modelID: string }> => { - let catalog: Set | null = null; - try { - catalog = await fetchBridgeGitModelCatalog(apiUrl, authHeaders); - } catch { - catalog = null; - } - - const hasModel = (providerID: string, modelID: string): boolean => { - if (!catalog) { - return false; - } - return catalog.has(`${providerID}/${modelID}`); - }; - - const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : ''; - const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : ''; - if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) { - return { providerID: requestProviderId, modelID: requestModelId }; - } - - const settingsProviderId = readStringField(settings, 'gitProviderId'); - const settingsModelId = readStringField(settings, 'gitModelId'); - if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) { - return { providerID: settingsProviderId, modelID: settingsModelId }; - } - - const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : ''; - const settingsZenModel = readStringField(settings, 'zenModel'); - return { - providerID: 'zen', - modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL, - }; -}; - -const extractTextFromMessageParts = (parts: unknown): string => { - if (!Array.isArray(parts)) { - return ''; - } - - const textParts = parts - .filter((part) => { - if (!part || typeof part !== 'object') return false; - const record = part as Record; - return record.type === 'text' && typeof record.text === 'string'; - }) - .map((part) => (part as Record).text as string) - .map((text) => text.trim()) - .filter((text) => text.length > 0); - - return textParts.join('\n').trim(); -}; - -const generateBridgeTextWithSessionFlow = async ({ - apiUrl, - directory, - prompt, - providerID, - modelID, - authHeaders, -}: { - apiUrl: string; - directory: string; - prompt: string; - providerID: string; - modelID: string; - authHeaders?: Record; -}): Promise => { - const headers = authHeaders || {}; - const apiBase = apiUrl.replace(/\/+$/, ''); - const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS; - const remainingMs = () => Math.max(1_000, deadlineAt - Date.now()); - let sessionId: string | null = null; - - try { - const sessionUrl = new URL(`${apiBase}/session`); - if (directory) { - sessionUrl.searchParams.set('directory', directory); - } - - const createResponse = await fetch(sessionUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: JSON.stringify({ title: 'Git Generation' }), - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!createResponse.ok) { - throw new Error('Failed to create OpenCode session'); - } - - const session = await createResponse.json().catch(() => null) as unknown; - const sessionObj = session && typeof session === 'object' ? session as Record : null; - const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : ''; - if (!createdSessionId) { - throw new Error('Invalid session response'); - } - sessionId = createdSessionId; - - const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`); - if (directory) { - promptUrl.searchParams.set('directory', directory); - } - - const promptResponse = await fetch(promptUrl.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: JSON.stringify({ - model: { - providerID, - modelID, - }, - parts: [{ type: 'text', text: prompt }], - }), - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!promptResponse.ok) { - throw new Error('Failed to send prompt'); - } - - const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`); - if (directory) { - messagesUrl.searchParams.set('directory', directory); - } - messagesUrl.searchParams.set('limit', '10'); - - while (Date.now() < deadlineAt) { - await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS); - - const messagesResponse = await fetch(messagesUrl.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...headers, - }, - signal: AbortSignal.timeout(remainingMs()), - }); - - if (!messagesResponse.ok) { - continue; - } - - const messages = await messagesResponse.json().catch(() => null) as unknown; - if (!Array.isArray(messages)) { - continue; - } - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] as Record | null; - if (!message || typeof message !== 'object') { - continue; - } - const info = message.info as Record | undefined; - if (info?.role !== 'assistant' || info?.finish !== 'stop') { - continue; - } - - const text = extractTextFromMessageParts(message.parts); - if (text) { - return text; - } - } - } - - throw new Error('Timeout waiting for generation to complete'); - } finally { - if (sessionId) { - const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`); - try { - await fetch(deleteUrl.toString(), { - method: 'DELETE', - headers, - signal: AbortSignal.timeout(5_000), - }); - } catch { - // ignore cleanup failures - } - } - } -}; - -const parseJsonObjectSafe = (value: string): Record | null => { - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - return parsed as Record; - } catch { - return null; - } -}; - -const persistSettings = async (changes: Record, ctx?: BridgeContext) => { - const current = readSettings(ctx); - const restChanges = { ...(changes || {}) }; - delete restChanges.lastDirectory; - - const keysToClear = new Set(); - - // Normalize empty-string clears to key removal (match web/desktop behavior) - for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) { - const value = restChanges[key]; - if (typeof value === 'string' && value.trim().length === 0) { - keysToClear.add(key); - delete restChanges[key]; - } - } - - if (typeof restChanges.usageAutoRefresh !== 'boolean') { - delete restChanges.usageAutoRefresh; - } - - if (typeof restChanges.usageRefreshIntervalMs === 'number' && Number.isFinite(restChanges.usageRefreshIntervalMs)) { - restChanges.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(restChanges.usageRefreshIntervalMs))); - } else { - delete restChanges.usageRefreshIntervalMs; - } - - const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record; - for (const key of keysToClear) { - delete merged[key]; - } - await ctx?.context?.globalState.update(SETTINGS_KEY, merged); - - if (keysToClear.has('opencodeBinary')) { - await writeSharedSettingsToDisk({ opencodeBinary: '' }); - } else if (typeof restChanges.opencodeBinary === 'string') { - await writeSharedSettingsToDisk({ opencodeBinary: restChanges.opencodeBinary.trim() }); - } - - return merged; -}; - -const normalizeFsPath = (value: string) => value.replace(/\\/g, '/'); - -const isSocketPath = async (candidate: string): Promise => { - if (!candidate) { - return false; - } - try { - const stat = await fs.promises.stat(candidate); - return typeof stat.isSocket === 'function' && stat.isSocket(); - } catch { - return false; - } -}; - -const resolveSshAuthSock = async (): Promise => { - const existing = (process.env.SSH_AUTH_SOCK || '').trim(); - if (existing) { - return existing; - } - - if (process.platform === 'win32') { - return undefined; - } - - const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh'); - if (await isSocketPath(gpgSock)) { - return gpgSock; - } - - const runGpgconf = async (args: string[]): Promise => { - for (const candidate of gpgconfCandidates) { - try { - const { stdout } = await execFileAsync(candidate, args); - return String(stdout || ''); - } catch { - continue; - } - } - return ''; - }; - - const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); - if (candidate && await isSocketPath(candidate)) { - return candidate; - } - - if (candidate) { - await runGpgconf(['--launch', 'gpg-agent']); - const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim(); - if (retried && await isSocketPath(retried)) { - return retried; - } - } - - return undefined; -}; - -const buildGitEnv = async (): Promise => { - const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; - if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) { - const resolved = await resolveSshAuthSock(); - if (resolved) { - env.SSH_AUTH_SOCK = resolved; - } - } - return env; -}; - -const execGit = async (args: string[], cwd: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => { - const env = await buildGitEnv(); - return new Promise((resolve) => { - const proc = spawn('git', args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - env, - windowsHide: true, - }); - - let stdout = ''; - let stderr = ''; - - proc.stdout?.on('data', (data) => { - stdout += data.toString(); - }); - - proc.stderr?.on('data', (data) => { - stderr += data.toString(); - }); - - proc.on('close', (code) => { - resolve({ stdout, stderr, exitCode: code ?? 0 }); - }); - - proc.on('error', (error) => { - resolve({ stdout: '', stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }); - }); - }); -}; - -const gitCheckIgnoreNames = async (cwd: string, names: string[]): Promise> => { - if (names.length === 0) { - return new Set(); - } - - const result = await execGit(['check-ignore', '--', ...names], cwd); - if (result.exitCode !== 0 || !result.stdout) { - return new Set(); - } - - return new Set( - result.stdout - .split('\n') - .map((name: string) => name.trim()) - .filter(Boolean) - ); -}; - -const gitCheckIgnorePaths = async (cwd: string, paths: string[]): Promise> => { - if (paths.length === 0) { - return new Set(); - } - - const result = await execGit(['check-ignore', '--', ...paths], cwd); - if (result.exitCode !== 0 || !result.stdout) { - return new Set(); - } - - return new Set( - result.stdout - .split('\n') - .map((name: string) => name.trim()) - .filter(Boolean) - ); -}; - -const expandTildePath = (value: string) => { - const trimmed = (value || '').trim(); - if (!trimmed) { - return trimmed; - } - - if (trimmed === '~') { - return os.homedir(); - } - - if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { - return path.join(os.homedir(), trimmed.slice(2)); - } - - return trimmed; -}; - -const resolveUserPath = (value: string, baseDirectory: string) => { - const expanded = expandTildePath(value); - if (!expanded) { - return expanded; - } - if (path.isAbsolute(expanded)) { - return expanded; - } - return path.resolve(baseDirectory, expanded); -}; - -const listDirectoryEntries = async (dirPath: string) => { - const uri = vscode.Uri.file(dirPath); - const entries = await vscode.workspace.fs.readDirectory(uri); - return entries.map(([name, fileType]) => ({ - name, - path: normalizeFsPath(vscode.Uri.joinPath(uri, name).fsPath), - isDirectory: fileType === vscode.FileType.Directory, - })); -}; - -const FILE_SEARCH_EXCLUDED_DIRS = new Set([ - 'node_modules', - '.git', - 'dist', - 'build', - '.next', - '.turbo', - '.cache', - 'coverage', - 'tmp', - 'logs', -]); - -const shouldSkipSearchDirectory = (name: string, includeHidden: boolean) => { - if (!name) { - return false; - } - if (!includeHidden && name.startsWith('.')) { - return true; - } - return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); -}; - -/** - * Fuzzy match scoring function. - * Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise. - * Higher scores indicate better matches. - */ -const fuzzyMatchScore = (query: string, candidate: string): number | null => { - if (!query) return 0; - - const q = query.toLowerCase(); - const c = candidate.toLowerCase(); - - // Fast path: exact substring match gets high score - if (c.includes(q)) { - const idx = c.indexOf(q); - let bonus = 0; - if (idx === 0) { - bonus = 20; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - bonus = 15; - } - } - return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); - } - - // Fuzzy match: all query chars must appear in order - let score = 0; - let lastIndex = -1; - let consecutive = 0; - - for (let i = 0; i < q.length; i++) { - const ch = q[i]; - if (!ch || ch === ' ') continue; - - const idx = c.indexOf(ch, lastIndex + 1); - if (idx === -1) { - return null; // No match - } - - const gap = idx - lastIndex - 1; - if (gap === 0) { - consecutive++; - } else { - consecutive = 0; - } - - score += 10; - score += Math.max(0, 18 - idx); // Prefer matches near start - score -= Math.min(gap, 10); // Penalize gaps - - // Bonus for word boundary matches - if (idx === 0) { - score += 12; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - score += 10; - } - } - - score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches - lastIndex = idx; - } - - // Prefer shorter paths - score += Math.max(0, 24 - Math.floor(c.length / 3)); - - return score; -}; - -const searchFilesystemFiles = async ( - rootPath: string, - query: string, - limit: number, - includeHidden: boolean, - respectGitignore: boolean, - timeBudgetMs?: number -) => { - const normalizedQuery = (query || '').trim().toLowerCase(); - const matchAll = normalizedQuery.length === 0; - const deadline = typeof timeBudgetMs === 'number' && timeBudgetMs > 0 ? Date.now() + timeBudgetMs : null; - - const rootUri = vscode.Uri.file(rootPath); - const queue: vscode.Uri[] = [rootUri]; - const visited = new Set([normalizeFsPath(rootUri.fsPath)]); - // Collect more candidates for fuzzy matching, then sort and trim - const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); - const candidates: Array<{ name: string; path: string; relativePath: string; extension?: string; score: number }> = []; - const MAX_CONCURRENCY = 10; - - while (queue.length > 0 && candidates.length < collectLimit) { - if (deadline && Date.now() > deadline) { - break; - } - const batch = queue.splice(0, MAX_CONCURRENCY); - const dirLists = await Promise.all( - batch.map((dir) => Promise.resolve(vscode.workspace.fs.readDirectory(dir)).catch(() => [] as [string, vscode.FileType][])) - ); - - for (let index = 0; index < batch.length; index += 1) { - if (deadline && Date.now() > deadline) { - break; - } - const currentDir = batch[index]; - const dirents = dirLists[index]; - - const ignoredNames = respectGitignore - ? await gitCheckIgnoreNames(normalizeFsPath(currentDir.fsPath), dirents.map(([name]) => name)) - : new Set(); - - for (const [entryName, entryType] of dirents) { - if (!entryName || (!includeHidden && entryName.startsWith('.'))) { - continue; - } - - if (respectGitignore && ignoredNames.has(entryName)) { - continue; - } - - const entryUri = vscode.Uri.joinPath(currentDir, entryName); - const absolute = normalizeFsPath(entryUri.fsPath); - - if (entryType === vscode.FileType.Directory) { - if (shouldSkipSearchDirectory(entryName, includeHidden)) { - continue; - } - if (!visited.has(absolute)) { - visited.add(absolute); - queue.push(entryUri); - } - continue; - } - - if (entryType !== vscode.FileType.File) { - continue; - } - - const relativePath = normalizeFsPath(path.relative(rootPath, absolute) || path.basename(absolute)); - const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; - - if (matchAll) { - candidates.push({ - name: entryName, - path: absolute, - relativePath, - extension, - score: 0, - }); - } else { - // Try fuzzy match against relative path (includes filename) - const score = fuzzyMatchScore(normalizedQuery, relativePath); - if (score !== null) { - candidates.push({ - name: entryName, - path: absolute, - relativePath, - extension, - score, - }); - } - } - - if (candidates.length >= collectLimit) { - queue.length = 0; - break; - } - } - - if (candidates.length >= collectLimit) { - break; - } - } - } - - // Sort by score descending, then by path length, then alphabetically - if (!matchAll) { - candidates.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (a.relativePath.length !== b.relativePath.length) { - return a.relativePath.length - b.relativePath.length; - } - return a.relativePath.localeCompare(b.relativePath); - }); - } - - // Return top results without the score field - return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ - name, - path: filePath, - relativePath, - extension, - })); -}; - -const searchDirectory = async ( - directory: string, - query: string, - limit = 60, - includeHidden = false, - respectGitignore = true -) => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const rootPath = directory - ? resolveUserPath(directory, workspaceRoot) - : vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - if (!rootPath) return []; - - const sanitizedQuery = query?.trim() || ''; - if (!sanitizedQuery) { - return searchFilesystemFiles(rootPath, '', limit, includeHidden, respectGitignore); - } - - const escapeGlob = (value: string) => value - .replace(/[\\{}()?*]/g, '\\$&') - .replace(/\[/g, '\\[') - .replace(/\]/g, '\\]'); - const exclude = '**/{node_modules,.git,dist,build,.next,.turbo,.cache,coverage,tmp,logs}/**'; - const mapResults = (results: vscode.Uri[]) => results.map((file) => { - const absolute = normalizeFsPath(file.fsPath); - const relative = normalizeFsPath(path.relative(rootPath, absolute)); - const name = path.basename(absolute); - return { - name, - path: absolute, - relativePath: relative || name, - extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, - }; - }); - const filterGitIgnored = async (results: vscode.Uri[]) => { - if (!respectGitignore || results.length === 0) { - return results; - } - - const relativePaths = results.map((file) => { - const relative = normalizeFsPath(path.relative(rootPath, file.fsPath)); - return relative || path.basename(file.fsPath); - }); - - const ignored = await gitCheckIgnorePaths(rootPath, relativePaths); - if (ignored.size === 0) { - return results; - } - - return results.filter((_, index) => !ignored.has(relativePaths[index])); - }; - - // Fast-path via VS Code's file index (may be case-sensitive depending on platform/workspace). - try { - const escapedQuery = escapeGlob(sanitizedQuery); - const pattern = `**/*${escapedQuery}*`; - const results = await vscode.workspace.findFiles( - new vscode.RelativePattern(vscode.Uri.file(rootPath), pattern), - exclude, - limit, - ); - - if (Array.isArray(results) && results.length > 0) { - const visible = includeHidden ? results : results.filter((file) => !path.basename(file.fsPath).startsWith('.')); - const filtered = await filterGitIgnored(visible); - if (filtered.length > 0) { - return mapResults(filtered); - } - } - - if (sanitizedQuery.length >= 2 && sanitizedQuery.length <= 32) { - const fuzzyPattern = `**/*${escapedQuery.split('').join('*')}*`; - const fuzzyResults = await vscode.workspace.findFiles( - new vscode.RelativePattern(vscode.Uri.file(rootPath), fuzzyPattern), - exclude, - limit, - ); - - if (Array.isArray(fuzzyResults) && fuzzyResults.length > 0) { - const visible = includeHidden ? fuzzyResults : fuzzyResults.filter((file) => !path.basename(file.fsPath).startsWith('.')); - const filtered = await filterGitIgnored(visible); - if (filtered.length > 0) { - return mapResults(filtered); - } - } - } - } catch { - // Fall through to filesystem traversal. - } - - // Fallback: deterministic, case-insensitive traversal with early-exit at limit. - return searchFilesystemFiles(rootPath, sanitizedQuery, limit, includeHidden, respectGitignore, 1500); -}; - -const fetchModelsMetadata = async () => { - const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined; - try { - const response = await fetch('https://models.dev/api.json', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - throw new Error(`models.dev responded with ${response.status}`); - } - return await response.json(); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -}; - -const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64'); - -const collectHeaders = (headers: Headers): Record => { - const result: Record = {}; - headers.forEach((value, key) => { - result[key] = value; - }); - return result; -}; - -const buildUnavailableApiResponse = (): ApiProxyResponsePayload => { - const body = JSON.stringify({ error: 'OpenCode API unavailable' }); - return { - status: 503, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; -}; - -const sanitizeForwardHeaders = (input: Record | undefined): Record => { - const headers: Record = { ...(input || {}) }; - delete headers['content-length']; - delete headers['host']; - delete headers['connection']; - return headers; -}; - -const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - -const getFsMimeType = (filePath: string): string => { - const ext = path.extname(filePath).toLowerCase(); - const mimeMap: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.txt': 'text/plain; charset=utf-8', - '.md': 'text/markdown; charset=utf-8', - '.markdown': 'text/markdown; charset=utf-8', - '.mmd': 'text/plain; charset=utf-8', - '.mermaid': 'text/plain; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.pdf': 'application/pdf', - }; - return mimeMap[ext] || 'application/octet-stream'; -}; - -type FsReadPathResolution = - | { ok: true; resolvedPath: string } - | { ok: false; status: number; error: string }; - -const resolveFileReadPath = async (targetPath: string): Promise => { - const trimmed = targetPath.trim(); - if (!trimmed) { - return { ok: false, status: 400, error: 'Path is required' }; - } - - const baseRoot = getFsAccessRoot(); - const resolved = resolveUserPath(trimmed, baseRoot); - if (!resolved) { - return { ok: false, status: 400, error: 'Path is required' }; - } - - try { - const [canonicalPath, canonicalBase] = await Promise.all([ - fs.promises.realpath(resolved), - fs.promises.realpath(baseRoot).catch(() => path.resolve(baseRoot)), - ]); - - if (!isPathInside(canonicalPath, canonicalBase)) { - return { ok: false, status: 403, error: 'Access to file denied' }; - } - - return { ok: true, resolvedPath: canonicalPath }; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err?.code === 'ENOENT') { - return { ok: false, status: 404, error: 'File not found' }; - } - return { ok: false, status: 500, error: 'Failed to resolve file path' }; - } -}; - -const buildProxyJsonError = (status: number, error: string): ApiProxyResponsePayload => ({ - status, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(JSON.stringify({ error })), -}); - -const tryHandleLocalFsProxy = async (method: string, requestPath: string): Promise => { - let parsed: URL; - try { - parsed = new URL(requestPath, 'https://openchamber.local'); - } catch { - return buildProxyJsonError(400, 'Invalid request path'); - } - - if (parsed.pathname !== '/api/fs/read' && parsed.pathname !== '/api/fs/raw') { - return null; - } - - if (method !== 'GET' && method !== 'HEAD') { - return buildProxyJsonError(405, 'Method not allowed'); - } - - const targetPath = parsed.searchParams.get('path') || ''; - const resolution = await resolveFileReadPath(targetPath); - if (!resolution.ok) { - return buildProxyJsonError(resolution.status, resolution.error); - } - - try { - const stats = await fs.promises.stat(resolution.resolvedPath); - if (!stats.isFile()) { - return buildProxyJsonError(400, 'Specified path is not a file'); - } - - if (parsed.pathname === '/api/fs/read') { - const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); - return { - status: 200, - headers: { - 'content-type': 'text/plain; charset=utf-8', - 'cache-control': 'no-store', - }, - bodyBase64: base64EncodeUtf8(content), - }; - } - - const raw = await fs.promises.readFile(resolution.resolvedPath); - return { - status: 200, - headers: { - 'content-type': getFsMimeType(resolution.resolvedPath), - 'cache-control': 'no-store', - }, - bodyBase64: Buffer.from(raw).toString('base64'), - }; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err?.code === 'ENOENT') { - return buildProxyJsonError(404, 'File not found'); - } - return buildProxyJsonError(500, 'Unable to read file'); - } -}; export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise { const { id, type, payload } = message; try { + const standardGitResponse = await handleStandardGitBridgeMessage({ id, type, payload }); + if (standardGitResponse) { + return standardGitResponse; + } + const specialGitResponse = await handleSpecialGitBridgeMessage( + { id, type, payload }, + ctx, + { readSettings, execGit } + ); + if (specialGitResponse) { + return specialGitResponse; + } + const fsResponse = await handleFsBridgeMessage( + { id, type, payload }, + { + resolveUserPath, + listDirectoryEntries, + normalizeFsPath, + execGit, + searchDirectory, + resolveFileReadPath, + parseDroppedFileReference, + readUriAsAttachment, + } + ); + if (fsResponse) { + return fsResponse; + } + const configResponse = await handleConfigBridgeMessage( + { id, type, payload }, + ctx, + { + readSettings, + persistSettings, + fetchOpenCodeSkillsFromApi, + clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + ); + if (configResponse) { + return configResponse; + } + const systemResponse = await handleSystemBridgeMessage( + { id, type, payload }, + ctx, + { + resolveUserPath, + fetchModelsMetadata, + updateCheckUrl: UPDATE_CHECK_URL, + clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }, + ); + if (systemResponse) { + return systemResponse; + } + const proxyResponse = await handleProxyBridgeMessage( + { id, type, payload }, + ctx, + { + tryHandleLocalFsProxy, + buildUnavailableApiResponse, + sanitizeForwardHeaders, + collectHeaders, + base64EncodeUtf8, + }, + ); + if (proxyResponse) { + return proxyResponse; + } + switch (type) { - case 'api:proxy': { - const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - const normalizedPath = - typeof requestPath === 'string' && requestPath.trim().length > 0 - ? requestPath.trim().startsWith('/') - ? requestPath.trim() - : `/${requestPath.trim()}` - : '/'; - - const localFsResponse = await tryHandleLocalFsProxy(normalizedMethod, normalizedPath); - if (localFsResponse) { - return { id, type, success: true, data: localFsResponse }; - } - - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - const data = buildUnavailableApiResponse(); - return { id, type, success: true, data }; - } - - const base = `${apiUrl.replace(/\/+$/, '')}/`; - const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); - const requestHeaders: Record = { - ...sanitizeForwardHeaders(headers), - ...ctx?.manager?.getOpenCodeAuthHeaders(), - }; - - // Ensure SSE requests are negotiated correctly. - if (normalizedPath === '/event' || normalizedPath === '/global/event') { - if (!requestHeaders.Accept) { - requestHeaders.Accept = 'text/event-stream'; - } - requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache'; - requestHeaders.Connection = requestHeaders.Connection || 'keep-alive'; - } - - try { - const response = await fetch(targetUrl, { - method: normalizedMethod, - headers: requestHeaders, - body: - typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD' - ? Buffer.from(bodyBase64, 'base64') - : undefined, - }); - - const arrayBuffer = await response.arrayBuffer(); - const data: ApiProxyResponsePayload = { - status: response.status, - headers: collectHeaders(response.headers), - bodyBase64: Buffer.from(arrayBuffer).toString('base64'), - }; - - return { id, type, success: true, data }; - } catch (error) { - const body = JSON.stringify({ - error: error instanceof Error ? error.message : 'Failed to reach OpenCode API', - }); - const data: ApiProxyResponsePayload = { - status: 502, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; - return { id, type, success: true, data }; - } - } - - case 'api:session:message': { - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - const data = buildUnavailableApiResponse(); - return { id, type, success: true, data }; - } - - const { path: requestPath, headers, bodyText } = (payload || {}) as ApiSessionMessageRequestPayload; - const normalizedPath = - typeof requestPath === 'string' && requestPath.trim().length > 0 - ? requestPath.trim().startsWith('/') - ? requestPath.trim() - : `/${requestPath.trim()}` - : '/'; - - if (!/^\/session\/[^/]+\/message(?:\?.*)?$/.test(normalizedPath)) { - const body = JSON.stringify({ error: 'Invalid session message proxy path' }); - const data: ApiProxyResponsePayload = { - status: 400, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; - return { id, type, success: true, data }; - } - - const base = `${apiUrl.replace(/\/+$/, '')}/`; - const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString(); - const requestHeaders: Record = { - ...sanitizeForwardHeaders(headers), - ...ctx?.manager?.getOpenCodeAuthHeaders(), - }; - - try { - const response = await fetch(targetUrl, { - method: 'POST', - headers: requestHeaders, - body: typeof bodyText === 'string' ? bodyText : '', - signal: AbortSignal.timeout(45000), - }); - - const arrayBuffer = await response.arrayBuffer(); - const data: ApiProxyResponsePayload = { - status: response.status, - headers: collectHeaders(response.headers), - bodyBase64: Buffer.from(arrayBuffer).toString('base64'), - }; - - return { id, type, success: true, data }; - } catch (error) { - const isTimeout = - error instanceof Error && - ((error as Error & { name?: string }).name === 'TimeoutError' || - (error as Error & { name?: string }).name === 'AbortError'); - const body = JSON.stringify({ - error: isTimeout ? 'OpenCode message forward timed out' : error instanceof Error ? error.message : 'OpenCode message forward failed', - }); - const data: ApiProxyResponsePayload = { - status: isTimeout ? 504 : 503, - headers: { 'content-type': 'application/json' }, - bodyBase64: base64EncodeUtf8(body), - }; - return { id, type, success: true, data }; - } - } - - case 'files:list': { - const { path: dirPath } = payload as { path: string }; - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(dirPath, workspaceRoot); - const uri = vscode.Uri.file(resolvedPath); - const entries = await vscode.workspace.fs.readDirectory(uri); - const result: FileEntry[] = entries.map(([name, fileType]) => ({ - name, - path: vscode.Uri.joinPath(uri, name).fsPath, - isDirectory: fileType === vscode.FileType.Directory, - })); - return { id, type, success: true, data: { directory: normalizeFsPath(resolvedPath), entries: result } }; - } - - case 'files:search': { - const { query, maxResults = 50 } = payload as { query: string; maxResults?: number }; - const pattern = `**/*${query}*`; - const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', maxResults); - const results: FileSearchResult[] = files.map((file) => ({ - path: file.fsPath, - })); - return { id, type, success: true, data: results }; - } - - case 'workspace:folder': { - const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; - return { id, type, success: true, data: { folder } }; - } - - case 'config:get': { - const { key } = payload as { key: string }; - const config = vscode.workspace.getConfiguration('openchamber'); - const value = config.get(key); - return { id, type, success: true, data: { value } }; - } - - case 'api:fs:list': { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const { path: targetPath, respectGitignore } = (payload || {}) as { path?: string; respectGitignore?: boolean }; - const target = targetPath || workspaceRoot; - const resolvedPath = resolveUserPath(target, workspaceRoot) || workspaceRoot; - - const entries = await listDirectoryEntries(resolvedPath); - const normalized = normalizeFsPath(resolvedPath); - - if (!respectGitignore) { - return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; - } - - const pathsToCheck = entries.map((entry) => entry.name).filter(Boolean); - if (pathsToCheck.length === 0) { - return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; - } - - try { - const result = await execGit(['check-ignore', '--', ...pathsToCheck], normalized); - const ignoredNames = new Set( - result.stdout - .split('\n') - .map((name) => name.trim()) - .filter(Boolean) - ); - - const filteredEntries = entries.filter((entry) => !ignoredNames.has(entry.name)); - return { id, type, success: true, data: { entries: filteredEntries, directory: normalized, path: normalized } }; - } catch { - return { id, type, success: true, data: { entries, directory: normalized, path: normalized } }; - } - } - - case 'api:fs:search': { - const { directory = '', query = '', limit, includeHidden, respectGitignore } = (payload || {}) as { - directory?: string; - query?: string; - limit?: number; - includeHidden?: boolean; - respectGitignore?: boolean; - }; - const files = await searchDirectory(directory, query, limit, Boolean(includeHidden), respectGitignore !== false); - return { id, type, success: true, data: { files } }; - } - - case 'api:fs:mkdir': { - const target = (payload as { path: string })?.path; - if (!target) { - return { id, type, success: false, error: 'Path is required' }; - } - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(target, workspaceRoot); - await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath)); - return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } }; - } - - case 'api:fs/home': { - // Match web/desktop semantics: OS home directory. - return { id, type, success: true, data: { home: normalizeFsPath(os.homedir()) } }; - } - - case 'api:fs:read': { - const target = (payload as { path: string })?.path; - if (!target) { - return { id, type, success: false, error: 'Path is required' }; - } - - const resolution = await resolveFileReadPath(target); - if (!resolution.ok) { - return { id, type, success: false, error: resolution.error }; - } - - try { - const content = await fs.promises.readFile(resolution.resolvedPath, 'utf8'); - return { id, type, success: true, data: { content, path: normalizeFsPath(resolution.resolvedPath) } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to read file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:write': { - const { path: targetPath, content } = (payload as { path: string; content: string }) || {}; - if (!targetPath) { - return { id, type, success: false, error: 'Path is required' }; - } - if (typeof content !== 'string') { - return { id, type, success: false, error: 'Content is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(targetPath, workspaceRoot); - const uri = vscode.Uri.file(resolvedPath); - // Ensure parent directory exists - const parentUri = vscode.Uri.file(path.dirname(resolvedPath)); - try { - await vscode.workspace.fs.createDirectory(parentUri); - } catch { - // Directory may already exist - } - await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); - return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to write file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:delete': { - const targetPath = (payload as { path: string })?.path; - if (!targetPath) { - return { id, type, success: false, error: 'Path is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(targetPath, workspaceRoot); - const uri = vscode.Uri.file(resolvedPath); - await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false }); - return { id, type, success: true, data: { success: true } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to delete file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:rename': { - const { oldPath, newPath } = (payload as { oldPath: string; newPath: string }) || {}; - if (!oldPath) { - return { id, type, success: false, error: 'oldPath is required' }; - } - if (!newPath) { - return { id, type, success: false, error: 'newPath is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedOld = resolveUserPath(oldPath, workspaceRoot); - const resolvedNew = resolveUserPath(newPath, workspaceRoot); - const oldUri = vscode.Uri.file(resolvedOld); - const newUri = vscode.Uri.file(resolvedNew); - await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: false }); - return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedNew) } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to rename file'; - return { id, type, success: false, error: message }; - } - } - - case 'api:fs:exec': { - const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {}; - if (!Array.isArray(commands) || commands.length === 0) { - return { id, type, success: false, error: 'Commands array is required' }; - } - if (!cwd) { - return { id, type, success: false, error: 'Working directory (cwd) is required' }; - } - try { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedCwd = resolveUserPath(cwd, workspaceRoot); - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); - const shellFlag = process.platform === 'win32' ? '/c' : '-c'; - - const augmentedEnv = { - ...process.env, - PATH: process.env.PATH, - }; - - const results: Array<{ - command: string; - success: boolean; - exitCode?: number; - stdout?: string; - stderr?: string; - error?: string; - }> = []; - - for (const cmd of commands) { - if (typeof cmd !== 'string' || !cmd.trim()) { - results.push({ command: cmd, success: false, error: 'Invalid command' }); - continue; - } - try { - // Use async exec to not block the extension host event loop - const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, { - cwd: resolvedCwd, - env: augmentedEnv, - timeout: 300000, // 5 minutes per command - }); - results.push({ - command: cmd, - success: true, - exitCode: 0, - stdout: (stdout || '').trim(), - stderr: (stderr || '').trim(), - }); - } catch (execError) { - const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string }; - results.push({ - command: cmd, - success: false, - exitCode: typeof err.code === 'number' ? err.code : 1, - stdout: (err.stdout || '').trim(), - stderr: (err.stderr || '').trim(), - error: err.message, - }); - } - } - - const allSucceeded = results.every((r) => r.success); - return { id, type, success: true, data: { success: allSucceeded, results } }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to execute commands'; - return { id, type, success: false, error: message }; - } - } - - case 'api:files/pick': { - const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false; - const defaultUri = vscode.workspace.workspaceFolders?.[0]?.uri; - - const picks = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: allowMany, - defaultUri, - openLabel: 'Attach', - }); - - if (!picks || picks.length === 0) { - return { id, type, success: true, data: { files: [], skipped: [] } }; - } - - const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; - const skipped: Array<{ name: string; reason: string }> = []; - - for (const uri of picks) { - const result = await readUriAsAttachment(uri); - if ('file' in result) { - files.push(result.file); - } else { - skipped.push(result.skipped); - } - } - - return { id, type, success: true, data: { files, skipped } }; - } - - case 'api:files/drop': { - const uris = Array.isArray((payload as { uris?: unknown[] })?.uris) - ? (payload as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - : []; - - if (uris.length === 0) { - return { id, type, success: true, data: { files: [], skipped: [] } }; - } - - const files: Array<{ name: string; mimeType: string; size: number; dataUrl: string }> = []; - const skipped: Array<{ name: string; reason: string }> = []; - - const dedupedUris = Array.from(new Set(uris.map((value) => value.trim()))); - - for (const rawUri of dedupedUris) { - const parsed = parseDroppedFileReference(rawUri); - if ('skipped' in parsed) { - skipped.push(parsed.skipped); - continue; - } - - const uri = parsed.uri; - - const name = path.basename(uri.fsPath || uri.path || rawUri); - - const result = await readUriAsAttachment(uri, name); - if ('file' in result) { - files.push(result.file); - } else { - skipped.push(result.skipped); - } - } - - return { id, type, success: true, data: { files, skipped } }; - } - - case 'api:files/save-image': { - const rawFileName = (payload as { fileName?: unknown })?.fileName; - const rawDataUrl = (payload as { dataUrl?: unknown })?.dataUrl; - const dataUrl = typeof rawDataUrl === 'string' ? rawDataUrl.trim() : ''; - if (!dataUrl.startsWith('data:image/')) { - return { id, type, success: false, error: 'Invalid image payload' }; - } - - const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0 - ? rawFileName.trim() - : `message-${Date.now()}.png`; - - const saveUri = await vscode.window.showSaveDialog({ - saveLabel: 'Save image', - defaultUri: vscode.workspace.workspaceFolders?.[0] - ? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName) - : undefined, - filters: { Images: ['png'] }, - }); - - if (!saveUri) { - return { id, type, success: true, data: { saved: false, canceled: true } }; - } - - const commaIndex = dataUrl.indexOf(','); - if (commaIndex === -1) { - return { id, type, success: false, error: 'Invalid image data URL' }; - } - - const base64 = dataUrl.slice(commaIndex + 1); - const bytes = Buffer.from(base64, 'base64'); - await vscode.workspace.fs.writeFile(saveUri, bytes); - - return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } }; - } - - case 'api:config/settings:get': { - const settings = readSettings(ctx); - return { id, type, success: true, data: settings }; - } - - case 'api:config/settings:save': { - const changes = (payload as Record) || {}; - const updated = await persistSettings(changes, ctx); - return { id, type, success: true, data: updated }; - } - - case 'api:github/auth:status': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const list = await readGitHubAuthList(context); - const accounts = list - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - - const stored = list.find((entry) => entry.current) || list[0]; - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false, accounts } }; - } - - try { - const user = await fetchMe(stored.accessToken); - return { id, type, success: true, data: { connected: true, user, scope: stored.scope, accounts } }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - const updatedAccounts = (await readGitHubAuthList(context)) - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - return { id, type, success: true, data: { connected: false, accounts: updatedAccounts } }; - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/auth:start': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const settings = readSettings(ctx); - const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID; - const scopes = readStringField(settings, 'githubScopes') || DEFAULT_GITHUB_SCOPES; - const flow = await startDeviceFlow(clientId, scopes); - return { id, type, success: true, data: flow }; - } - - case 'api:github/auth:complete': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const deviceCode = readStringField(payload, 'deviceCode'); - if (!deviceCode) return { id, type, success: false, error: 'deviceCode is required' }; - - const settings = readSettings(ctx); - const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID; - - const token = await exchangeDeviceCode(clientId, deviceCode); - const tokenRecord = token && typeof token === 'object' ? (token as Record) : null; - const tokenError = typeof tokenRecord?.error === 'string' ? tokenRecord.error : ''; - const tokenErrorDescription = typeof tokenRecord?.error_description === 'string' ? tokenRecord.error_description : ''; - if (tokenError) { - return { - id, - type, - success: true, - data: { - connected: false, - status: tokenError, - error: tokenErrorDescription || tokenError, - }, - }; - } - const accessToken = typeof tokenRecord?.access_token === 'string' ? tokenRecord.access_token : ''; - if (!accessToken) { - return { id, type, success: false, error: 'Missing access_token from GitHub' }; - } - - const user = await fetchMe(accessToken); - await writeGitHubAuth(context, { - accessToken, - scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined, - tokenType: typeof tokenRecord?.token_type === 'string' ? tokenRecord.token_type : undefined, - createdAt: Date.now(), - user, - }); - - return { - id, - type, - success: true, - data: { - connected: true, - user, - scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined, - }, - }; - } - - case 'api:github/auth:disconnect': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const removed = await clearGitHubAuth(context); - return { id, type, success: true, data: { removed } }; - } - - case 'api:github/auth:activate': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const accountId = readStringField(payload, 'accountId'); - if (!accountId) return { id, type, success: false, error: 'accountId is required' }; - const activated = await activateGitHubAuth(context, accountId); - if (!activated) return { id, type, success: false, error: 'GitHub account not found' }; - const list = await readGitHubAuthList(context); - const accounts = list - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - const stored = list.find((entry) => entry.current) || list[0]; - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false, accounts } }; - } - try { - const user = await fetchMe(stored.accessToken); - return { id, type, success: true, data: { connected: true, user, scope: stored.scope, accounts } }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - const updatedAccounts = (await readGitHubAuthList(context)) - .filter((entry) => entry.user && entry.accountId) - .map((entry) => ({ - id: entry.accountId as string, - user: entry.user, - scope: entry.scope, - current: Boolean(entry.current), - })); - return { id, type, success: true, data: { connected: false, accounts: updatedAccounts } }; - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/me': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - try { - const user = await fetchMe(stored.accessToken); - return { id, type, success: true, data: user }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - return { id, type, success: false, error: 'GitHub token expired or revoked' }; - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:status': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const directory = readStringField(payload, 'directory'); - const branch = readStringField(payload, 'branch'); - if (!directory || !branch) { - return { id, type, success: false, error: 'directory and branch are required' }; - } - - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - - try { - const result = await getPullRequestStatus( - stored.accessToken, - stored.user?.login || null, - directory, - branch, - ); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:create': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const title = readStringField(payload, 'title'); - const head = readStringField(payload, 'head'); - const base = readStringField(payload, 'base'); - const body = readStringField(payload, 'body'); - const draft = readBooleanField(payload, 'draft'); - if (!directory || !title || !head || !base) { - return { id, type, success: false, error: 'directory, title, head, base are required' }; - } - try { - const pr = await createPullRequest(stored.accessToken, directory, { - directory, - title, - head, - base, - ...(body ? { body } : {}), - ...(typeof draft === 'boolean' ? { draft } : {}), - }); - return { id, type, success: true, data: pr }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:update': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - const title = readStringField(payload, 'title'); - const body = readStringField(payload, 'body'); - if (!directory || !number || !title) { - return { id, type, success: false, error: 'directory, number, title are required' }; - } - try { - const pr = await updatePullRequest(stored.accessToken, directory, { - directory, - number, - title, - ...(typeof body === 'string' ? { body } : {}), - }); - return { id, type, success: true, data: pr }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:merge': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const method = normalizeMergeMethod(readStringField(payload, 'method') || 'merge'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await mergePullRequest(stored.accessToken, directory, { - directory, - number, - method, - }); - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pr:ready': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) return { id, type, success: false, error: 'GitHub not connected' }; - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await markPullRequestReady(stored.accessToken, directory, number); - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined; - const message = error instanceof Error ? error.message : String(error); - if (status === 401 || message === 'unauthorized') { - await clearGitHubAuth(context); - } - return { id, type, success: false, error: message }; - } - } - - case 'api:github/issues:list': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const page = readNumberField(payload, 'page') ?? 1; - if (!directory) { - return { id, type, success: false, error: 'directory is required' }; - } - try { - const result = await listIssues(stored.accessToken, directory, page); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/issues:get': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await getIssue(stored.accessToken, directory, number); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/issues:comments': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await listIssueComments(stored.accessToken, directory, number); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:github/pulls:list': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const page = readNumberField(payload, 'page') ?? 1; - if (!directory) { - return { id, type, success: false, error: 'directory is required' }; - } - try { - const result = await listPullRequests(stored.accessToken, directory, page); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - + case 'api:github/auth:status': + case 'api:github/auth:start': + case 'api:github/auth:complete': + case 'api:github/auth:disconnect': + case 'api:github/auth:activate': + case 'api:github/me': + case 'api:github/pr:status': + case 'api:github/pr:create': + case 'api:github/pr:update': + case 'api:github/pr:merge': + case 'api:github/pr:ready': + case 'api:github/issues:list': + case 'api:github/issues:get': + case 'api:github/issues:comments': + case 'api:github/pulls:list': case 'api:github/pulls:context': { - const context = ctx?.context; - if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; - const stored = await readGitHubAuth(context); - if (!stored?.accessToken) { - return { id, type, success: true, data: { connected: false } }; - } - const directory = readStringField(payload, 'directory'); - const number = readNumberField(payload, 'number') ?? 0; - const includeDiff = readBooleanField(payload, 'includeDiff') ?? false; - const includeCheckDetails = readBooleanField(payload, 'includeCheckDetails') ?? false; - if (!directory || !number) { - return { id, type, success: false, error: 'directory and number are required' }; - } - try { - const result = await getPullRequestContext(stored.accessToken, directory, number, includeDiff, includeCheckDetails); - if (result.connected === false) { - await clearGitHubAuth(context); - } - return { id, type, success: true, data: result }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:config/reload': { - await ctx?.manager?.restart(); - return { id, type, success: true, data: { restarted: true } }; - } - - case 'api:config/agents': { - const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; - const agentName = typeof name === 'string' ? name.trim() : ''; - if (!agentName) { - return { id, type, success: false, error: 'Agent name is required' }; - } - - // Use directory from request if provided, otherwise fall back to workspace - const workingDirectory = (typeof directory === 'string' && directory.trim()) - ? directory.trim() - : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); - - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - if (normalizedMethod === 'GET') { - const sources = getAgentSources(agentName, workingDirectory); - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - return { - id, - type, - success: true, - data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, - }; - } - - if (normalizedMethod === 'POST') { - // Extract scope from body if present - const scopeValue = body?.scope as string | undefined; - const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined; - createAgent(agentName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateAgent(agentName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteAgent(agentName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/commands': { - const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; - const commandName = typeof name === 'string' ? name.trim() : ''; - if (!commandName) { - return { id, type, success: false, error: 'Command name is required' }; - } - - // Use directory from request if provided, otherwise fall back to workspace - const workingDirectory = (typeof directory === 'string' && directory.trim()) - ? directory.trim() - : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); - - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - if (normalizedMethod === 'GET') { - const sources = getCommandSources(commandName, workingDirectory); - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - return { - id, - type, - success: true, - data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists }, - }; - } - - if (normalizedMethod === 'POST') { - // Extract scope from body if present - const scopeValue = body?.scope as string | undefined; - const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined; - createCommand(commandName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateCommand(commandName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteCommand(commandName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/mcp': { - const { method, name, body, directory } = (payload || {}) as { method?: string; name?: string; body?: Record; directory?: string }; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - const mcpName = typeof name === 'string' ? name.trim() : ''; - - const workingDirectory = (typeof directory === 'string' && directory.trim()) - ? directory.trim() - : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); - - if (normalizedMethod === 'GET' && !mcpName) { - const configs = listMcpConfigs(workingDirectory); - return { id, type, success: true, data: configs }; - } - - if (!mcpName) { - return { id, type, success: false, error: 'MCP server name is required' }; - } - - if (normalizedMethod === 'GET') { - const config = getMcpConfig(mcpName, workingDirectory); - if (!config) { - return { id, type, success: false, error: `MCP server "${mcpName}" not found` }; - } - return { id, type, success: true, data: config }; - } - - if (normalizedMethod === 'POST') { - const scope = body?.scope as 'user' | 'project' | undefined; - createMcpConfig(mcpName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" created. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateMcpConfig(mcpName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" updated. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteMcpConfig(mcpName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" deleted. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/skills': { - const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record }; - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - - // LIST all skills (no name provided) - if (!name && normalizedMethod === 'GET') { - const skills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory); - return { id, type, success: true, data: { skills } }; - } - - const skillName = typeof name === 'string' ? name.trim() : ''; - if (!skillName) { - return { id, type, success: false, error: 'Skill name is required' }; - } - - if (normalizedMethod === 'GET') { - const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) - .find((skill) => skill.name === skillName); - const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); - return { - id, - type, - success: true, - data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source }, - }; - } - - if (normalizedMethod === 'POST') { - const scopeValue = body?.scope as string | undefined; - const sourceValue = body?.source as string | undefined; - const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined; - const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode'; - createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record, workingDirectory, scope); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'PATCH') { - updateSkill(skillName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - if (normalizedMethod === 'DELETE') { - deleteSkill(skillName, workingDirectory); - await ctx?.manager?.restart(); - return { - id, - type, - success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }, - }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:config/skills:catalog': { - const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh); - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - const settings = readSettings(ctx); - const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; - - const additionalSources: SkillsCatalogSourceConfig[] = Array.isArray(rawCatalogs) - ? (rawCatalogs - .map((entry) => { - if (!entry || typeof entry !== 'object') return null; - const candidate = entry as Record; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; - const source = typeof candidate.source === 'string' ? candidate.source.trim() : ''; - const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : ''; - if (!id || !label || !source) return null; - const normalized: SkillsCatalogSourceConfig = { - id, - label, - description: source, - source, - ...(subpath ? { defaultSubpath: subpath } : {}), - }; - return normalized; - }) - .filter((v) => v !== null) as SkillsCatalogSourceConfig[]) - : []; - - const installedSkills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined; - const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills); - return { id, type, success: true, data }; - } - - case 'api:config/skills:scan': { - const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string }; - const data = await scanSkillsRepositoryFromGit({ - source: String(body.source || ''), - subpath: body.subpath, - }); - return { id, type, success: true, data }; - } - - case 'api:config/skills:install': { - const body = (payload || {}) as { - source?: string; - subpath?: string; - scope?: 'user' | 'project'; - targetSource?: 'opencode' | 'agents'; - selections?: Array<{ skillDir: string }>; - conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll'; - conflictDecisions?: Record; - }; - - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - const data = await installSkillsFromGit({ - source: String(body.source || ''), - subpath: body.subpath, - scope: body.scope === 'project' ? 'project' : 'user', - targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode', - workingDirectory: body.scope === 'project' ? workingDirectory : undefined, - selections: Array.isArray(body.selections) ? body.selections : [], - conflictPolicy: body.conflictPolicy, - conflictDecisions: body.conflictDecisions, - }); - - if (data.ok) { - const installed = data.installed || []; - const skipped = data.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await ctx?.manager?.restart(); - } - - return { - id, - type, - success: true, - data: { - ok: true, - installed, - skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, - }, - }; - } - - return { id, type, success: true, data }; - } - - case 'api:config/skills/files': { - const { method, name, filePath, content } = (payload || {}) as { - method?: string; - name?: string; - filePath?: string; - content?: string; - }; - const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - const skillName = typeof name === 'string' ? name.trim() : ''; - if (!skillName) { - return { id, type, success: false, error: 'Skill name is required' }; - } - - const relativePath = typeof filePath === 'string' ? filePath.trim() : ''; - if (!relativePath) { - return { id, type, success: false, error: 'File path is required' }; - } - - const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || []) - .find((skill) => skill.name === skillName); - const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null); - if (!sources.md.dir) { - return { id, type, success: false, error: `Skill "${skillName}" not found` }; - } - - const skillDir = sources.md.dir; - const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const fileContent = readSkillSupportingFile(skillDir, relativePath); - if (fileContent === null) { - return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` }; - } - return { id, type, success: true, data: { content: fileContent } }; - } - - if (normalizedMethod === 'PUT') { - writeSkillSupportingFile(skillDir, relativePath, content || ''); - return { id, type, success: true, data: { success: true } }; - } - - if (normalizedMethod === 'DELETE') { - deleteSkillSupportingFile(skillDir, relativePath); - return { id, type, success: true, data: { success: true } }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:opencode/directory': { - const target = (payload as { path?: string })?.path; - if (!target) { - return { id, type, success: false, error: 'Path is required' }; - } - const baseDirectory = - ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir(); - const resolvedPath = resolveUserPath(target, baseDirectory); - const result = await ctx?.manager?.setWorkingDirectory(resolvedPath); - if (!result) { - return { id, type, success: false, error: 'OpenCode manager unavailable' }; - } - return { id, type, success: true, data: result }; - } - - case 'api:models/metadata': { - try { - const data = await fetchModelsMetadata(); - return { id, type, success: true, data }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:openchamber:update-check': { - try { - const body = (payload && typeof payload === 'object' ? payload : {}) as Record; - const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0 - ? body.currentVersion.trim() - : 'unknown'; - const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0 - ? body.instanceMode.trim() - : 'local'; - const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0 - ? body.deviceClass.trim() - : 'desktop'; - const platformRaw = typeof body.platform === 'string' && body.platform.trim().length > 0 - ? body.platform.trim() - : os.platform(); - const archRaw = typeof body.arch === 'string' && body.arch.trim().length > 0 - ? body.arch.trim() - : os.arch(); - const reportUsage = body.reportUsage !== false; - - const installId = getOrCreateInstallId('vscode'); - const requestBody = { - appType: 'vscode', - deviceClass, - platform: mapNodePlatformToApiPlatform(platformRaw), - arch: mapNodeArchToApiArch(archRaw), - channel: 'stable', - currentVersion, - installId, - instanceMode, - reportUsage, - }; - - const response = await fetch(UPDATE_CHECK_URL, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) { - const text = await response.text().catch(() => 'update check failed'); - return { id, type, success: false, error: text || `Update check failed with ${response.status}` }; - } - - const data = await response.json(); - return { id, type, success: true, data }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'editor:openFile': { - const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number }; - try { - const doc = await vscode.workspace.openTextDocument(filePath); - const options: vscode.TextDocumentShowOptions = {}; - if (typeof line === 'number') { - const pos = new vscode.Position(Math.max(0, line - 1), column || 0); - options.selection = new vscode.Range(pos, pos); - } - await vscode.window.showTextDocument(doc, options); - return { id, type, success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'editor:openDiff': { - const { original, modified, label, line, patch } = payload as { - original: string; - modified: string; - label?: string; - line?: number; - patch?: string; - }; - try { - const modifiedUri = vscode.Uri.file(modified); - const modifiedDoc = await vscode.workspace.openTextDocument(modifiedUri); - let originalUri = original ? vscode.Uri.file(original) : modifiedUri; - - if (typeof patch === 'string' && patch.trim().length > 0) { - const originalContent = reconstructOriginalContentFromPatch(modifiedDoc.getText(), patch); - if (typeof originalContent === 'string') { - ensureVirtualDiffProviderRegistered(ctx); - originalUri = createVirtualOriginalDiffUri(modified, originalContent); - } - } - - const leftLabel = original ? path.basename(original) : `${path.basename(modified)} (before)`; - const title = label || `${leftLabel} ↔ ${path.basename(modified)}`; - - await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title); - - if (typeof line === 'number' && Number.isFinite(line)) { - const targetLine = Math.max(0, Math.trunc(line) - 1); - await new Promise((resolve) => setTimeout(resolve, 0)); - const targetEditor = vscode.window.visibleTextEditors.find( - (editor) => editor.document.uri.toString() === modifiedUri.toString(), - ); - if (targetEditor) { - const target = new vscode.Position(targetLine, 0); - targetEditor.selection = new vscode.Selection(target, target); - targetEditor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenter); - } - } - - return { id, type, success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:provider/auth:delete': { - const { providerId, scope } = (payload || {}) as { providerId?: string; scope?: string }; - if (!providerId) { - return { id, type, success: false, error: 'Provider ID is required' }; - } - const normalizedScope = typeof scope === 'string' ? scope : 'auth'; - try { - let removed = false; - if (normalizedScope === 'auth') { - removed = removeProviderAuth(providerId); - } else if (normalizedScope === 'user' || normalizedScope === 'project' || normalizedScope === 'custom') { - removed = removeProviderConfig(providerId, ctx?.manager?.getWorkingDirectory(), normalizedScope); - } else if (normalizedScope === 'all') { - const workingDirectory = ctx?.manager?.getWorkingDirectory(); - const authRemoved = removeProviderAuth(providerId); - const userRemoved = removeProviderConfig(providerId, workingDirectory, 'user'); - const projectRemoved = workingDirectory - ? removeProviderConfig(providerId, workingDirectory, 'project') - : false; - const customRemoved = removeProviderConfig(providerId, workingDirectory, 'custom'); - removed = authRemoved || userRemoved || projectRemoved || customRemoved; - } else { - return { id, type, success: false, error: 'Invalid scope' }; - } - - if (removed) { - await ctx?.manager?.restart(); - } - return { - id, - type, - success: true, - data: { - success: true, - removed, - requiresReload: removed, - message: removed - ? `Provider ${providerId} disconnected successfully. Reloading interface…` - : `Provider ${providerId} was not configured.`, - reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined, - }, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:provider/source:get': { - const { providerId } = (payload || {}) as { providerId?: string }; - if (!providerId) { - return { id, type, success: false, error: 'Provider ID is required' }; - } - try { - const sources = getProviderSources(providerId, ctx?.manager?.getWorkingDirectory()); - const auth = getProviderAuth(providerId); - sources.auth.exists = Boolean(auth); - return { id, type, success: true, data: { providerId, sources } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:quota:providers': { - try { - const providers = listConfiguredQuotaProviders(); - return { id, type, success: true, data: { providers } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'api:quota:get': { - const { providerId } = (payload || {}) as { providerId?: string }; - if (!providerId) { - return { id, type, success: false, error: 'Provider ID is required' }; - } - try { - const result = await fetchQuotaForProvider(providerId); - return { id, type, success: true, data: result }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - - case 'vscode:command': { - const { command, args } = (payload || {}) as { command?: string; args?: unknown[] }; - if (!command) { - return { id, type, success: false, error: 'Command is required' }; - } - try { - const result = await vscode.commands.executeCommand(command, ...(args || [])); - return { id, type, success: true, data: { result } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'vscode:openExternalUrl': { - const { url } = (payload || {}) as { url?: string }; - const target = typeof url === 'string' ? url.trim() : ''; - if (!target) { - return { id, type, success: false, error: 'URL is required' }; - } - try { - await vscode.env.openExternal(vscode.Uri.parse(target)); - return { id, type, success: true, data: { opened: true } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } - } - - case 'notifications:can-notify': { - return { id, type, success: true, data: true }; - } - - case 'notifications:notify': { - const request = (payload || {}) as NotificationsNotifyRequestPayload; - const notification = request.payload || {}; - const title = typeof notification.title === 'string' ? notification.title.trim() : ''; - const body = typeof notification.body === 'string' ? notification.body.trim() : ''; - - const message = title && body - ? `${title}: ${body}` - : title || body; - - if (!message) { - return { id, type, success: true, data: { shown: false } }; - } - - void vscode.window.showInformationMessage(message); - return { id, type, success: true, data: { shown: true } }; - } - - // ============== Git Operations ============== - - case 'api:git/check': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const isRepo = await gitService.checkIsGitRepository(directory); - return { id, type, success: true, data: isRepo }; - } - - case 'api:git/worktree-type': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const isLinked = await gitService.isLinkedWorktree(directory); - return { id, type, success: true, data: isLinked }; - } - - case 'api:git/status': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const status = await gitService.getGitStatus(directory); - return { id, type, success: true, data: status }; - } - - case 'api:git/branches': { - const { directory, method, name, startPoint, force } = (payload || {}) as { - directory?: string; - method?: string; - name?: string; - startPoint?: string; - force?: boolean; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const branches = await gitService.getGitBranches(directory); - return { id, type, success: true, data: branches }; - } - - if (normalizedMethod === 'POST') { - if (!name) { - return { id, type, success: false, error: 'Branch name is required' }; - } - const result = await gitService.createBranch(directory, name, startPoint); - return { id, type, success: true, data: result }; - } - - if (normalizedMethod === 'DELETE') { - if (!name) { - return { id, type, success: false, error: 'Branch name is required' }; - } - const result = await gitService.deleteGitBranch(directory, name, force); - return { id, type, success: true, data: result }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/remote-branches': { - const { directory, branch, remote } = (payload || {}) as { - directory?: string; - branch?: string; - remote?: string; - }; - if (!directory || !branch) { - return { id, type, success: false, error: 'Directory and branch are required' }; - } - const result = await gitService.deleteRemoteBranch(directory, branch, remote); - return { id, type, success: true, data: result }; - } - - case 'api:git/checkout': { - const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; - if (!directory || !branch) { - return { id, type, success: false, error: 'Directory and branch are required' }; - } - const result = await gitService.checkoutBranch(directory, branch); - return { id, type, success: true, data: result }; - } - - case 'api:git/worktrees': { - const { directory, method } = (payload || {}) as { - directory?: string; - method?: string; - body?: unknown; - directoryPath?: string; - deleteLocalBranch?: boolean; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const worktrees = await gitService.listGitWorktrees(directory); - return { id, type, success: true, data: worktrees }; - } - - if (normalizedMethod === 'POST') { - const created = await gitService.createWorktree(directory, (payload || {}) as gitService.CreateGitWorktreePayload); - return { id, type, success: true, data: created }; - } - - if (normalizedMethod === 'DELETE') { - const removePayload = payload as { - body?: { directory?: string; deleteLocalBranch?: boolean }; - directory?: string; - deleteLocalBranch?: boolean; - }; - const bodyDirectory = typeof removePayload?.body?.directory === 'string' - ? removePayload.body.directory - : ''; - const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : ''; - const worktreeDirectory = bodyDirectory || legacyDirectory || ''; - - if (!worktreeDirectory) { - return { id, type, success: false, error: 'Worktree directory is required' }; - } - const removed = await gitService.removeWorktree(directory, { - directory: worktreeDirectory, - deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true, - }); - return { id, type, success: true, data: { success: Boolean(removed) } }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/worktrees/validate': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.validateWorktreeCreate(directory, (payload || {}) as gitService.CreateGitWorktreePayload); - return { id, type, success: true, data: result }; - } - - case 'api:git/worktrees/bootstrap-status': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.getWorktreeBootstrapStatus(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/worktrees/preview': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.previewWorktreeCreate(directory, (payload || {}) as gitService.CreateGitWorktreePayload); - return { id, type, success: true, data: result }; - } - - case 'api:git/diff': { - const { directory, path: filePath, staged, contextLines } = (payload || {}) as { - directory?: string; - path?: string; - staged?: boolean; - contextLines?: number; - }; - if (!directory || !filePath) { - return { id, type, success: false, error: 'Directory and path are required' }; - } - const result = await gitService.getGitDiff(directory, filePath, staged, contextLines); - return { id, type, success: true, data: result }; - } - - case 'api:git/file-diff': { - const { directory, path: filePath, staged } = (payload || {}) as { - directory?: string; - path?: string; - staged?: boolean; - }; - if (!directory || !filePath) { - return { id, type, success: false, error: 'Directory and path are required' }; - } - const result = await gitService.getGitFileDiff(directory, filePath, staged); - return { id, type, success: true, data: result }; - } - - case 'api:git/revert': { - const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string }; - if (!directory || !filePath) { - return { id, type, success: false, error: 'Directory and path are required' }; - } - await gitService.revertGitFile(directory, filePath); - return { id, type, success: true, data: { success: true } }; - } - - case 'api:git/commit': { - const { directory, message, addAll, files } = (payload || {}) as { - directory?: string; - message?: string; - addAll?: boolean; - files?: string[]; - }; - if (!directory || !message) { - return { id, type, success: false, error: 'Directory and message are required' }; - } - const result = await gitService.createGitCommit(directory, message, { addAll, files }); - return { id, type, success: true, data: result }; - } - - case 'api:git/push': { - const { directory, remote, branch, options } = (payload || {}) as { - directory?: string; - remote?: string; - branch?: string; - options?: string[] | Record; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.gitPush(directory, { remote, branch, options }); - return { id, type, success: true, data: result }; - } - - case 'api:git/pull': { - const { directory, remote, branch } = (payload || {}) as { - directory?: string; - remote?: string; - branch?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.gitPull(directory, { remote, branch }); - return { id, type, success: true, data: result }; - } - - case 'api:git/fetch': { - const { directory, remote, branch } = (payload || {}) as { - directory?: string; - remote?: string; - branch?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.gitFetch(directory, { remote, branch }); - return { id, type, success: true, data: result }; - } - - case 'api:git/remotes': { - const { directory, method, remote } = (payload || {}) as { - directory?: string; - method?: string; - remote?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - if (normalizedMethod === 'GET') { - const result = await gitService.getRemotes(directory); - return { id, type, success: true, data: result }; - } - - if (normalizedMethod === 'DELETE') { - if (!remote) { - return { id, type, success: false, error: 'Remote name is required' }; - } - const result = await gitService.removeRemote(directory, remote); - return { id, type, success: true, data: result }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/rebase': { - const { directory, onto } = (payload || {}) as { directory?: string; onto?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - if (!onto) { - return { id, type, success: false, error: 'onto is required' }; - } - const result = await gitService.rebase(directory, { onto }); - return { id, type, success: true, data: result }; - } - - case 'api:git/rebase/abort': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.abortRebase(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/merge': { - const { directory, branch } = (payload || {}) as { directory?: string; branch?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - if (!branch) { - return { id, type, success: false, error: 'branch is required' }; - } - const result = await gitService.merge(directory, { branch }); - return { id, type, success: true, data: result }; - } - - case 'api:git/merge/abort': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.abortMerge(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/rebase/continue': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.continueRebase(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/merge/continue': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.continueMerge(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/stash': { - const { directory, message, includeUntracked } = (payload || {}) as { - directory?: string; - message?: string; - includeUntracked?: boolean; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.stash(directory, { message, includeUntracked }); - return { id, type, success: true, data: result }; - } - - case 'api:git/stash/pop': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.stashPop(directory); - return { id, type, success: true, data: result }; - } - - case 'api:git/log': { - const { directory, maxCount, from, to, file } = (payload || {}) as { - directory?: string; - maxCount?: number; - from?: string; - to?: string; - file?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - const result = await gitService.getGitLog(directory, { maxCount, from, to, file }); - return { id, type, success: true, data: result }; - } - - case 'api:git/commit-files': { - const { directory, hash } = (payload || {}) as { directory?: string; hash?: string }; - if (!directory || !hash) { - return { id, type, success: false, error: 'Directory and hash are required' }; - } - const result = await gitService.getCommitFiles(directory, hash); - return { id, type, success: true, data: result }; - } - - case 'api:git/pr-description': { - const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as { - directory?: string; - base?: string; - head?: string; - context?: string; - providerId?: string; - modelId?: string; - zenModel?: string; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - if (!base || !head) { - return { id, type, success: false, error: 'base and head are required' }; - } - - // Collect diffs (best-effort) - let files: string[] = []; - try { - const listed = await gitService.getGitRangeFiles(directory, base, head); - files = Array.isArray(listed) ? listed : []; - } catch { - files = []; - } - - if (files.length === 0) { - return { id, type, success: false, error: 'No diffs available for base...head' }; - } - - let diffSummaries = ''; - for (const file of files) { - try { - const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3); - const raw = typeof diff?.diff === 'string' ? diff.diff : ''; - if (!raw.trim()) continue; - diffSummaries += `FILE: ${file}\n${raw}\n\n`; - } catch { - // ignore - } - } - - if (!diffSummaries.trim()) { - return { id, type, success: false, error: 'No diffs available for selected files' }; - } - - const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}${context?.trim() ? `\n- Additional context: ${context.trim()}` : ''}\n\nDiff summary:\n${diffSummaries}`; - - try { - const apiUrl = ctx?.manager?.getApiUrl(); - if (!apiUrl) { - return { id, type, success: false, error: 'OpenCode API unavailable' }; - } - - const settings = readSettings(ctx) as Record; - const { providerID, modelID } = await resolveBridgeGitGenerationModel( - { providerId, modelId, zenModel: payloadZenModel }, - settings, - apiUrl, - ctx?.manager?.getOpenCodeAuthHeaders() - ); - const raw = await generateBridgeTextWithSessionFlow({ - apiUrl, - directory, - prompt, - providerID, - modelID, - authHeaders: ctx?.manager?.getOpenCodeAuthHeaders(), - }); - if (!raw) { - return { id, type, success: false, error: 'No PR description returned by generator' }; - } - - const cleaned = String(raw) - .trim() - .replace(/^```json\s*/i, '') - .replace(/^```\s*/i, '') - .replace(/```\s*$/i, '') - .trim(); - - const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw); - if (parsed) { - const title = typeof parsed.title === 'string' ? parsed.title : ''; - const body = typeof parsed.body === 'string' ? parsed.body : ''; - return { id, type, success: true, data: { title, body } }; - } - - return { id, type, success: true, data: { title: '', body: String(raw) } }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } - } - - case 'api:git/identity': { - const { directory, method, userName, userEmail, sshKey } = (payload || {}) as { - directory?: string; - method?: string; - userName?: string; - userEmail?: string; - sshKey?: string | null; - }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const identity = await gitService.getCurrentGitIdentity(directory); - return { id, type, success: true, data: identity }; - } - - if (normalizedMethod === 'POST') { - if (!userName || !userEmail) { - return { id, type, success: false, error: 'userName and userEmail are required' }; - } - const result = await gitService.setGitIdentity(directory, userName, userEmail, sshKey); - return { id, type, success: true, data: result }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; - } - - case 'api:git/ignore-openchamber': { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - // This is now a no-op since the function was removed with legacy worktree support. - return { id, type, success: true, data: { success: true } }; - } - - case 'api:git/conflict-details': { - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - - try { - // Get git status --porcelain - const statusResult = await execGit(['status', '--porcelain'], directory); - const statusPorcelain = statusResult.stdout; - - // Get unmerged files (files with conflicts) - const unmergedResult = await execGit(['diff', '--name-only', '--diff-filter=U'], directory); - const unmergedFiles = unmergedResult.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); - - // Get current diff - const diffResult = await execGit(['diff'], directory); - const diff = diffResult.stdout; - - // Detect operation type and get head info - let operation: 'merge' | 'rebase' = 'merge'; - let headInfo = ''; - - // Check for MERGE_HEAD (merge in progress) - const mergeHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'], directory); - const mergeHeadExists = mergeHeadResult.exitCode === 0; - - if (mergeHeadExists) { - operation = 'merge'; - const mergeHead = mergeHeadResult.stdout.trim(); - // Try to read MERGE_MSG file - let mergeMsg = ''; - try { - const mergeMsgPath = path.join(directory, '.git', 'MERGE_MSG'); - mergeMsg = await fs.promises.readFile(mergeMsgPath, 'utf8'); - } catch { - // MERGE_MSG may not exist - } - headInfo = `MERGE_HEAD: ${mergeHead}${mergeMsg ? '\n' + mergeMsg : ''}`; - } else { - // Check for REBASE_HEAD (rebase in progress) - const rebaseHeadResult = await execGit(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'], directory); - const rebaseHeadExists = rebaseHeadResult.exitCode === 0; - - if (rebaseHeadExists) { - operation = 'rebase'; - const rebaseHead = rebaseHeadResult.stdout.trim(); - headInfo = `REBASE_HEAD: ${rebaseHead}`; - } - } - - return { - id, - type, - success: true, - data: { - statusPorcelain: statusPorcelain.trim(), - unmergedFiles, - diff: diff.trim(), - headInfo: headInfo.trim(), - operation, - }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: message }; - } + return { id, type, success: false, error: GITHUB_BACKEND_DISABLED_ERROR }; } default: diff --git a/packages/vscode/src/opencode-ready.ts b/packages/vscode/src/opencode-ready.ts new file mode 100644 index 00000000..b7f721da --- /dev/null +++ b/packages/vscode/src/opencode-ready.ts @@ -0,0 +1,59 @@ +import type { OpenCodeManager } from './opencode'; + +export const API_URL_WAIT_TIMEOUT_MS = 30000; + +export async function waitForApiUrl( + manager: OpenCodeManager | undefined, + timeoutMs = API_URL_WAIT_TIMEOUT_MS, +): Promise { + if (!manager) { + return null; + } + + const initialUrl = manager.getApiUrl(); + if (initialUrl) { + return initialUrl; + } + + return new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + let subscription: { dispose(): void } | null = null; + let disposeAfterSubscribe = false; + + const handleStatusChange = () => { + const nextUrl = manager.getApiUrl(); + if (!nextUrl || settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + if (subscription) { + subscription.dispose(); + } else { + disposeAfterSubscribe = true; + } + resolve(nextUrl); + }; + + subscription = manager.onStatusChange(handleStatusChange); + if (disposeAfterSubscribe) { + subscription.dispose(); + return; + } + if (settled) { + return; + } + + timeoutId = setTimeout(() => { + if (settled) { + return; + } + settled = true; + subscription?.dispose(); + resolve(manager.getApiUrl()); + }, timeoutMs); + }); +} diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts index da10c2a1..48bc8696 100644 --- a/packages/vscode/src/sessionActivityWatcher.ts +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -64,6 +64,14 @@ const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void } }; +export const getSessionActivitySnapshot = (): Record => { + const snapshot: Record = {}; + for (const [sessionId, data] of sessionActivityPhases.entries()) { + snapshot[sessionId] = { type: data.phase }; + } + return snapshot; +}; + const deriveSessionActivity = (payload: Record): SessionActivity | null => { if (!payload || typeof payload !== 'object') { return null; diff --git a/packages/vscode/src/sseProxy.ts b/packages/vscode/src/sseProxy.ts index b757a8b4..67826e56 100644 --- a/packages/vscode/src/sseProxy.ts +++ b/packages/vscode/src/sseProxy.ts @@ -1,5 +1,6 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import type { OpenCodeManager } from './opencode'; +import { waitForApiUrl } from './opencode-ready'; type StreamEvent = { data: TData; @@ -55,8 +56,8 @@ const resolveDefaultDirectory = (manager: OpenCodeManager): string => { return manager.getWorkingDirectory() || 'global'; }; -const createAuthedClient = (manager: OpenCodeManager, headers?: Record) => { - const baseUrl = manager.getApiUrl(); +const createAuthedClient = async (manager: OpenCodeManager, headers?: Record) => { + const baseUrl = await waitForApiUrl(manager); if (!baseUrl) { throw new Error('OpenCode API URL not available'); } @@ -98,7 +99,7 @@ export const openSseProxy = async ({ signal, onChunk, }: OpenSseProxyOptions): Promise => { - const client = createAuthedClient(manager, headers); + const client = await createAuthedClient(manager, headers); const { pathname, directory } = normalizeSsePath(path); const resolvedDirectory = directory || resolveDefaultDirectory(manager); diff --git a/packages/vscode/webview/api/streamPerf.ts b/packages/vscode/webview/api/streamPerf.ts new file mode 100644 index 00000000..460702f9 --- /dev/null +++ b/packages/vscode/webview/api/streamPerf.ts @@ -0,0 +1,88 @@ +const STREAM_PERF_STORAGE_KEY = 'openchamber_stream_perf'; + +type PerfCounter = { + count: number; + total: number; + max: number; + last: number; +}; + +type StreamPerfState = { + counters: Map; + startedAt: number; + lastUpdatedAt: number; +}; + +declare global { + interface Window { + __openchamberVsCodeStreamPerfState__?: StreamPerfState; + } +} + +export const vscodeStreamPerfEnabled = (): boolean => { + try { + return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const nowMs = (): number => { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); +}; + +const ensurePerfState = (): StreamPerfState | null => { + if (!vscodeStreamPerfEnabled()) { + return null; + } + + if (!window.__openchamberVsCodeStreamPerfState__) { + const startedAt = Date.now(); + window.__openchamberVsCodeStreamPerfState__ = { + counters: new Map(), + startedAt, + lastUpdatedAt: startedAt, + }; + } + + return window.__openchamberVsCodeStreamPerfState__; +}; + +const updateCounter = (metric: string, amount: number): void => { + const state = ensurePerfState(); + if (!state) { + return; + } + + const bucket = state.counters.get(metric) ?? { count: 0, total: 0, max: 0, last: 0 }; + bucket.count += 1; + bucket.total += amount; + bucket.max = Math.max(bucket.max, amount); + bucket.last = amount; + state.counters.set(metric, bucket); + state.lastUpdatedAt = Date.now(); +}; + +export const vscodeStreamPerfCount = (metric: string, count = 1): void => { + updateCounter(metric, count); +}; + +export const vscodeStreamPerfObserve = (metric: string, value: number): void => { + updateCounter(metric, value); +}; + +export const vscodeStreamPerfMeasure = (metric: string, fn: () => T): T => { + if (!vscodeStreamPerfEnabled()) { + return fn(); + } + + const start = nowMs(); + try { + return fn(); + } finally { + updateCounter(metric, nowMs() - start); + } +}; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 753cd17f..f847af11 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1,5 +1,6 @@ import { createVSCodeAPIs } from './api'; import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; +import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import { buildVSCodeThemeFromPalette, @@ -132,15 +133,27 @@ let bootstrapFailed = false; const recordBootstrapFetch = (pathname: string, ok: boolean) => { if (!pathname.startsWith('/api/')) return; + // Don't mark as failed while still connecting — early 503s are expected + const isConnected = window.__OPENCHAMBER_CONNECTION__?.status === 'connected'; + if (pathname.startsWith('/api/config/providers')) { - if (ok) bootstrapProvidersReady = true; - else bootstrapFailed = true; + if (ok) { + bootstrapProvidersReady = true; + // Reset failed flag — a successful retry supersedes earlier 503s + if (bootstrapAgentsReady || !isConnected) bootstrapFailed = false; + } else if (isConnected) { + bootstrapFailed = true; + } return; } if (pathname === '/api/agent' || pathname.startsWith('/api/agent?')) { - if (ok) bootstrapAgentsReady = true; - else bootstrapFailed = true; + if (ok) { + bootstrapAgentsReady = true; + if (bootstrapProvidersReady || !isConnected) bootstrapFailed = false; + } else if (isConnected) { + bootstrapFailed = true; + } } }; @@ -395,14 +408,20 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const method = ((init?.method || 'GET') as string).toUpperCase(); if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') { - return new Response(JSON.stringify({ - statusSessions: {}, - attentionSessions: {}, - serverTime: Date.now(), - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + const activity = await sendBridgeMessage>('api:session-activity:get') + .catch(() => ({})); + return new Response( + JSON.stringify({ + statusSessions: {}, + attentionSessions: {}, + activitySessions: activity || {}, + serverTime: Date.now(), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); } if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') { @@ -412,6 +431,84 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { }); } + if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') { + const sessionId = normalizedPathname.split('/')[3] || ''; + return new Response( + JSON.stringify({ + success: true, + sessionId, + messageSent: true, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (normalizedPathname === '/api/session-activity' && method === 'GET') { + const activity = await sendBridgeMessage>('api:session-activity:get') + .catch(() => ({})); + return new Response(JSON.stringify(activity || {}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (normalizedPathname === '/api/sessions/status' && method === 'GET') { + return new Response( + JSON.stringify({ + sessions: {}, + serverTime: Date.now(), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (normalizedPathname === '/api/sessions/attention' && method === 'GET') { + return new Response( + JSON.stringify({ + sessions: {}, + serverTime: Date.now(), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (/^\/api\/sessions\/[^/]+\/status$/.test(normalizedPathname) && method === 'GET') { + const sessionId = normalizedPathname.split('/')[3] || ''; + return new Response( + JSON.stringify({ + error: 'Session not found or no state available', + sessionId, + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + if (/^\/api\/sessions\/[^/]+\/attention$/.test(normalizedPathname) && method === 'GET') { + const sessionId = normalizedPathname.split('/')[3] || ''; + return new Response( + JSON.stringify({ + error: 'Session not found or no attention state available', + sessionId, + }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + if (normalizedPathname === '/api/tts/status' && method === 'GET') { return new Response(JSON.stringify({ available: false }), { status: 200, @@ -734,6 +831,16 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } }); } + if (pathname === '/api/config/opencode-resolution' && method === 'GET') { + try { + const data = await sendBridgeMessage('api:config/opencode-resolution:get'); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + if (pathname.startsWith('/api/config/reload')) { await sendBridgeMessage('api:config/reload'); return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -749,6 +856,16 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } } + if (pathname === '/api/zen/models' && method === 'GET') { + try { + const data = await sendBridgeMessage('api:zen:models'); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message, models: [] }), { status: 502, headers: { 'Content-Type': 'application/json' } }); + } + } + if (pathname.startsWith('/api/openchamber/update-check')) { try { const currentVersion = url.searchParams.get('currentVersion') || undefined; @@ -816,8 +933,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') { const providerId = decodeURIComponent(providerAuthMatch[1]); const scope = url.searchParams.get('scope') || 'auth'; + const queryDirectory = url.searchParams.get('directory') || undefined; try { - const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope }); + const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope, directory: queryDirectory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -829,8 +947,9 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/); if (providerSourceMatch && (init?.method || 'GET').toUpperCase() === 'GET') { const providerId = decodeURIComponent(providerSourceMatch[1]); + const queryDirectory = url.searchParams.get('directory') || undefined; try { - const data = await sendBridgeMessage('api:provider/source:get', { providerId }); + const data = await sendBridgeMessage('api:provider/source:get', { providerId, directory: queryDirectory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -877,7 +996,7 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const headers = { ...headersFromRequest, ...headersFromInit }; if (isSseApiPath(targetUrl.pathname)) { - const start = await startSseProxy({ path: suffixPath, headers }); + const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers })); if (!start.streamId) { return new Response(null, { status: start.status || 503, headers: start.headers || {} }); } @@ -894,11 +1013,14 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { if (!msg || msg.streamId !== streamId) return; if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') { + vscodeStreamPerfCount('vscode.webview.sse_chunk'); + vscodeStreamPerfObserve('vscode.webview.sse_chunk_bytes', msg.chunk.length); controller.enqueue(encoder.encode(msg.chunk)); return; } if (msg.type === 'api:sse:end') { + vscodeStreamPerfCount('vscode.webview.sse_end'); unsubscribe?.(); unsubscribe = null; if (typeof msg.error === 'string' && msg.error.length > 0) { @@ -974,11 +1096,9 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { // Listen for addToContext command from extension onCommand('addToContext', (payload) => { const { text } = payload as { text: string }; - - // Import the store dynamically to avoid circular dependencies - import('@/stores/useSessionStore').then(({ useSessionStore }) => { - const store = useSessionStore.getState(); - store.setPendingInputText(text, 'append'); + + import('@/sync/input-store').then(({ useInputStore }) => { + useInputStore.getState().setPendingInputText(text, 'append'); }); }); @@ -997,29 +1117,29 @@ onCommand('addFileMentions', (payload) => { const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' '); - import('@/stores/useSessionStore').then(({ useSessionStore }) => { - const store = useSessionStore.getState(); - store.setPendingInputText(mentionText, 'append-inline'); + import('@/sync/input-store').then(({ useInputStore }) => { + useInputStore.getState().setPendingInputText(mentionText, 'append-inline'); }); }); // Listen for createSessionWithPrompt command from extension (Explain, Improve Code) onCommand('createSessionWithPrompt', (payload) => { const { prompt } = payload as { prompt: string }; - + Promise.all([ - import('@/stores/useSessionStore'), + import('@/sync/session-ui-store'), import('@/stores/useConfigStore'), - ]).then(([{ useSessionStore }, { useConfigStore }]) => { - const sessionStore = useSessionStore.getState(); + import('@/sync/input-store'), + ]).then(([{ useSessionUIStore }, { useConfigStore }, { useInputStore }]) => { + const sessionStore = useSessionUIStore.getState(); const configStore = useConfigStore.getState(); - + // Open a new session draft first sessionStore.openNewSessionDraft(); - + // Get current provider/model/agent configuration const { currentProviderId, currentModelId, currentAgentName } = configStore; - + if (currentProviderId && currentModelId) { // Send the message - this will create the session from the draft and send sessionStore.sendMessage( @@ -1035,16 +1155,15 @@ onCommand('createSessionWithPrompt', (payload) => { }); } else { // If no provider/model configured, just set the text and let user send manually - sessionStore.setPendingInputText(prompt); + useInputStore.getState().setPendingInputText(prompt); } }); }); // Listen for newSession command from extension title bar button onCommand('newSession', () => { - import('@/stores/useSessionStore').then(({ useSessionStore }) => { - const store = useSessionStore.getState(); - store.openNewSessionDraft(); + import('@/sync/session-ui-store').then(({ useSessionUIStore }) => { + useSessionUIStore.getState().openNewSessionDraft(); }); // Also dispatch event to navigate to chat view in VSCodeLayout diff --git a/packages/web/package.json b/packages/web/package.json index 73e96d64..ffde089d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -29,7 +29,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.3.0", + "@opencode-ai/sdk": "^1.3.7", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 6e217c3e..976367a0 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -4,18 +4,15 @@ import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import http from 'http'; import net from 'net'; -import { WebSocketServer } from 'ws'; import { fileURLToPath } from 'url'; import os from 'os'; import crypto from 'crypto'; import { createUiAuth } from './lib/opencode/ui-auth.js'; import { createTunnelAuth } from './lib/opencode/tunnel-auth.js'; -import { - printTunnelWarning, -} from './lib/cloudflare-tunnel.js'; -import { createTunnelService } from './lib/tunnels/index.js'; +import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js'; import { createTunnelProviderRegistry } from './lib/tunnels/registry.js'; import { createCloudflareTunnelProvider } from './lib/tunnels/providers/cloudflare.js'; +import { createRequestSecurityRuntime } from './lib/security/request-security.js'; import { TUNNEL_MODE_MANAGED_LOCAL, TUNNEL_MODE_MANAGED_REMOTE, @@ -29,16 +26,45 @@ import { normalizeTunnelProvider, } from './lib/tunnels/types.js'; import { prepareNotificationLastMessage } from './lib/notifications/index.js'; +import { registerTtsRoutes } from './lib/tts/routes.js'; +import { detectSayTtsCapability } from './lib/tts/capability-runtime.js'; +import { createTerminalRuntime } from './lib/terminal/runtime.js'; +import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js'; +import { createOpenCodeLifecycleRuntime } from './lib/opencode/lifecycle.js'; +import { createOpenCodeEnvRuntime } from './lib/opencode/env-runtime.js'; +import { resolveOpenCodeEnvConfig } from './lib/opencode/env-config.js'; +import { createHmrStateRuntime } from './lib/opencode/hmr-state-runtime.js'; +import { createOpenCodeNetworkRuntime } from './lib/opencode/network-runtime.js'; +import { createOpenCodeAuthStateRuntime } from './lib/opencode/auth-state-runtime.js'; +import { createProjectDirectoryRuntime } from './lib/opencode/project-directory-runtime.js'; +import { createSettingsNormalizationRuntime } from './lib/opencode/settings-normalization-runtime.js'; +import { createSettingsHelpers } from './lib/opencode/settings-helpers.js'; +import { createThemeRuntime } from './lib/opencode/theme-runtime.js'; +import { createFeatureRoutesRuntime } from './lib/opencode/feature-routes-runtime.js'; +import { parseServeCliOptions } from './lib/opencode/cli-options.js'; import { - TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, - TERMINAL_INPUT_WS_PATH, - createTerminalInputWsControlFrame, - isRebindRateLimited, - normalizeTerminalInputWsMessageToText, - parseRequestPathname, - pruneRebindTimestamps, - readTerminalInputWsControlFrame, -} from './lib/terminal/index.js'; + registerAuthAndAccessRoutes, + registerCommonRequestMiddleware, + registerServerStatusRoutes, +} from './lib/opencode/core-routes.js'; +import { registerOpenChamberRoutes } from './lib/opencode/openchamber-routes.js'; +import { createServerUtilsRuntime } from './lib/opencode/server-utils-runtime.js'; +import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js'; +import { createSettingsRuntime } from './lib/opencode/settings-runtime.js'; +import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolution-runtime.js'; +import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js'; +import { createSessionRuntime } from './lib/opencode/session-runtime.js'; +import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; +import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; +import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js'; +import { createStartupPipelineRuntime } from './lib/opencode/startup-pipeline-runtime.js'; +import { runCliEntryIfMain } from './lib/opencode/cli-entry-runtime.js'; +import { registerNotificationRoutes } from './lib/notifications/routes.js'; +import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js'; +import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js'; +import { createPushRuntime } from './lib/notifications/push-runtime.js'; +import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js'; +import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import webPush from 'web-push'; const __filename = fileURLToPath(import.meta.url); @@ -73,1189 +99,70 @@ const OPENCHAMBER_VERSION = (() => { return 'unknown'; })(); const fsPromises = fs.promises; -const FILE_SEARCH_MAX_CONCURRENCY = 5; -const FILE_SEARCH_EXCLUDED_DIRS = new Set([ - 'node_modules', - '.git', - 'dist', - 'build', - '.next', - '.turbo', - '.cache', - 'coverage', - 'tmp', - 'logs' -]); -// Lock to prevent race conditions in persistSettings -let persistSettingsLock = Promise.resolve(); +const settingsNormalizationRuntime = createSettingsNormalizationRuntime({ + os, + path, + processLike: process, + tunnelBootstrapTtlDefaultMs: TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS, + tunnelBootstrapTtlMinMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS, + tunnelBootstrapTtlMaxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS, + tunnelSessionTtlDefaultMs: TUNNEL_SESSION_TTL_DEFAULT_MS, + tunnelSessionTtlMinMs: TUNNEL_SESSION_TTL_MIN_MS, + tunnelSessionTtlMaxMs: TUNNEL_SESSION_TTL_MAX_MS, +}); -const normalizeDirectoryPath = (value) => { - if (typeof value !== 'string') { - return value; - } - - const trimmed = value.trim(); - if (!trimmed) { - return trimmed; - } - - if (trimmed === '~') { - return os.homedir(); - } - - if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { - return path.join(os.homedir(), trimmed.slice(2)); - } - - return trimmed; -}; - -const normalizePathForPersistence = (value) => { - if (typeof value !== 'string') { - return value; - } - - const normalized = normalizeDirectoryPath(value); - if (typeof normalized !== 'string') { - return normalized; - } - - const trimmed = normalized.trim(); - if (!trimmed) { - return trimmed; - } - - if (process.platform !== 'win32') { - return trimmed; - } - - return trimmed.replace(/\//g, '\\'); -}; - -const areStringArraysEqual = (a, b) => { - if (!Array.isArray(a) || !Array.isArray(b)) { - return false; - } - if (a.length !== b.length) { - return false; - } - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) { - return false; - } - } - return true; -}; - -const normalizeSettingsPaths = (input) => { - const settings = input && typeof input === 'object' ? input : {}; - let next = settings; - let changed = false; - - const ensureNext = () => { - if (next === settings) { - next = { ...settings }; - } - }; - - const normalizePathField = (key) => { - if (typeof settings[key] !== 'string' || settings[key].length === 0) { - return; - } - const normalized = normalizePathForPersistence(settings[key]); - if (normalized !== settings[key]) { - ensureNext(); - next[key] = normalized; - changed = true; - } - }; - - const normalizePathArrayField = (key) => { - if (!Array.isArray(settings[key])) { - return; - } - - const normalized = normalizeStringArray( - settings[key] - .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) - .filter((entry) => typeof entry === 'string' && entry.length > 0) - ); - - if (!areStringArraysEqual(normalized, settings[key])) { - ensureNext(); - next[key] = normalized; - changed = true; - } - }; - - normalizePathField('lastDirectory'); - normalizePathField('homeDirectory'); - normalizePathArrayField('approvedDirectories'); - normalizePathArrayField('pinnedDirectories'); - - if (Array.isArray(settings.projects)) { - const normalizedProjects = sanitizeProjects(settings.projects) || []; - if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) { - ensureNext(); - next.projects = normalizedProjects; - changed = true; - } - } - - return { settings: next, changed }; -}; +const normalizeDirectoryPath = (...args) => settingsNormalizationRuntime.normalizeDirectoryPath(...args); +const normalizePathForPersistence = (...args) => settingsNormalizationRuntime.normalizePathForPersistence(...args); +const normalizeSettingsPaths = (...args) => settingsNormalizationRuntime.normalizeSettingsPaths(...args); +const normalizeTunnelBootstrapTtlMs = (...args) => settingsNormalizationRuntime.normalizeTunnelBootstrapTtlMs(...args); +const normalizeTunnelSessionTtlMs = (...args) => settingsNormalizationRuntime.normalizeTunnelSessionTtlMs(...args); +const normalizeManagedRemoteTunnelHostname = (...args) => + settingsNormalizationRuntime.normalizeManagedRemoteTunnelHostname(...args); +const normalizeManagedRemoteTunnelPresets = (...args) => + settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresets(...args); +const normalizeManagedRemoteTunnelPresetTokens = (...args) => + settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresetTokens(...args); +const isUnsafeSkillRelativePath = (...args) => settingsNormalizationRuntime.isUnsafeSkillRelativePath(...args); +const sanitizeTypographySizesPartial = (...args) => + settingsNormalizationRuntime.sanitizeTypographySizesPartial(...args); +const normalizeStringArray = (...args) => settingsNormalizationRuntime.normalizeStringArray(...args); +const sanitizeModelRefs = (...args) => settingsNormalizationRuntime.sanitizeModelRefs(...args); +const sanitizeSkillCatalogs = (...args) => settingsNormalizationRuntime.sanitizeSkillCatalogs(...args); +const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProjects(...args); const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber'); const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes'); const MAX_THEME_JSON_BYTES = 512 * 1024; -const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; -const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value)); - -const normalizeTunnelBootstrapTtlMs = (value) => { - if (value === null) { - return null; - } - if (!Number.isFinite(value)) { - return TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS; - } - return clampNumber(Math.round(value), TUNNEL_BOOTSTRAP_TTL_MIN_MS, TUNNEL_BOOTSTRAP_TTL_MAX_MS); -}; - -const normalizeTunnelSessionTtlMs = (value) => { - if (!Number.isFinite(value)) { - return TUNNEL_SESSION_TTL_DEFAULT_MS; - } - return clampNumber(Math.round(value), TUNNEL_SESSION_TTL_MIN_MS, TUNNEL_SESSION_TTL_MAX_MS); -}; - -const normalizeManagedRemoteTunnelHostname = (value) => { - if (typeof value !== 'string') { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - const parsed = (() => { - try { - if (trimmed.includes('://')) { - return new URL(trimmed); - } - return new URL(`https://${trimmed}`); - } catch { - return null; - } - })(); - - const hostname = parsed?.hostname?.trim().toLowerCase() || ''; - if (!hostname) { - return undefined; - } - return hostname; -}; - -const normalizeManagedRemoteTunnelPresets = (value) => { - if (!Array.isArray(value)) { - return undefined; - } - - const result = []; - const seenIds = new Set(); - const seenHostnames = new Set(); - - for (const entry of value) { - if (!entry || typeof entry !== 'object') continue; - const candidate = entry; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; - const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname); - if (!id || !name || !hostname) continue; - if (seenIds.has(id) || seenHostnames.has(hostname)) continue; - seenIds.add(id); - seenHostnames.add(hostname); - result.push({ id, name, hostname }); - } - - return result; -}; - -const normalizeManagedRemoteTunnelPresetTokens = (value) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - - const result = {}; - for (const [rawId, rawToken] of Object.entries(value)) { - const id = typeof rawId === 'string' ? rawId.trim() : ''; - const token = typeof rawToken === 'string' ? rawToken.trim() : ''; - if (!id || !token) { - continue; - } - result[id] = token; - } - - return Object.keys(result).length > 0 ? result : undefined; -}; - -const isValidThemeColor = (value) => isNonEmptyString(value); - -const normalizeThemeJson = (raw) => { - if (!raw || typeof raw !== 'object') { - return null; - } - - const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null; - const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null; - if (!metadata || !colors) { - return null; - } - - const id = metadata.id; - const name = metadata.name; - const variant = metadata.variant; - if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) { - return null; - } - - const primary = colors.primary; - const surface = colors.surface; - const interactive = colors.interactive; - const status = colors.status; - const syntax = colors.syntax; - const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null; - const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null; - - if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) { - return null; - } - - // Minimal fields required by CSSVariableGenerator and diff/syntax rendering. - const required = [ - primary.base, - primary.foreground, - surface.background, - surface.foreground, - surface.muted, - surface.mutedForeground, - surface.elevated, - surface.elevatedForeground, - surface.subtle, - interactive.border, - interactive.selection, - interactive.selectionForeground, - interactive.focusRing, - interactive.hover, - status.error, - status.errorForeground, - status.errorBackground, - status.errorBorder, - status.warning, - status.warningForeground, - status.warningBackground, - status.warningBorder, - status.success, - status.successForeground, - status.successBackground, - status.successBorder, - status.info, - status.infoForeground, - status.infoBackground, - status.infoBorder, - syntaxBase.background, - syntaxBase.foreground, - syntaxBase.keyword, - syntaxBase.string, - syntaxBase.number, - syntaxBase.function, - syntaxBase.variable, - syntaxBase.type, - syntaxBase.comment, - syntaxBase.operator, - syntaxHighlights.diffAdded, - syntaxHighlights.diffRemoved, - syntaxHighlights.lineNumber, - ]; - - if (!required.every(isValidThemeColor)) { - return null; - } - - const tags = Array.isArray(metadata.tags) - ? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0) - : []; - - return { - ...raw, - metadata: { - ...metadata, - id: id.trim(), - name: name.trim(), - description: typeof metadata.description === 'string' ? metadata.description : '', - version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0', - variant, - tags, - }, - }; -}; - -const readCustomThemesFromDisk = async () => { - try { - const entries = await fsPromises.readdir(OPENCHAMBER_USER_THEMES_DIR, { withFileTypes: true }); - const themes = []; - const seen = new Set(); - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.toLowerCase().endsWith('.json')) continue; - - const filePath = path.join(OPENCHAMBER_USER_THEMES_DIR, entry.name); - try { - const stat = await fsPromises.stat(filePath); - if (!stat.isFile()) continue; - if (stat.size > MAX_THEME_JSON_BYTES) { - console.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`); - continue; - } - - const rawText = await fsPromises.readFile(filePath, 'utf8'); - const parsed = JSON.parse(rawText); - const normalized = normalizeThemeJson(parsed); - if (!normalized) { - console.warn(`[themes] Skip ${entry.name}: invalid theme JSON`); - continue; - } - - const id = normalized.metadata.id; - if (seen.has(id)) { - console.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`); - continue; - } - - seen.add(id); - themes.push(normalized); - } catch (error) { - console.warn(`[themes] Failed to read ${entry.name}:`, error); - } - } - - return themes; - } catch (error) { - // Missing dir is fine. - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return []; - } - console.warn('[themes] Failed to list custom themes dir:', error); - return []; - } -}; - -const isPathWithinRoot = (resolvedPath, rootPath) => { - const resolvedRoot = path.resolve(rootPath || os.homedir()); - const relative = path.relative(resolvedRoot, resolvedPath); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - return false; - } - return true; -}; - -const resolveWorkspacePath = (targetPath, baseDirectory) => { - const normalized = normalizeDirectoryPath(targetPath); - if (!normalized || typeof normalized !== 'string') { - return { ok: false, error: 'Path is required' }; - } - - const resolved = path.resolve(normalized); - const resolvedBase = path.resolve(baseDirectory || os.homedir()); - - if (isPathWithinRoot(resolved, resolvedBase)) { - return { ok: true, base: resolvedBase, resolved }; - } - - // Allow writing OpenChamber per-project config under ~/.config/openchamber. - // LEGACY_PROJECT_CONFIG: migration target root; allowed outside workspace. - if (isPathWithinRoot(resolved, OPENCHAMBER_USER_CONFIG_ROOT)) { - return { ok: true, base: path.resolve(OPENCHAMBER_USER_CONFIG_ROOT), resolved }; - } - - return { ok: false, error: 'Path is outside of active workspace' }; -}; - -const resolveWorkspacePathFromWorktrees = async (targetPath, baseDirectory) => { - const normalized = normalizeDirectoryPath(targetPath); - if (!normalized || typeof normalized !== 'string') { - return { ok: false, error: 'Path is required' }; - } - - const resolved = path.resolve(normalized); - const resolvedBase = path.resolve(baseDirectory || os.homedir()); - - try { - const { getWorktrees } = await import('./lib/git/index.js'); - const worktrees = await getWorktrees(resolvedBase); - - for (const worktree of worktrees) { - const candidatePath = typeof worktree?.path === 'string' - ? worktree.path - : (typeof worktree?.worktree === 'string' ? worktree.worktree : ''); - const candidate = normalizeDirectoryPath(candidatePath); - if (!candidate) { - continue; - } - const candidateResolved = path.resolve(candidate); - if (isPathWithinRoot(resolved, candidateResolved)) { - return { ok: true, base: candidateResolved, resolved }; - } - } - } catch (error) { - console.warn('Failed to resolve worktree roots:', error); - } - - return { ok: false, error: 'Path is outside of active workspace' }; -}; - -const resolveWorkspacePathFromContext = async (req, targetPath) => { - const resolvedProject = await resolveProjectDirectory(req); - if (!resolvedProject.directory) { - return { ok: false, error: resolvedProject.error || 'Active workspace is required' }; - } - - const resolved = resolveWorkspacePath(targetPath, resolvedProject.directory); - if (resolved.ok || resolved.error !== 'Path is outside of active workspace') { - return resolved; - } - - return resolveWorkspacePathFromWorktrees(targetPath, resolvedProject.directory); -}; - - -const normalizeRelativeSearchPath = (rootPath, targetPath) => { - const relative = path.relative(rootPath, targetPath) || path.basename(targetPath); - return relative.split(path.sep).join('/') || targetPath; -}; - -const shouldSkipSearchDirectory = (name, includeHidden) => { - if (!name) { - return false; - } - if (!includeHidden && name.startsWith('.')) { - return true; - } - return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); -}; - -const listDirectoryEntries = async (dirPath) => { - try { - return await fsPromises.readdir(dirPath, { withFileTypes: true }); - } catch { - return []; - } -}; - -/** - * Fuzzy match scoring function. - * Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise. - * Higher scores indicate better matches. - */ -const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => { - if (!normalizedQuery) return 0; - - const q = normalizedQuery; - const c = candidate.toLowerCase(); - - // Fast path: exact substring match gets high score - if (c.includes(q)) { - const idx = c.indexOf(q); - // Bonus for match at start or after word boundary - let bonus = 0; - if (idx === 0) { - bonus = 20; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - bonus = 15; - } - } - return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); - } - - // Fuzzy match: all query chars must appear in order - let score = 0; - let lastIndex = -1; - let consecutive = 0; - - for (let i = 0; i < q.length; i++) { - const ch = q[i]; - if (!ch || ch === ' ') continue; - - const idx = c.indexOf(ch, lastIndex + 1); - if (idx === -1) { - return null; // No match - } - - const gap = idx - lastIndex - 1; - if (gap === 0) { - consecutive++; - } else { - consecutive = 0; - } - - score += 10; - score += Math.max(0, 18 - idx); // Prefer matches near start - score -= Math.min(gap, 10); // Penalize gaps - - // Bonus for word boundary matches - if (idx === 0) { - score += 12; - } else { - const prev = c[idx - 1]; - if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { - score += 10; - } - } - - score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches - lastIndex = idx; - } - - // Prefer shorter paths - score += Math.max(0, 24 - Math.floor(c.length / 3)); - - return score; -}; - -const searchFilesystemFiles = async (rootPath, options) => { - const { limit, query, includeHidden, respectGitignore } = options; - const includeHiddenEntries = Boolean(includeHidden); - const normalizedQuery = query.trim().toLowerCase(); - const matchAll = normalizedQuery.length === 0; - const queue = [rootPath]; - const visited = new Set([rootPath]); - const shouldRespectGitignore = respectGitignore !== false; - // Collect more candidates for fuzzy matching, then sort and trim - const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); - const candidates = []; - - while (queue.length > 0 && candidates.length < collectLimit) { - const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY); - - const dirResults = await Promise.all( - batch.map(async (dir) => { - if (!shouldRespectGitignore) { - return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() }; - } - - try { - const dirents = await listDirectoryEntries(dir); - const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean); - if (pathsToCheck.length === 0) { - return { dir, dirents, ignoredPaths: new Set() }; - } - - const result = await new Promise((resolve) => { - const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { - cwd: dir, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - child.stdout.on('data', (data) => { stdout += data.toString(); }); - child.on('close', () => resolve(stdout)); - child.on('error', () => resolve('')); - }); - - const ignoredNames = new Set( - String(result) - .split('\n') - .map((name) => name.trim()) - .filter(Boolean) - ); - - return { dir, dirents, ignoredPaths: ignoredNames }; - } catch { - return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() }; - } - }) - ); - - for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) { - for (const dirent of dirents) { - const entryName = dirent.name; - if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) { - continue; - } - - if (shouldRespectGitignore && ignoredPaths.has(entryName)) { - continue; - } - - const entryPath = path.join(currentDir, entryName); - - if (dirent.isDirectory()) { - if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) { - continue; - } - if (!visited.has(entryPath)) { - visited.add(entryPath); - queue.push(entryPath); - } - continue; - } - - if (!dirent.isFile()) { - continue; - } - - const relativePath = normalizeRelativeSearchPath(rootPath, entryPath); - const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; - - if (matchAll) { - candidates.push({ - name: entryName, - path: entryPath, - relativePath, - extension, - score: 0 - }); - } else { - // Try fuzzy match against relative path (includes filename) - const score = fuzzyMatchScoreNormalized(normalizedQuery, relativePath); - if (score !== null) { - candidates.push({ - name: entryName, - path: entryPath, - relativePath, - extension, - score - }); - } - } - - if (candidates.length >= collectLimit) { - queue.length = 0; - break; - } - } - - if (candidates.length >= collectLimit) { - break; - } - } - } - - // Sort by score descending, then by path length, then alphabetically - if (!matchAll) { - candidates.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (a.relativePath.length !== b.relativePath.length) { - return a.relativePath.length - b.relativePath.length; - } - return a.relativePath.localeCompare(b.relativePath); - }); - } - - // Return top results without the score field - return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ - name, - path: filePath, - relativePath, - extension - })); -}; - -const createTimeoutSignal = (timeoutMs) => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - return { - signal: controller.signal, - cleanup: () => clearTimeout(timer), - }; -}; - -/** Humanize a project label: replace dashes/underscores with spaces, title-case each word. Mirrors the UI's formatProjectLabel. */ -const formatProjectLabel = (label) => { - if (!label || typeof label !== 'string') return ''; - return label - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, (char) => char.toUpperCase()); -}; - -const resolveNotificationTemplate = (template, variables) => { - if (!template || typeof template !== 'string') return ''; - return template.replace(/\{(\w+)\}/g, (_match, key) => { - const value = variables[key]; - if (value === undefined || value === null) return ''; - return String(value); - }); -}; - -const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => { - if (!resolved) { - return false; - } - - if (typeof template !== 'string') { - return true; - } - - if (template.includes('{last_message}')) { - return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0; - } - - return true; -}; - -const ZEN_DEFAULT_MODEL = 'gpt-5-nano'; - -/** - * Validated fallback zen model determined at startup by checking available free - * models from the zen API. When `null`, startup validation hasn't run yet (or - * failed), so `resolveZenModel` falls back to `ZEN_DEFAULT_MODEL`. - */ -let validatedZenFallback = null; - -/** Cached free zen models response and timestamp (shared by startup + endpoint). */ -let cachedZenModels = null; -let cachedZenModelsTimestamp = 0; -const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -/** - * Fetch free models from the zen API with caching. Returns an array of - * `{ id, owned_by }` objects (may be empty on failure). Results are cached - * for `ZEN_MODELS_CACHE_TTL` ms. - */ -const fetchFreeZenModels = async () => { - const now = Date.now(); - if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) { - return cachedZenModels.models; - } - - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; - try { - const response = await fetch('https://opencode.ai/zen/v1/models', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - throw new Error(`zen/v1/models responded with status ${response.status}`); - } - const data = await response.json(); - const allModels = Array.isArray(data?.data) ? data.data : []; - const freeModels = allModels - .filter((m) => typeof m?.id === 'string' && m.id.endsWith('-free')) - .map((m) => ({ id: m.id, owned_by: m.owned_by })); - - cachedZenModels = { models: freeModels }; - cachedZenModelsTimestamp = Date.now(); - return freeModels; - } finally { - if (timeout) clearTimeout(timeout); - } -}; - -/** - * Resolve the zen model to use. Checks the provided override first, - * then falls back to the stored zenModel setting, then to the validated - * startup fallback, then to the hardcoded default. - */ -const resolveZenModel = async (override) => { - if (typeof override === 'string' && override.trim().length > 0) { - return override.trim(); - } - try { - const settings = await readSettingsFromDisk(); - if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) { - return settings.zenModel.trim(); - } - } catch { - // ignore - } - return validatedZenFallback || ZEN_DEFAULT_MODEL; -}; - -const validateZenModelAtStartup = async () => { - try { - const freeModels = await fetchFreeZenModels(); - const freeModelIds = freeModels.map((m) => m.id); - - if (freeModelIds.length > 0) { - validatedZenFallback = freeModelIds[0]; - - const settings = await readSettingsFromDisk(); - const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; - - if (!storedModel || !freeModelIds.includes(storedModel)) { - const fallback = freeModelIds[0]; - console.log( - storedModel - ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` - : `[zen] No model configured, setting default to "${fallback}"` - ); - await persistSettings({ zenModel: fallback }); - } else { - console.log(`[zen] Stored model "${storedModel}" verified as available`); - } - } else { - console.warn('[zen] No free models returned from API, skipping validation'); - } - } catch (error) { - console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); - } -}; - - -const summarizeText = async (text, targetLength, zenModel) => { - if (!text || typeof text !== 'string' || text.trim().length === 0) return text; - - try { - const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output ONLY the summary text, nothing else.\n\nText:\n${text}`; - - const completionTimeout = createTimeoutSignal(15000); - let response; - try { - response = await fetch('https://opencode.ai/zen/v1/responses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: zenModel || ZEN_DEFAULT_MODEL, - input: [{ role: 'user', content: prompt }], - max_output_tokens: 1000, - stream: false, - reasoning: { effort: 'low' }, - }), - signal: completionTimeout.signal, - }); - } finally { - completionTimeout.cleanup(); - } - - if (!response.ok) return text; - - const data = await response.json(); - const summary = data?.output?.find((item) => item?.type === 'message') - ?.content?.find((item) => item?.type === 'output_text')?.text?.trim(); - - return summary || text; - } catch { - return text; - } -}; - -const NOTIFICATION_BODY_MAX_CHARS = 1000; - -/** - * Extract text from parts array (used when parts are available inline or fetched from API). - */ -const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { - if (!Array.isArray(parts) || parts.length === 0) return ''; - - const textParts = parts - .filter((p) => p && (p.type === 'text' || typeof p.text === 'string' || typeof p.content === 'string')) - .map((p) => p.text || p.content || '') - .filter(Boolean); - - let text = textParts.length > 0 ? textParts.join('\n').trim() : ''; - - // Truncate to prevent oversized notification payloads - if (maxLength > 0 && text.length > maxLength) { - text = text.slice(0, maxLength); - } - - return text; -}; - -/** - * Try to extract message text from the payload itself (fast path). - * Note: message.updated events from the OpenCode SSE stream typically do NOT include - * parts inline — parts are sent via separate message.part.updated events. This function - * is a fast path for the rare case where parts are included. - */ -const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { - const info = payload?.properties?.info; - if (!info) return ''; - - // Try inline parts on info or on properties - const parts = info.parts || payload?.properties?.parts; - const text = extractTextFromParts(parts, maxLength); - if (text) return text; - - // Fallback: try content array (legacy) - const content = info.content; - if (Array.isArray(content)) { - const textContent = content - .filter((c) => c && (c.type === 'text' || typeof c.text === 'string')) - .map((c) => c.text || '') - .filter(Boolean); - if (textContent.length > 0) { - let result = textContent.join('\n').trim(); - if (maxLength > 0 && result.length > maxLength) { - result = result.slice(0, maxLength); - } - return result; - } - } - - return ''; -}; - -/** - * Fetch the last assistant message text from the OpenCode API. - * This is needed because message.updated events don't include parts; - * we must fetch them separately via the session messages endpoint. - */ -const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { - if (!sessionId) return ''; - - try { - // Fetch last few messages to find the one that triggered the notification - const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); - const response = await fetch(`${url}?limit=5`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(3000), - }); - - if (!response.ok) return ''; - - const messages = await response.json().catch(() => null); - if (!Array.isArray(messages)) return ''; - - // Find the specific message by ID, or fall back to the last assistant message - let target = null; - if (messageId) { - target = messages.find((m) => m?.info?.id === messageId && m?.info?.role === 'assistant'); - } - if (!target) { - // Find the last assistant message with finish === 'stop' - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - if (m?.info?.role === 'assistant' && m?.info?.finish === 'stop') { - target = m; - break; - } - } - } - - if (!target || !Array.isArray(target.parts)) return ''; - - return extractTextFromParts(target.parts, maxLength); - } catch { - return ''; - } -}; - -/** - * In-memory cache of session titles populated from SSE session.updated / session.created events. - * This is the preferred source for session titles since it is populated passively and doesn't - * require a separate API call. - */ -const sessionTitleCache = new Map(); - -const cacheSessionTitle = (sessionId, title) => { - if (typeof sessionId === 'string' && sessionId.length > 0 && - typeof title === 'string' && title.length > 0) { - sessionTitleCache.set(sessionId, title); - } -}; - -const getCachedSessionTitle = (sessionId) => { - return sessionTitleCache.get(sessionId) ?? null; -}; - -/** - * Extract and cache session title from session.updated / session.created SSE events. - * Called by the global event watcher to passively maintain the title cache. - */ -const maybeCacheSessionInfoFromEvent = (payload) => { - if (!payload || typeof payload !== 'object') return; - const type = payload.type; - if (type !== 'session.updated' && type !== 'session.created') return; - const info = payload.properties?.info; - if (!info || typeof info !== 'object') return; - const sessionId = info.id; - const title = info.title; - cacheSessionTitle(sessionId, title); - // Also cache parentID from session events to ensure subtask detection works correctly - const parentID = info.parentID; - if (sessionId && parentID !== undefined) { - setCachedSessionParentId(sessionId, parentID); - } -}; - -/** - * Fetch session metadata (title, directory) from the OpenCode API. - * Cached for 60s per session to avoid repeated API calls. - */ -const sessionInfoCache = new Map(); -const SESSION_INFO_CACHE_TTL_MS = 60 * 1000; - -const fetchSessionInfo = async (sessionId) => { - if (!sessionId) return null; - - const cached = sessionInfoCache.get(sessionId); - if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) { - return cached.data; - } - - try { - const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, ''); - const response = await fetch(url, { - method: 'GET', - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(2000), - }); - if (!response.ok) { - console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`); - return null; - } - const data = await response.json().catch(() => null); - if (data && typeof data === 'object') { - sessionInfoCache.set(sessionId, { data, at: Date.now() }); - return data; - } - return null; - } catch (err) { - console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, err?.message || err); - return null; - } -}; - -const buildTemplateVariables = async (payload, sessionId) => { - const info = payload?.properties?.info || {}; - - // Session title — try inline payload, then SSE cache, then API fetch - let sessionTitle = payload?.properties?.sessionTitle || - payload?.properties?.session?.title || - (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') || - ''; - - // Try the SSE-populated session title cache (filled from session.updated / session.created events) - if (!sessionTitle && sessionId) { - const cached = getCachedSessionTitle(sessionId); - if (cached) { - sessionTitle = cached; - } - } - - // Last resort: fetch session info from the API - let sessionInfo = null; - if (!sessionTitle && sessionId) { - sessionInfo = await fetchSessionInfo(sessionId); - if (sessionInfo && typeof sessionInfo.title === 'string') { - sessionTitle = sessionInfo.title; - // Populate the SSE cache so future notifications don't need an API call - cacheSessionTitle(sessionId, sessionTitle); - } - } - - // Agent name from mode or agent field (v2 has both mode and agent) - const agentName = (() => { - const mode = typeof info.agent === 'string' && info.agent.trim().length > 0 - ? info.agent.trim() - : (typeof info.mode === 'string' ? info.mode.trim() : ''); - if (!mode) return 'Agent'; - return mode.split(/[-_\s]+/).filter(Boolean) - .map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(' '); - })(); - - // Model name — v2 has modelID directly on info, v1 user messages nest it under info.model.modelID - const modelName = (() => { - const raw = typeof info.modelID === 'string' ? info.modelID.trim() - : (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : ''); - if (!raw) return 'Assistant'; - return raw.split(/[-_]+/).filter(Boolean) - .map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' '); - })(); - - // Project name, branch, worktree — derived from multiple sources with fallbacks - let projectName = ''; - let branch = ''; - let worktreeDir = ''; - - // 1. Primary source: the message payload's path (always accurate for the session) - const infoPath = info.path; - if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) { - worktreeDir = infoPath.root; - } else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) { - worktreeDir = infoPath.cwd; - } - - // 2. Look up the user-facing project label from stored settings - try { - const settings = await readSettingsFromDisk(); - const projects = Array.isArray(settings.projects) ? settings.projects : []; - - if (worktreeDir) { - // Match the session directory against stored projects to find the label - const normalizedDir = worktreeDir.replace(/\/+$/, ''); - const matchedProject = projects.find((p) => { - if (!p || typeof p.path !== 'string') return false; - return p.path.replace(/\/+$/, '') === normalizedDir; - }); - if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) { - projectName = matchedProject.label.trim(); - } else { - // No label stored — derive from directory name - projectName = normalizedDir.split('/').filter(Boolean).pop() || ''; - } - } else { - // No directory from payload — fall back to active project - const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; - const activeProject = activeId ? projects.find((p) => p && p.id === activeId) : projects[0]; - if (activeProject) { - projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0 - ? activeProject.label.trim() - : typeof activeProject.path === 'string' - ? activeProject.path.split('/').pop() || '' - : ''; - worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : ''; - } - } - } catch { - // Settings read failed — derive from directory if available - if (worktreeDir && !projectName) { - projectName = worktreeDir.split('/').filter(Boolean).pop() || ''; - } - } - - // 3. Get branch from git - if (worktreeDir) { - try { - const { simpleGit } = await import('simple-git'); - const git = simpleGit({ - baseDir: worktreeDir, - spawnOptions: { windowsHide: true }, - binary: resolveGitBinaryForSpawn(), - }); - branch = await Promise.race([ - git.revparse(['--abbrev-ref', 'HEAD']), - new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)), - ]).catch(() => ''); - } catch { - // ignore — git may not be available - } - } - - return { - project_name: formatProjectLabel(projectName), - worktree: worktreeDir, - branch: typeof branch === 'string' ? branch.trim() : '', - session_name: sessionTitle, - agent_name: agentName, - model_name: modelName, - last_message: '', // Populated by caller - session_id: sessionId || '', - }; -}; +const themeRuntime = createThemeRuntime({ + fsPromises, + path, + themesDir: OPENCHAMBER_USER_THEMES_DIR, + maxThemeJsonBytes: MAX_THEME_JSON_BYTES, + logger: console, +}); + +const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromDisk(...args); + +let notificationTemplateRuntime = null; + +const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args); +const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args); +const resolveNotificationTemplate = (...args) => notificationTemplateRuntime.resolveNotificationTemplate(...args); +const shouldApplyResolvedTemplateMessage = (...args) => notificationTemplateRuntime.shouldApplyResolvedTemplateMessage(...args); +const fetchFreeZenModels = (...args) => notificationTemplateRuntime.fetchFreeZenModels(...args); +const resolveZenModel = (...args) => notificationTemplateRuntime.resolveZenModel(...args); +const validateZenModelAtStartup = (...args) => notificationTemplateRuntime.validateZenModelAtStartup(...args); +const summarizeText = (...args) => notificationTemplateRuntime.summarizeText(...args); +const extractTextFromParts = (...args) => notificationTemplateRuntime.extractTextFromParts(...args); +const extractLastMessageText = (...args) => notificationTemplateRuntime.extractLastMessageText(...args); +const fetchLastAssistantMessageText = (...args) => notificationTemplateRuntime.fetchLastAssistantMessageText(...args); +const maybeCacheSessionInfoFromEvent = (...args) => notificationTemplateRuntime.maybeCacheSessionInfoFromEvent(...args); +const buildTemplateVariables = (...args) => notificationTemplateRuntime.buildTemplateVariables(...args); +const getCachedZenModels = (...args) => notificationTemplateRuntime.getCachedZenModels(...args); const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) @@ -1265,2357 +172,148 @@ const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subsc const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json'); const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION = 1; -const PROJECT_ICONS_DIR_PATH = path.join(OPENCHAMBER_DATA_DIR, 'project-icons'); -const PROJECT_ICON_MIME_TO_EXTENSION = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/svg+xml': 'svg', - 'image/webp': 'webp', - 'image/x-icon': 'ico', -}; -const PROJECT_ICON_EXTENSION_TO_MIME = Object.fromEntries( - Object.entries(PROJECT_ICON_MIME_TO_EXTENSION).map(([mime, ext]) => [ext, mime]) -); -const PROJECT_ICON_SUPPORTED_MIMES = new Set(Object.keys(PROJECT_ICON_MIME_TO_EXTENSION)); -const PROJECT_ICON_MAX_BYTES = 5 * 1024 * 1024; -const PROJECT_ICON_THEME_COLORS = { - light: '#111111', - dark: '#f5f5f5', -}; -const PROJECT_ICON_HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/; -const normalizeProjectIconMime = (value) => { - if (typeof value !== 'string') { - return null; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === 'image/jpg') { - return 'image/jpeg'; - } - if (PROJECT_ICON_SUPPORTED_MIMES.has(normalized)) { - return normalized; - } - return null; -}; - -const projectIconBaseName = (projectId) => { - const hash = crypto.createHash('sha1').update(projectId).digest('hex'); - return `project-${hash}`; -}; - -const projectIconPathForMime = (projectId, mime) => { - const normalizedMime = normalizeProjectIconMime(mime); - if (!normalizedMime) { - return null; - } - const ext = PROJECT_ICON_MIME_TO_EXTENSION[normalizedMime]; - return path.join(PROJECT_ICONS_DIR_PATH, `${projectIconBaseName(projectId)}.${ext}`); -}; - -const projectIconPathCandidates = (projectId) => { - const base = projectIconBaseName(projectId); - return Object.values(PROJECT_ICON_MIME_TO_EXTENSION).map((ext) => path.join(PROJECT_ICONS_DIR_PATH, `${base}.${ext}`)); -}; - -const removeProjectIconFiles = async (projectId, keepPath) => { - const candidates = projectIconPathCandidates(projectId); - await Promise.all(candidates.map(async (candidatePath) => { - if (keepPath && candidatePath === keepPath) { - return; - } - try { - await fsPromises.unlink(candidatePath); - } catch (error) { - if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { - throw error; - } - } - })); -}; - -const parseProjectIconDataUrl = (value) => { - if (typeof value !== 'string') { - return { ok: false, error: 'dataUrl is required' }; - } - - const trimmed = value.trim(); - const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i); - if (!match) { - return { ok: false, error: 'Invalid dataUrl format' }; - } - - const mime = normalizeProjectIconMime(match[1]); - if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) { - return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' }; - } - - try { - const base64 = match[2].replace(/\s+/g, ''); - const bytes = Buffer.from(base64, 'base64'); - if (bytes.length === 0) { - return { ok: false, error: 'Icon content is empty' }; - } - if (bytes.length > PROJECT_ICON_MAX_BYTES) { - return { ok: false, error: 'Icon exceeds size limit (5 MB)' }; - } - return { ok: true, mime, bytes }; - } catch { - return { ok: false, error: 'Failed to decode icon data' }; - } -}; - -const normalizeProjectIconThemeVariant = (value) => { - if (typeof value !== 'string') { - return null; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === 'light' || normalized === 'dark') { - return normalized; - } - return null; -}; - -const normalizeProjectIconColor = (value) => { - if (typeof value !== 'string') { - return null; - } - - const normalized = value.trim(); - if (!PROJECT_ICON_HEX_COLOR_PATTERN.test(normalized)) { - return null; - } - return normalized; -}; - -const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => { - if (typeof svgMarkup !== 'string') { - return svgMarkup; - } - - const color = iconColor || PROJECT_ICON_THEME_COLORS[themeVariant]; - if (!color) { - return svgMarkup; - } - - const svgTagIndex = svgMarkup.search(/', svgTagIndex); - if (svgOpenTagEndIndex === -1) { - return svgMarkup; - } - - const overrideStyle = ``; - return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`; -}; - -const findProjectById = (settings, projectId) => { - const projects = sanitizeProjects(settings?.projects) || []; - const index = projects.findIndex((project) => project.id === projectId); - if (index === -1) { - return { projects, index: -1, project: null }; - } - return { projects, index, project: projects[index] }; -}; - -const readSettingsFromDisk = async () => { - try { - const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object') { - return parsed; - } - return {}; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return {}; - } - console.warn('Failed to read settings file:', error); - return {}; - } -}; - -const writeSettingsToDisk = async (settings) => { - try { - await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true }); - await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8'); - } catch (error) { - console.warn('Failed to write settings file:', error); - throw error; - } -}; - -const PUSH_SUBSCRIPTIONS_VERSION = 1; -let persistPushSubscriptionsLock = Promise.resolve(); -let persistManagedRemoteTunnelConfigLock = Promise.resolve(); - -const readPushSubscriptionsFromDisk = async () => { - try { - const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } - if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) { - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } - - const subscriptionsBySession = - parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object' - ? parsed.subscriptionsBySession - : {}; - - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession }; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } - console.warn('Failed to read push subscriptions file:', error); - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; - } -}; - -const writePushSubscriptionsToDisk = async (data) => { - await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); - await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8'); -}; - -const persistPushSubscriptionUpdate = async (mutate) => { - persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => { - await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); - const current = await readPushSubscriptionsFromDisk(); - const next = mutate({ - version: PUSH_SUBSCRIPTIONS_VERSION, - subscriptionsBySession: current.subscriptionsBySession || {}, - }); - await writePushSubscriptionsToDisk(next); - return next; - }); - - return persistPushSubscriptionsLock; -}; - -const sanitizeManagedRemoteTunnelConfigEntries = (value) => { - if (!Array.isArray(value)) { - return []; - } - - const result = []; - const seenIds = new Set(); - const seenHostnames = new Set(); - for (const entry of value) { - if (!entry || typeof entry !== 'object') { - continue; - } - - const id = typeof entry.id === 'string' ? entry.id.trim() : ''; - const name = typeof entry.name === 'string' ? entry.name.trim() : ''; - const hostname = normalizeManagedRemoteTunnelHostname(entry.hostname); - const token = typeof entry.token === 'string' ? entry.token.trim() : ''; - const updatedAt = Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(); - - if (!id || !name || !hostname || !token) { - continue; - } - if (seenIds.has(id) || seenHostnames.has(hostname)) { - continue; - } - - seenIds.add(id); - seenHostnames.add(hostname); - result.push({ id, name, hostname, token, updatedAt }); - } - - return result; -}; - -const migrateManagedRemoteTunnelConfigFromLegacyFile = async () => { - try { - const legacyRaw = await fsPromises.readFile(CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(legacyRaw); - const tunnels = sanitizeManagedRemoteTunnelConfigEntries(parsed?.tunnels); - const migrated = { - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels, - }; - await writeManagedRemoteTunnelConfigToDisk(migrated); - return migrated; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } - console.warn('Failed to migrate legacy named tunnel config file:', error); - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } -}; - -const readManagedRemoteTunnelConfigFromDisk = async () => { - try { - const raw = await fsPromises.readFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } - - const version = parsed.version === CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION - ? CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION - : CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION; - - return { - version, - tunnels: sanitizeManagedRemoteTunnelConfigEntries(parsed.tunnels), - }; - } catch (error) { - if (error && typeof error === 'object' && error.code === 'ENOENT') { - return migrateManagedRemoteTunnelConfigFromLegacyFile(); - } - console.warn('Failed to read managed remote tunnel config file:', error); - return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; - } -}; - -const writeManagedRemoteTunnelConfigToDisk = async (data) => { - await fsPromises.mkdir(path.dirname(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH), { recursive: true }); - await fsPromises.writeFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); -}; - -const updateManagedRemoteTunnelConfig = async (mutate) => { - persistManagedRemoteTunnelConfigLock = persistManagedRemoteTunnelConfigLock.then(async () => { - const current = await readManagedRemoteTunnelConfigFromDisk(); - const next = mutate({ - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: sanitizeManagedRemoteTunnelConfigEntries(current.tunnels), - }); - - await writeManagedRemoteTunnelConfigToDisk({ - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: sanitizeManagedRemoteTunnelConfigEntries(next?.tunnels), - }); - }); - - return persistManagedRemoteTunnelConfigLock; -}; - -const syncManagedRemoteTunnelConfigWithPresets = async (presets) => { - const sanitizedPresets = normalizeManagedRemoteTunnelPresets(presets) || []; - - await updateManagedRemoteTunnelConfig((current) => { - const byId = new Map(current.tunnels.map((entry) => [entry.id, entry])); - const byHostname = new Map(current.tunnels.map((entry) => [entry.hostname, entry])); - - const nextTunnels = []; - for (const preset of sanitizedPresets) { - const existing = byId.get(preset.id) || byHostname.get(preset.hostname) || null; - if (!existing) { - continue; - } - - nextTunnels.push({ - ...existing, - id: preset.id, - name: preset.name, - hostname: preset.hostname, - }); - } - - return { - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: nextTunnels, - }; - }); -}; - -const upsertManagedRemoteTunnelToken = async ({ id, name, hostname, token }) => { - if (typeof id !== 'string' || typeof name !== 'string' || typeof hostname !== 'string' || typeof token !== 'string') { - return; - } - const normalizedId = id.trim(); - const normalizedName = name.trim(); - const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); - const normalizedToken = token.trim(); - if (!normalizedId || !normalizedName || !normalizedHostname || !normalizedToken) { - return; - } - - await updateManagedRemoteTunnelConfig((current) => { - const withoutConflicts = current.tunnels.filter((entry) => entry.id !== normalizedId && entry.hostname !== normalizedHostname); - withoutConflicts.push({ - id: normalizedId, - name: normalizedName, - hostname: normalizedHostname, - token: normalizedToken, - updatedAt: Date.now(), - }); - - return { - version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, - tunnels: withoutConflicts, - }; - }); -}; - -const resolveManagedRemoteTunnelToken = async ({ presetId, hostname }) => { - const normalizedPresetId = typeof presetId === 'string' ? presetId.trim() : ''; - const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); - const config = await readManagedRemoteTunnelConfigFromDisk(); - - if (normalizedPresetId) { - const byId = config.tunnels.find((entry) => entry.id === normalizedPresetId); - if (byId?.token) { - return byId.token; - } - } - - if (normalizedHostname) { - const byHostname = config.tunnels.find((entry) => entry.hostname === normalizedHostname); - if (byHostname?.token) { - return byHostname.token; - } - } - - return ''; -}; - -const resolveDirectoryCandidate = (value) => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeDirectoryPath(trimmed); - return path.resolve(normalized); -}; - -const validateDirectoryPath = async (candidate) => { - const resolved = resolveDirectoryCandidate(candidate); - if (!resolved) { - return { ok: false, error: 'Directory parameter is required' }; - } - try { - const stats = await fsPromises.stat(resolved); - if (!stats.isDirectory()) { - return { ok: false, error: 'Specified path is not a directory' }; - } - return { ok: true, directory: resolved }; - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return { ok: false, error: 'Directory not found' }; - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return { ok: false, error: 'Access to directory denied' }; - } - return { ok: false, error: 'Failed to validate directory' }; - } -}; - -const resolveProjectDirectory = async (req) => { - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requested = headerDirectory || queryDirectory || null; - - if (requested) { - const validated = await validateDirectoryPath(requested); - if (!validated.ok) { - return { directory: null, error: validated.error }; - } - return { directory: validated.directory, error: null }; - } - - const settings = await readSettingsFromDiskMigrated(); - const projects = sanitizeProjects(settings.projects) || []; - if (projects.length === 0) { - return { directory: null, error: 'Directory parameter or active project is required' }; - } - - const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; - const active = projects.find((project) => project.id === activeId) || projects[0]; - if (!active || !active.path) { - return { directory: null, error: 'Directory parameter or active project is required' }; - } - - const validated = await validateDirectoryPath(active.path); - if (!validated.ok) { - return { directory: null, error: validated.error }; - } - - return { directory: validated.directory, error: null }; -}; - -const isUnsafeSkillRelativePath = (value) => { - if (typeof value !== 'string' || value.length === 0) { - return true; - } - - const normalized = value.replace(/\\/g, '/'); - if (path.posix.isAbsolute(normalized)) { - return true; - } - - return normalized.split('/').some((segment) => segment === '..'); -}; - -const resolveOptionalProjectDirectory = async (req) => { - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requested = headerDirectory || queryDirectory || null; - - if (!requested) { - return { directory: null, error: null }; - } - - const validated = await validateDirectoryPath(requested); - if (!validated.ok) { - return { directory: null, error: validated.error }; - } - - return { directory: validated.directory, error: null }; -}; - -const sanitizeTypographySizesPartial = (input) => { - if (!input || typeof input !== 'object') { - return undefined; - } - const candidate = input; - const result = {}; - let populated = false; - - const assign = (key) => { - if (typeof candidate[key] === 'string' && candidate[key].length > 0) { - result[key] = candidate[key]; - populated = true; - } - }; - - assign('markdown'); - assign('code'); - assign('uiHeader'); - assign('uiLabel'); - assign('meta'); - assign('micro'); - - return populated ? result : undefined; -}; - -const normalizeStringArray = (input) => { - if (!Array.isArray(input)) { - return []; - } - return Array.from( - new Set( - input.filter((entry) => typeof entry === 'string' && entry.length > 0) - ) - ); -}; - -const sanitizeModelRefs = (input, limit) => { - if (!Array.isArray(input)) { - return undefined; - } - - const result = []; - const seen = new Set(); - - for (const entry of input) { - if (!entry || typeof entry !== 'object') continue; - const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : ''; - const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : ''; - if (!providerID || !modelID) continue; - const key = `${providerID}/${modelID}`; - if (seen.has(key)) continue; - seen.add(key); - result.push({ providerID, modelID }); - if (result.length >= limit) break; - } - - return result; -}; - -const sanitizeSkillCatalogs = (input) => { - if (!Array.isArray(input)) { - return undefined; - } - - const result = []; - const seen = new Set(); - - for (const entry of input) { - if (!entry || typeof entry !== 'object') continue; - - const id = typeof entry.id === 'string' ? entry.id.trim() : ''; - const label = typeof entry.label === 'string' ? entry.label.trim() : ''; - const source = typeof entry.source === 'string' ? entry.source.trim() : ''; - const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : ''; - const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : ''; - - if (!id || !label || !source) continue; - if (seen.has(id)) continue; - seen.add(id); - - result.push({ - id, - label, - source, - ...(subpath ? { subpath } : {}), - ...(gitIdentityId ? { gitIdentityId } : {}), - }); - } - - return result; -}; - -const sanitizeProjects = (input) => { - if (!Array.isArray(input)) { - return undefined; - } - - const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; - const normalizeIconBackground = (value) => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null; - }; - - const result = []; - const seenIds = new Set(); - const seenPaths = new Set(); - - for (const entry of input) { - if (!entry || typeof entry !== 'object') continue; - - const candidate = entry; - const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; - const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; - const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : ''; - const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : ''; - const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; - const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : ''; - const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object' - ? candidate.iconImage - : null; - const iconBackground = normalizeIconBackground(candidate.iconBackground); - const color = typeof candidate.color === 'string' ? candidate.color.trim() : ''; - const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null; - const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt) - ? Number(candidate.lastOpenedAt) - : null; - - if (!id || !normalizedPath) continue; - if (seenIds.has(id)) continue; - if (seenPaths.has(normalizedPath)) continue; - - seenIds.add(id); - seenPaths.add(normalizedPath); - - const project = { - id, - path: normalizedPath, - ...(label ? { label } : {}), - ...(icon ? { icon } : {}), - ...(iconBackground ? { iconBackground } : {}), - ...(color ? { color } : {}), - ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}), - ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), - }; - - if (candidate.iconImage === null) { - project.iconImage = null; - } else if (iconImage) { - const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : ''; - const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt) - ? Math.max(0, Math.round(iconImage.updatedAt)) - : 0; - const source = iconImage.source === 'custom' || iconImage.source === 'auto' - ? iconImage.source - : null; - if (mime && updatedAt > 0 && source) { - project.iconImage = { mime, updatedAt, source }; - } - } - - if (candidate.iconBackground === null) { - project.iconBackground = null; - } - - if (typeof candidate.sidebarCollapsed === 'boolean') { - project.sidebarCollapsed = candidate.sidebarCollapsed; - } - - result.push(project); - } - - return result; -}; - -const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant'; -const PWA_APP_NAME_MAX_LENGTH = 64; - -const normalizePwaAppName = (value, fallback = '') => { - if (typeof value !== 'string') { - return fallback; - } - const normalized = value.trim().replace(/\s+/g, ' '); - if (!normalized) { - return fallback; - } - return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH); -}; - -const sanitizeSettingsUpdate = (payload) => { - if (!payload || typeof payload !== 'object') { - return {}; - } - - const candidate = payload; - const result = {}; - - if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) { - result.themeId = candidate.themeId; - } - if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) { - result.themeVariant = candidate.themeVariant; - } - if (typeof candidate.useSystemTheme === 'boolean') { - result.useSystemTheme = candidate.useSystemTheme; - } - if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) { - result.lightThemeId = candidate.lightThemeId; - } - if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) { - result.darkThemeId = candidate.darkThemeId; - } - if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) { - result.splashBgLight = candidate.splashBgLight.trim(); - } - if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) { - result.splashFgLight = candidate.splashFgLight.trim(); - } - if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) { - result.splashBgDark = candidate.splashBgDark.trim(); - } - if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) { - result.splashFgDark = candidate.splashFgDark.trim(); - } - if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) { - const normalized = normalizePathForPersistence(candidate.lastDirectory); - if (typeof normalized === 'string' && normalized.length > 0) { - result.lastDirectory = normalized; - } - } - if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { - const normalized = normalizePathForPersistence(candidate.homeDirectory); - if (typeof normalized === 'string' && normalized.length > 0) { - result.homeDirectory = normalized; - } - } - - // Absolute path to the opencode CLI binary (optional override). - // Accept empty-string to clear (we persist an empty string sentinel so the running - // process can reliably drop a previously applied OPENCODE_BINARY override). - if (typeof candidate.opencodeBinary === 'string') { - const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim(); - result.opencodeBinary = normalized; - } - if (Array.isArray(candidate.projects)) { - const projects = sanitizeProjects(candidate.projects); - if (projects) { - result.projects = projects; - } - } - if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { - result.activeProjectId = candidate.activeProjectId; - } - - if (Array.isArray(candidate.approvedDirectories)) { - result.approvedDirectories = normalizeStringArray( - candidate.approvedDirectories - .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) - .filter((entry) => typeof entry === 'string' && entry.length > 0) - ); - } - if (Array.isArray(candidate.securityScopedBookmarks)) { - result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks); - } - if (Array.isArray(candidate.pinnedDirectories)) { - result.pinnedDirectories = normalizeStringArray( - candidate.pinnedDirectories - .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) - .filter((entry) => typeof entry === 'string' && entry.length > 0) - ); - } - - - if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) { - result.uiFont = candidate.uiFont; - } - if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) { - result.monoFont = candidate.monoFont; - } - if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) { - result.markdownDisplayMode = candidate.markdownDisplayMode; - } - if (typeof candidate.githubClientId === 'string') { - const trimmed = candidate.githubClientId.trim(); - if (trimmed.length > 0) { - result.githubClientId = trimmed; - } - } - if (typeof candidate.githubScopes === 'string') { - const trimmed = candidate.githubScopes.trim(); - if (trimmed.length > 0) { - result.githubScopes = trimmed; - } - } - if (typeof candidate.showReasoningTraces === 'boolean') { - result.showReasoningTraces = candidate.showReasoningTraces; - } - if (typeof candidate.showTextJustificationActivity === 'boolean') { - result.showTextJustificationActivity = candidate.showTextJustificationActivity; - } - if (typeof candidate.showDeletionDialog === 'boolean') { - result.showDeletionDialog = candidate.showDeletionDialog; - } - if (typeof candidate.nativeNotificationsEnabled === 'boolean') { - result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled; - } - if (typeof candidate.notificationMode === 'string') { - const mode = candidate.notificationMode.trim(); - if (mode === 'always' || mode === 'hidden-only') { - result.notificationMode = mode; - } - } - if (typeof candidate.notifyOnSubtasks === 'boolean') { - result.notifyOnSubtasks = candidate.notifyOnSubtasks; - } - if (typeof candidate.notifyOnCompletion === 'boolean') { - result.notifyOnCompletion = candidate.notifyOnCompletion; - } - if (typeof candidate.notifyOnError === 'boolean') { - result.notifyOnError = candidate.notifyOnError; - } - if (typeof candidate.notifyOnQuestion === 'boolean') { - result.notifyOnQuestion = candidate.notifyOnQuestion; - } - if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') { - result.notificationTemplates = candidate.notificationTemplates; - } - if (typeof candidate.summarizeLastMessage === 'boolean') { - result.summarizeLastMessage = candidate.summarizeLastMessage; - } - if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) { - result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold)); - } - if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) { - result.summaryLength = Math.max(10, Math.round(candidate.summaryLength)); - } - if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) { - result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength)); - } - if (typeof candidate.usageAutoRefresh === 'boolean') { - result.usageAutoRefresh = candidate.usageAutoRefresh; - } - if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) { - result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs))); - } - if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') { - result.usageDisplayMode = candidate.usageDisplayMode; - } - if (Array.isArray(candidate.usageDropdownProviders)) { - result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders); - } - if (typeof candidate.autoDeleteEnabled === 'boolean') { - result.autoDeleteEnabled = candidate.autoDeleteEnabled; - } - if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { - const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays))); - result.autoDeleteAfterDays = normalizedDays; - } - if (candidate.tunnelBootstrapTtlMs === null) { - result.tunnelBootstrapTtlMs = null; - } else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) { - result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs); - } - if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) { - result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs); - } - if (typeof candidate.tunnelProvider === 'string') { - const provider = normalizeTunnelProvider(candidate.tunnelProvider); - if (provider) { - result.tunnelProvider = provider; - } - } - if (typeof candidate.tunnelMode === 'string') { - result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode); - } - if (candidate.managedLocalTunnelConfigPath === null) { - result.managedLocalTunnelConfigPath = null; - } else if (typeof candidate.managedLocalTunnelConfigPath === 'string') { - const trimmed = candidate.managedLocalTunnelConfigPath.trim(); - result.managedLocalTunnelConfigPath = trimmed.length > 0 ? normalizeOptionalPath(trimmed) : null; - } - if (typeof candidate.managedRemoteTunnelHostname === 'string') { - const hostname = normalizeManagedRemoteTunnelHostname(candidate.managedRemoteTunnelHostname); - result.managedRemoteTunnelHostname = hostname; - } - if (candidate.managedRemoteTunnelToken === null) { - result.managedRemoteTunnelToken = null; - } else if (typeof candidate.managedRemoteTunnelToken === 'string') { - result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim(); - } - const managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets); - if (managedRemoteTunnelPresets) { - result.managedRemoteTunnelPresets = managedRemoteTunnelPresets; - } - const managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens); - if (managedRemoteTunnelPresetTokens) { - result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens; - } - if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') { - const id = candidate.managedRemoteTunnelSelectedPresetId.trim(); - result.managedRemoteTunnelSelectedPresetId = id || undefined; - } - - const typography = sanitizeTypographySizesPartial(candidate.typographySizes); - if (typography) { - result.typographySizes = typography; - } - - if (typeof candidate.defaultModel === 'string') { - const trimmed = candidate.defaultModel.trim(); - result.defaultModel = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.defaultVariant === 'string') { - const trimmed = candidate.defaultVariant.trim(); - result.defaultVariant = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.defaultAgent === 'string') { - const trimmed = candidate.defaultAgent.trim(); - result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.defaultGitIdentityId === 'string') { - const trimmed = candidate.defaultGitIdentityId.trim(); - result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; - } - if (typeof candidate.autoCreateWorktree === 'boolean') { - result.autoCreateWorktree = candidate.autoCreateWorktree; - } - if (typeof candidate.gitmojiEnabled === 'boolean') { - result.gitmojiEnabled = candidate.gitmojiEnabled; - } - if (typeof candidate.zenModel === 'string') { - const trimmed = candidate.zenModel.trim(); - result.zenModel = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.gitProviderId === 'string') { - const trimmed = candidate.gitProviderId.trim(); - result.gitProviderId = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.gitModelId === 'string') { - const trimmed = candidate.gitModelId.trim(); - result.gitModelId = trimmed.length > 0 ? trimmed : undefined; - } - if (typeof candidate.pwaAppName === 'string') { - result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined); - } - if (typeof candidate.toolCallExpansion === 'string') { - const mode = candidate.toolCallExpansion.trim(); - if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') { - result.toolCallExpansion = mode; - } - } - if (typeof candidate.inputSpellcheckEnabled === 'boolean') { - result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; - } - if (typeof candidate.showToolFileIcons === 'boolean') { - result.showToolFileIcons = candidate.showToolFileIcons; - } - if (typeof candidate.showExpandedBashTools === 'boolean') { - result.showExpandedBashTools = candidate.showExpandedBashTools; - } - if (typeof candidate.showExpandedEditTools === 'boolean') { - result.showExpandedEditTools = candidate.showExpandedEditTools; - } - if (typeof candidate.chatRenderMode === 'string') { - const mode = candidate.chatRenderMode.trim(); - if (mode === 'sorted' || mode === 'live') { - result.chatRenderMode = mode; - } - } - if (typeof candidate.activityRenderMode === 'string') { - const mode = candidate.activityRenderMode.trim(); - if (mode === 'collapsed' || mode === 'summary') { - result.activityRenderMode = mode; - } - } - if (typeof candidate.mermaidRenderingMode === 'string') { - const mode = candidate.mermaidRenderingMode.trim(); - if (mode === 'svg' || mode === 'ascii') { - result.mermaidRenderingMode = mode; - } - } - if (typeof candidate.userMessageRenderingMode === 'string') { - const mode = candidate.userMessageRenderingMode.trim(); - if (mode === 'markdown' || mode === 'plain') { - result.userMessageRenderingMode = mode; - } - } - if (typeof candidate.stickyUserHeader === 'boolean') { - result.stickyUserHeader = candidate.stickyUserHeader; - } - if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { - result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize))); - } - if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) { - result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize))); - } - if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) { - result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding))); - } - if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) { - result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius))); - } - if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { - result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset))); - } - - const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); - if (favoriteModels) { - result.favoriteModels = favoriteModels; - } - - const recentModels = sanitizeModelRefs(candidate.recentModels, 16); - if (recentModels) { - result.recentModels = recentModels; - } - if (typeof candidate.diffLayoutPreference === 'string') { - const mode = candidate.diffLayoutPreference.trim(); - if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') { - result.diffLayoutPreference = mode; - } - } - if (typeof candidate.diffViewMode === 'string') { - const mode = candidate.diffViewMode.trim(); - if (mode === 'single' || mode === 'stacked') { - result.diffViewMode = mode; - } - } - if (typeof candidate.directoryShowHidden === 'boolean') { - result.directoryShowHidden = candidate.directoryShowHidden; - } - if (typeof candidate.filesViewShowGitignored === 'boolean') { - result.filesViewShowGitignored = candidate.filesViewShowGitignored; - } - if (typeof candidate.openInAppId === 'string') { - const trimmed = candidate.openInAppId.trim(); - if (trimmed.length > 0) { - result.openInAppId = trimmed; - } - } - - // Message limit — single setting for fetch / trim / Load More chunk - if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) { - result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit))); - } - - const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); - if (skillCatalogs) { - result.skillCatalogs = skillCatalogs; - } - - // Usage model selections - which models appear in dropdown - if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') { - const sanitized = {}; - for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) { - if (typeof providerId === 'string' && Array.isArray(models)) { - const validModels = models.filter((m) => typeof m === 'string' && m.length > 0); - if (validModels.length > 0) { - sanitized[providerId] = validModels; - } - } - } - if (Object.keys(sanitized).length > 0) { - result.usageSelectedModels = sanitized; - } - } - - // Usage page collapsed families - for "Other Models" section - if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') { - const sanitized = {}; - for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) { - if (typeof providerId === 'string' && Array.isArray(families)) { - const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); - if (validFamilies.length > 0) { - sanitized[providerId] = validFamilies; - } - } - } - if (Object.keys(sanitized).length > 0) { - result.usageCollapsedFamilies = sanitized; - } - } - - // Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed) - if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') { - const sanitized = {}; - for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) { - if (typeof providerId === 'string' && Array.isArray(families)) { - const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); - if (validFamilies.length > 0) { - sanitized[providerId] = validFamilies; - } - } - } - if (Object.keys(sanitized).length > 0) { - result.usageExpandedFamilies = sanitized; - } - } - - // Custom model groups configuration - if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') { - const sanitized = {}; - for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) { - if (typeof providerId !== 'string') continue; - - const providerConfig = {}; - - // customGroups: array of {id, label, models, order} - if (Array.isArray(config.customGroups)) { - const validGroups = config.customGroups - .filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string') - .map((g) => ({ - id: g.id.slice(0, 64), - label: g.label.slice(0, 128), - models: Array.isArray(g.models) - ? g.models.filter((m) => typeof m === 'string').slice(0, 500) - : [], - order: typeof g.order === 'number' ? g.order : 0, - })); - if (validGroups.length > 0) { - providerConfig.customGroups = validGroups; - } - } - - // modelAssignments: Record - if (config.modelAssignments && typeof config.modelAssignments === 'object') { - const assignments = {}; - for (const [model, groupId] of Object.entries(config.modelAssignments)) { - if (typeof model === 'string' && typeof groupId === 'string') { - assignments[model] = groupId; - } - } - if (Object.keys(assignments).length > 0) { - providerConfig.modelAssignments = assignments; - } - } - - // renamedGroups: Record - if (config.renamedGroups && typeof config.renamedGroups === 'object') { - const renamed = {}; - for (const [groupId, label] of Object.entries(config.renamedGroups)) { - if (typeof groupId === 'string' && typeof label === 'string') { - renamed[groupId] = label.slice(0, 128); - } - } - if (Object.keys(renamed).length > 0) { - providerConfig.renamedGroups = renamed; - } - } - - if (Object.keys(providerConfig).length > 0) { - sanitized[providerId] = providerConfig; - } - } - if (Object.keys(sanitized).length > 0) { - result.usageModelGroups = sanitized; - } - } - - // Usage reporting opt-out (default: true/enabled) - if (typeof candidate.reportUsage === 'boolean') { - result.reportUsage = candidate.reportUsage; - } - - return result; -}; - -const mergePersistedSettings = (current, changes) => { - const baseApproved = Array.isArray(changes.approvedDirectories) - ? changes.approvedDirectories - : Array.isArray(current.approvedDirectories) - ? current.approvedDirectories - : []; - - const additionalApproved = []; - if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) { - additionalApproved.push(changes.lastDirectory); - } - if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) { - additionalApproved.push(changes.homeDirectory); - } - const projectEntries = Array.isArray(changes.projects) - ? changes.projects - : Array.isArray(current.projects) - ? current.projects - : []; - projectEntries.forEach((project) => { - if (project && typeof project.path === 'string' && project.path.length > 0) { - additionalApproved.push(project.path); - } - }); - const approvedSource = [...baseApproved, ...additionalApproved]; - - const baseBookmarks = Array.isArray(changes.securityScopedBookmarks) - ? changes.securityScopedBookmarks - : Array.isArray(current.securityScopedBookmarks) - ? current.securityScopedBookmarks - : []; - - const nextTypographySizes = changes.typographySizes - ? { - ...(current.typographySizes || {}), - ...changes.typographySizes - } - : current.typographySizes; - - const next = { - ...current, - ...changes, - approvedDirectories: Array.from( - new Set( - approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0) - ) - ), - securityScopedBookmarks: Array.from( - new Set( - baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0) - ) - ), - typographySizes: nextTypographySizes - }; - - return next; -}; - -const formatSettingsResponse = (settings) => { - const sanitized = sanitizeSettingsUpdate(settings); - delete sanitized.managedRemoteTunnelToken; - const approved = normalizeStringArray(settings.approvedDirectories); - const bookmarks = normalizeStringArray(settings.securityScopedBookmarks); - const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; - const pwaAppName = normalizePwaAppName(settings?.pwaAppName, ''); - - return { - ...sanitized, - hasManagedRemoteTunnelToken, - ...(pwaAppName ? { pwaAppName } : {}), - approvedDirectories: approved, - securityScopedBookmarks: bookmarks, - pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), - typographySizes: sanitizeTypographySizesPartial(settings.typographySizes), - showReasoningTraces: - typeof settings.showReasoningTraces === 'boolean' - ? settings.showReasoningTraces - : typeof sanitized.showReasoningTraces === 'boolean' - ? sanitized.showReasoningTraces - : false - }; -}; - -const validateProjectEntries = async (projects) => { - console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`); - - if (!Array.isArray(projects)) { - console.warn(`[validateProjectEntries] Input is not an array, returning empty`); - return []; - } - - const validations = projects.map(async (project) => { - if (!project || typeof project.path !== 'string' || project.path.length === 0) { - console.error(`[validateProjectEntries] Invalid project entry: missing or empty path`, project); - return null; - } - try { - const stats = await fsPromises.stat(project.path); - if (!stats.isDirectory()) { - console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`); - return null; - } - return project; - } catch (error) { - const err = error; - console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`); - if (err && typeof err === 'object' && err.code === 'ENOENT') { - console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`); - return null; - } - console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`); - return project; - } - }); - - const results = (await Promise.all(validations)).filter((p) => p !== null); - - console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`); - return results; -}; - -const migrateSettingsFromLegacyLastDirectory = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - const now = Date.now(); - - const sanitizedProjects = sanitizeProjects(settings.projects) || []; - let nextProjects = sanitizedProjects; - let nextActiveProjectId = - typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined; - - let changed = false; - - if (nextProjects.length === 0) { - const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : ''; - const candidate = legacy ? resolveDirectoryCandidate(legacy) : null; - - if (candidate) { - try { - const stats = await fsPromises.stat(candidate); - if (stats.isDirectory()) { - const id = crypto.randomUUID(); - nextProjects = [ - { - id, - path: candidate, - addedAt: now, - lastOpenedAt: now, - }, - ]; - nextActiveProjectId = id; - changed = true; - } - } catch { - // ignore invalid lastDirectory - } - } - } - - if (nextProjects.length > 0) { - const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null; - if (!active) { - nextActiveProjectId = nextProjects[0].id; - changed = true; - } - } else if (nextActiveProjectId) { - nextActiveProjectId = undefined; - changed = true; - } - - if (!changed) { - return { settings, changed: false }; - } - - const merged = mergePersistedSettings(settings, { - ...settings, - projects: nextProjects, - ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }), - }); - - return { settings: merged, changed: true }; -}; - -const migrateSettingsFromLegacyThemePreferences = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - - const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : ''; - const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : ''; - - const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0; - const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0; - - if (hasLight && hasDark) { - return { settings, changed: false }; - } - - const defaultLight = 'flexoki-light'; - const defaultDark = 'flexoki-dark'; - - let nextLightThemeId = hasLight ? settings.lightThemeId : undefined; - let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined; - - if (!hasLight) { - if (themeId && themeVariant === 'light') { - nextLightThemeId = themeId; - } else { - nextLightThemeId = defaultLight; - } - } - - if (!hasDark) { - if (themeId && themeVariant === 'dark') { - nextDarkThemeId = themeId; - } else { - nextDarkThemeId = defaultDark; - } - } - - const merged = mergePersistedSettings(settings, { - ...settings, - ...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}), - ...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}), - }); - - return { settings: merged, changed: true }; -}; - -const migrateSettingsFromLegacyCollapsedProjects = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - const collapsed = Array.isArray(settings.collapsedProjects) - ? normalizeStringArray(settings.collapsedProjects) - : []; - - if (collapsed.length === 0 || !Array.isArray(settings.projects)) { - if (collapsed.length === 0) { - return { settings, changed: false }; - } - // Nothing to apply to; drop legacy key. - const next = { ...settings }; - delete next.collapsedProjects; - return { settings: next, changed: true }; - } - - const set = new Set(collapsed); - const projects = sanitizeProjects(settings.projects) || []; - let changed = false; - - const nextProjects = projects.map((project) => { - const shouldCollapse = set.has(project.id); - if (project.sidebarCollapsed !== shouldCollapse) { - changed = true; - return { ...project, sidebarCollapsed: shouldCollapse }; - } - return project; - }); - - if (!changed) { - // Still drop legacy key if present. - if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) { - const next = { ...settings }; - delete next.collapsedProjects; - return { settings: next, changed: true }; - } - return { settings, changed: false }; - } - - const next = { ...settings, projects: nextProjects }; - delete next.collapsedProjects; - return { settings: next, changed: true }; -}; - -const DEFAULT_NOTIFICATION_TEMPLATES = { - completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, - error: { title: 'Tool error', message: '{last_message}' }, - question: { title: 'Input needed', message: '{last_message}' }, - subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, -}; - -const ensureNotificationTemplateShape = (templates) => { - const input = templates && typeof templates === 'object' ? templates : {}; - let changed = false; - const next = {}; - - for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) { - const currentEntry = input[event]; - const base = DEFAULT_NOTIFICATION_TEMPLATES[event]; - const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title; - const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message; - if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') { - changed = true; - } - next[event] = { title: currentTitle, message: currentMessage }; - } - - return { templates: next, changed }; -}; - -const migrateSettingsNotificationDefaults = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - let changed = false; - const next = { ...settings }; - - if (typeof settings.notifyOnSubtasks !== 'boolean') { - next.notifyOnSubtasks = true; - changed = true; - } - if (typeof settings.notifyOnCompletion !== 'boolean') { - next.notifyOnCompletion = true; - changed = true; - } - if (typeof settings.notifyOnError !== 'boolean') { - next.notifyOnError = true; - changed = true; - } - if (typeof settings.notifyOnQuestion !== 'boolean') { - next.notifyOnQuestion = true; - changed = true; - } - - const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates); - if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') { - next.notificationTemplates = templates; - changed = true; - } - - return { settings: changed ? next : settings, changed }; -}; - -const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => { - const settings = current && typeof current === 'object' ? current : {}; - const next = { ...settings }; - let changed = false; - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) { - next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname); - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) { - if (next.namedTunnelToken === null) { - next.managedRemoteTunnelToken = null; - } else if (typeof next.namedTunnelToken === 'string') { - next.managedRemoteTunnelToken = next.namedTunnelToken.trim(); - } - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) { - next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets); - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) { - next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens); - changed = true; - } - - if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId') - && Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) { - const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string' - ? next.namedTunnelSelectedPresetId.trim() - : ''; - if (selectedPresetId) { - next.managedRemoteTunnelSelectedPresetId = selectedPresetId; - } - changed = true; - } - - const legacyKeys = [ - 'namedTunnelHostname', - 'namedTunnelToken', - 'namedTunnelPresets', - 'namedTunnelPresetTokens', - 'namedTunnelSelectedPresetId', - ]; - for (const key of legacyKeys) { - if (Object.prototype.hasOwnProperty.call(next, key)) { - delete next[key]; - changed = true; - } - } - - return { settings: changed ? next : settings, changed }; -}; - -const readSettingsFromDiskMigrated = async () => { - const current = await readSettingsFromDisk(); - const migration1 = await migrateSettingsFromLegacyLastDirectory(current); - const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings); - const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings); - const migration4 = await migrateSettingsNotificationDefaults(migration3.settings); - const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings); - const migration6 = normalizeSettingsPaths(migration5.settings); - if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) { - await writeSettingsToDisk(migration6.settings); - } - return migration6.settings; -}; - -const getOrCreateVapidKeys = async () => { - const settings = await readSettingsFromDiskMigrated(); - const existing = settings?.vapidKeys; - if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') { - return { publicKey: existing.publicKey, privateKey: existing.privateKey }; - } - - const generated = webPush.generateVAPIDKeys(); - const next = { - ...settings, - vapidKeys: { - publicKey: generated.publicKey, - privateKey: generated.privateKey, - }, - }; - - await writeSettingsToDisk(next); - return { publicKey: generated.publicKey, privateKey: generated.privateKey }; -}; - -const getUiSessionTokenFromRequest = (req) => { - const cookieHeader = req?.headers?.cookie; - if (!cookieHeader || typeof cookieHeader !== 'string') { - return null; - } - const segments = cookieHeader.split(';'); - for (const segment of segments) { - const [rawName, ...rest] = segment.split('='); - const name = rawName?.trim(); - if (!name) continue; - if (name !== 'oc_ui_session') continue; - const value = rest.join('=').trim(); - try { - return decodeURIComponent(value || ''); - } catch { - return value || null; - } - } - return null; -}; +const managedTunnelConfigRuntime = createManagedTunnelConfigRuntime({ + fsPromises, + path, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + constants: { + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, + CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + }, +}); + +const readManagedRemoteTunnelConfigFromDisk = (...args) => managedTunnelConfigRuntime.readManagedRemoteTunnelConfigFromDisk(...args); +const syncManagedRemoteTunnelConfigWithPresets = (...args) => managedTunnelConfigRuntime.syncManagedRemoteTunnelConfigWithPresets(...args); +const upsertManagedRemoteTunnelToken = (...args) => managedTunnelConfigRuntime.upsertManagedRemoteTunnelToken(...args); +const resolveManagedRemoteTunnelToken = (...args) => managedTunnelConfigRuntime.resolveManagedRemoteTunnelToken(...args); + +const settingsHelpers = createSettingsHelpers({ + normalizePathForPersistence, + normalizeDirectoryPath, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + sanitizeTypographySizesPartial, + normalizeStringArray, + sanitizeModelRefs, + sanitizeSkillCatalogs, + sanitizeProjects, +}); + +const normalizePwaAppName = (...args) => settingsHelpers.normalizePwaAppName(...args); +const sanitizeSettingsUpdate = (...args) => settingsHelpers.sanitizeSettingsUpdate(...args); +const mergePersistedSettings = (...args) => settingsHelpers.mergePersistedSettings(...args); +const formatSettingsResponse = (...args) => settingsHelpers.formatSettingsResponse(...args); + +const projectDirectoryRuntime = createProjectDirectoryRuntime({ + fsPromises, + path, + normalizeDirectoryPath, + getReadSettingsFromDiskMigrated: () => readSettingsFromDiskMigrated, + sanitizeProjects, +}); + +const resolveDirectoryCandidate = (...args) => projectDirectoryRuntime.resolveDirectoryCandidate(...args); +const validateDirectoryPath = (...args) => projectDirectoryRuntime.validateDirectoryPath(...args); +const resolveProjectDirectory = (...args) => projectDirectoryRuntime.resolveProjectDirectory(...args); +const resolveOptionalProjectDirectory = (...args) => projectDirectoryRuntime.resolveOptionalProjectDirectory(...args); + +const settingsRuntime = createSettingsRuntime({ + fsPromises, + path, + crypto, + SETTINGS_FILE_PATH, + sanitizeProjects, + sanitizeSettingsUpdate, + mergePersistedSettings, + normalizeSettingsPaths, + normalizeStringArray, + formatSettingsResponse, + resolveDirectoryCandidate, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + syncManagedRemoteTunnelConfigWithPresets, + upsertManagedRemoteTunnelToken, +}); + +const readSettingsFromDiskMigrated = (...args) => settingsRuntime.readSettingsFromDiskMigrated(...args); +const readSettingsFromDisk = (...args) => settingsRuntime.readSettingsFromDisk(...args); +const writeSettingsToDisk = (...args) => settingsRuntime.writeSettingsToDisk(...args); +const persistSettings = (...args) => settingsRuntime.persistSettings(...args); + +const requestSecurityRuntime = createRequestSecurityRuntime({ + readSettingsFromDiskMigrated, +}); + +const getUiSessionTokenFromRequest = (...args) => requestSecurityRuntime.getUiSessionTokenFromRequest(...args); + +const pushRuntime = createPushRuntime({ + fsPromises, + path, + webPush, + PUSH_SUBSCRIPTIONS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, +}); + +const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...args); +const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args); +const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args); +const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args); +const updateUiVisibility = (...args) => pushRuntime.updateUiVisibility(...args); +const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args); +const isUiVisible = (...args) => pushRuntime.isUiVisible(...args); +const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args); +const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args); const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128; const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000; const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000; -const rejectWebSocketUpgrade = (socket, statusCode, reason) => { - if (!socket || socket.destroyed) { - return; - } +const rejectWebSocketUpgrade = (...args) => requestSecurityRuntime.rejectWebSocketUpgrade(...args); - const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request'; - const body = Buffer.from(message, 'utf8'); - const statusText = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - }[statusCode] || 'Bad Request'; - try { - socket.write( - `HTTP/1.1 ${statusCode} ${statusText}\r\n` + - 'Connection: close\r\n' + - 'Content-Type: text/plain; charset=utf-8\r\n' + - `Content-Length: ${body.length}\r\n\r\n` - ); - socket.write(body); - } catch { - } +const isRequestOriginAllowed = (...args) => requestSecurityRuntime.isRequestOriginAllowed(...args); - try { - socket.destroy(); - } catch { - } -}; +const notificationEmitterRuntime = createNotificationEmitterRuntime({ + process, + getDesktopNotifyEnabled: () => ENV_DESKTOP_NOTIFY, + desktopNotifyPrefix: DESKTOP_NOTIFY_PREFIX, + getUiNotificationClients: () => uiNotificationClients, +}); +const writeSseEvent = (...args) => notificationEmitterRuntime.writeSseEvent(...args); +const emitDesktopNotification = (...args) => notificationEmitterRuntime.emitDesktopNotification(...args); +const broadcastUiNotification = (...args) => notificationEmitterRuntime.broadcastUiNotification(...args); -const getRequestOriginCandidates = async (req) => { - const origins = new Set(); - const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' - ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase() - : ''; - const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http'); - - const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string' - ? req.headers['x-forwarded-host'].split(',')[0].trim() - : ''; - const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : ''); - - if (host) { - origins.add(`${protocol}://${host}`); - const [hostname, port] = host.split(':'); - const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : ''; - const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : ''; - if (normalizedHost === 'localhost') { - origins.add(`${protocol}://127.0.0.1${portSuffix}`); - origins.add(`${protocol}://[::1]${portSuffix}`); - } else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') { - origins.add(`${protocol}://localhost${portSuffix}`); - } - } - - try { - const settings = await readSettingsFromDiskMigrated(); - if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) { - origins.add(new URL(settings.publicOrigin.trim()).origin); - } - } catch { - } - - return origins; -}; - -const isRequestOriginAllowed = async (req) => { - const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; - if (!originHeader) { - return false; - } - - let normalizedOrigin = ''; - try { - normalizedOrigin = new URL(originHeader).origin; - } catch { - return false; - } - - const allowedOrigins = await getRequestOriginCandidates(req); - return allowedOrigins.has(normalizedOrigin); -}; - -const normalizePushSubscriptions = (record) => { - if (!Array.isArray(record)) return []; - return record - .map((entry) => { - if (!entry || typeof entry !== 'object') return null; - const endpoint = entry.endpoint; - const p256dh = entry.p256dh; - const auth = entry.auth; - if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') { - return null; - } - return { - endpoint, - p256dh, - auth, - createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, - }; - }) - .filter(Boolean); -}; - -const getPushSubscriptionsForUiSession = async (uiSessionToken) => { - if (!uiSessionToken) return []; - const store = await readPushSubscriptionsFromDisk(); - const record = store.subscriptionsBySession?.[uiSessionToken]; - return normalizePushSubscriptions(record); -}; - -const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => { - if (!uiSessionToken) { - return; - } - - await ensurePushInitialized(); - - const now = Date.now(); - - await persistPushSubscriptionUpdate((current) => { - const subsBySession = { ...(current.subscriptionsBySession || {}) }; - const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; - - const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint); - - filtered.unshift({ - endpoint: subscription.endpoint, - p256dh: subscription.p256dh, - auth: subscription.auth, - createdAt: now, - lastSeenAt: now, - userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, - }); - - subsBySession[uiSessionToken] = filtered.slice(0, 10); - - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; - }); -}; - -const removePushSubscription = async (uiSessionToken, endpoint) => { - if (!uiSessionToken || !endpoint) return; - - await ensurePushInitialized(); - - await persistPushSubscriptionUpdate((current) => { - const subsBySession = { ...(current.subscriptionsBySession || {}) }; - const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; - const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); - if (filtered.length === 0) { - delete subsBySession[uiSessionToken]; - } else { - subsBySession[uiSessionToken] = filtered; - } - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; - }); -}; - -const removePushSubscriptionFromAllSessions = async (endpoint) => { - if (!endpoint) return; - - await ensurePushInitialized(); - - await persistPushSubscriptionUpdate((current) => { - const subsBySession = { ...(current.subscriptionsBySession || {}) }; - for (const [token, entries] of Object.entries(subsBySession)) { - if (!Array.isArray(entries)) continue; - const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); - if (filtered.length === 0) { - delete subsBySession[token]; - } else { - subsBySession[token] = filtered; - } - } - return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; - }); -}; - -const buildSessionDeepLinkUrl = (sessionId) => { - if (!sessionId || typeof sessionId !== 'string') { - return '/'; - } - return `/?session=${encodeURIComponent(sessionId)}`; -}; - -const sendPushToSubscription = async (sub, payload) => { - await ensurePushInitialized(); - const body = JSON.stringify(payload); - - const pushSubscription = { - endpoint: sub.endpoint, - keys: { - p256dh: sub.p256dh, - auth: sub.auth, - } - }; - - try { - await webPush.sendNotification(pushSubscription, body); - } catch (error) { - const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null; - if (statusCode === 410 || statusCode === 404) { - await removePushSubscriptionFromAllSessions(sub.endpoint); - return; - } - console.warn('[Push] Failed to send notification:', error); - } -}; - -const sendPushToAllUiSessions = async (payload, options = {}) => { - const requireNoSse = options.requireNoSse === true; - const store = await readPushSubscriptionsFromDisk(); - const sessions = store.subscriptionsBySession || {}; - const subscriptionsByEndpoint = new Map(); - - for (const [token, record] of Object.entries(sessions)) { - const subscriptions = normalizePushSubscriptions(record); - if (subscriptions.length === 0) continue; - - for (const sub of subscriptions) { - if (!subscriptionsByEndpoint.has(sub.endpoint)) { - subscriptionsByEndpoint.set(sub.endpoint, sub); - } - } - } - - await Promise.all(Array.from(subscriptionsByEndpoint.entries()).map(async ([endpoint, sub]) => { - if (requireNoSse && isAnyUiVisible()) { - return; - } - await sendPushToSubscription(sub, payload); - })); -}; - -let pushInitialized = false; - - - -const uiVisibilityByToken = new Map(); -let globalVisibilityState = false; - -const updateUiVisibility = (token, visible) => { - if (!token) return; - const now = Date.now(); - const nextVisible = Boolean(visible); - uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now }); - globalVisibilityState = nextVisible; - -}; - -const isAnyUiVisible = () => globalVisibilityState === true; - -const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true; - -// Session activity tracking (mirrors desktop session_activity.rs) -const sessionActivityPhases = new Map(); // sessionId -> { phase: 'idle'|'busy'|'cooldown', updatedAt: number } -const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId -const SESSION_COOLDOWN_DURATION_MS = 2000; - -// Complete session status tracking - source of truth for web clients -// This maintains the authoritative state, clients only cache it -const sessionStates = new Map(); // sessionId -> { -// status: 'idle'|'busy'|'retry', -// lastUpdateAt: number, -// lastEventId: string, -// metadata: { attempt?: number, message?: string, next?: number } -// } -const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours -const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour - -const updateSessionState = (sessionId, status, eventId, metadata = {}) => { - if (!sessionId || typeof sessionId !== 'string') return; - - const now = Date.now(); - const existing = sessionStates.get(sessionId); - const existingAttentionState = sessionAttentionStates.get(sessionId); - - // Only update if this is a newer event (simple ordering protection) - if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) { - // Same status within 5 seconds, skip to reduce noise - return; - } - - sessionStates.set(sessionId, { - status, - lastUpdateAt: now, - lastEventId: eventId || `server-${now}`, - metadata: { ...existing?.metadata, ...metadata } - }); - - // Update attention tracking state (must be called before broadcasting) - updateSessionAttentionStatus(sessionId, status, eventId); - const attentionState = sessionAttentionStates.get(sessionId); - - // Broadcast status change to connected web clients via SSE - // This enables real-time updates without polling - // Include needsAttention in the same event to ensure atomic updates - const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention; - if (uiNotificationClients.size > 0 && (!existing || existing.status !== status || attentionChanged)) { - const state = sessionStates.get(sessionId); - for (const res of uiNotificationClients) { - try { - writeSseEvent(res, { - type: 'openchamber:session-status', - properties: { - sessionId, - status: state.status, - timestamp: state.lastUpdateAt, - metadata: state.metadata, - needsAttention: attentionState?.needsAttention ?? false - } - }); - } catch { - // Client disconnected, will be cleaned up by close handler - } - } - } - - // Also update activity phases for backward compatibility - const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle'; - setSessionActivityPhase(sessionId, phase); -}; - -const getSessionStateSnapshot = () => { - const result = {}; - const now = Date.now(); - - for (const [sessionId, data] of sessionStates) { - // Skip very old states (session likely gone) - if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue; - - result[sessionId] = { - status: data.status, - lastUpdateAt: data.lastUpdateAt, - metadata: data.metadata - }; - } - - return result; -}; - -const getSessionState = (sessionId) => { - if (!sessionId) return null; - return sessionStates.get(sessionId) || null; -}; - -// Session attention tracking - authoritative source for unread/needs-attention state -// Tracks which sessions need user attention based on activity and view state -const sessionAttentionStates = new Map(); // sessionId -> { -// needsAttention: boolean, -// lastUserMessageAt: number | null, -// lastStatusChangeAt: number, -// viewedByClients: Set, -// status: 'idle' | 'busy' | 'retry' -// } -const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours - -const getOrCreateAttentionState = (sessionId) => { - if (!sessionId || typeof sessionId !== 'string') return null; - - let state = sessionAttentionStates.get(sessionId); - if (!state) { - state = { - needsAttention: false, - lastUserMessageAt: null, - lastStatusChangeAt: Date.now(), - viewedByClients: new Set(), - status: 'idle' - }; - sessionAttentionStates.set(sessionId, state); - } - return state; -}; - -const updateSessionAttentionStatus = (sessionId, status, eventId) => { - const state = getOrCreateAttentionState(sessionId); - if (!state) return; - - const prevStatus = state.status; - state.status = status; - state.lastStatusChangeAt = Date.now(); - - // Check if we need to mark as needsAttention - // Condition: transitioning from busy/retry to idle + user sent message + not currently viewed - // Note: The actual broadcast with needsAttention is done in updateSessionState - // to ensure both status and attention are sent in a single event - if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') { - if (state.lastUserMessageAt && state.viewedByClients.size === 0) { - state.needsAttention = true; - } - } -}; - -const markSessionViewed = (sessionId, clientId) => { - const state = getOrCreateAttentionState(sessionId); - if (!state) return; - - const wasNeedsAttention = state.needsAttention; - state.viewedByClients.add(clientId); - - // Clear needsAttention when viewed - if (wasNeedsAttention) { - state.needsAttention = false; - - // Broadcast attention cleared event - if (uiNotificationClients.size > 0) { - for (const res of uiNotificationClients) { - try { - writeSseEvent(res, { - type: 'openchamber:session-status', - properties: { - sessionId, - status: state.status, - timestamp: Date.now(), - metadata: {}, - needsAttention: false - } - }); - } catch { - // Client disconnected - } - } - } - } -}; - -const markSessionUnviewed = (sessionId, clientId) => { - const state = sessionAttentionStates.get(sessionId); - if (!state) return; - - state.viewedByClients.delete(clientId); -}; - -const markUserMessageSent = (sessionId) => { - const state = getOrCreateAttentionState(sessionId); - if (!state) return; - - state.lastUserMessageAt = Date.now(); -}; - -const getSessionAttentionSnapshot = () => { - const result = {}; - const now = Date.now(); - - for (const [sessionId, state] of sessionAttentionStates) { - // Skip very old states - if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue; - - result[sessionId] = { - needsAttention: state.needsAttention, - lastUserMessageAt: state.lastUserMessageAt, - lastStatusChangeAt: state.lastStatusChangeAt, - status: state.status, - isViewed: state.viewedByClients.size > 0 - }; - } - - return result; -}; - -const getSessionAttentionState = (sessionId) => { - if (!sessionId) return null; - const state = sessionAttentionStates.get(sessionId); - if (!state) return null; - - return { - needsAttention: state.needsAttention, - lastUserMessageAt: state.lastUserMessageAt, - lastStatusChangeAt: state.lastStatusChangeAt, - status: state.status, - isViewed: state.viewedByClients.size > 0 - }; -}; - -const cleanupOldSessionStates = () => { - const now = Date.now(); - let cleaned = 0; - - for (const [sessionId, data] of sessionStates) { - if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) { - sessionStates.delete(sessionId); - cleaned++; - } - } - - // Also cleanup attention states - for (const [sessionId, state] of sessionAttentionStates) { - if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) { - sessionAttentionStates.delete(sessionId); - cleaned++; - } - } - - if (cleaned > 0) { - console.info(`[SessionState] Cleaned up ${cleaned} old session states`); - } -}; - -// Start periodic cleanup -setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS); - -const setSessionActivityPhase = (sessionId, phase) => { - if (!sessionId || typeof sessionId !== 'string') return false; - - const current = sessionActivityPhases.get(sessionId); - if (current?.phase === phase) return false; // No change - - // Match desktop semantics: only enter cooldown from busy. - if (phase === 'cooldown' && current?.phase !== 'busy') { - return false; - } - - // Cancel existing cooldown timer only on phase change. - const existingTimer = sessionActivityCooldowns.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - sessionActivityCooldowns.delete(sessionId); - } - - sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); - - // Schedule transition from cooldown to idle - if (phase === 'cooldown') { - const timer = setTimeout(() => { - const now = sessionActivityPhases.get(sessionId); - if (now?.phase === 'cooldown') { - sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); - } - sessionActivityCooldowns.delete(sessionId); - }, SESSION_COOLDOWN_DURATION_MS); - sessionActivityCooldowns.set(sessionId, timer); - } - - return true; -}; - -const getSessionActivitySnapshot = () => { - const result = {}; - for (const [sessionId, data] of sessionActivityPhases) { - result[sessionId] = { type: data.phase }; - } - return result; -}; - -const resetAllSessionActivityToIdle = () => { - // Cancel all cooldown timers - for (const timer of sessionActivityCooldowns.values()) { - clearTimeout(timer); - } - sessionActivityCooldowns.clear(); - - // Reset all phases to idle - const now = Date.now(); - for (const [sessionId] of sessionActivityPhases) { - sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now }); - } -}; - -const resolveVapidSubject = async () => { - const configured = process.env.OPENCHAMBER_VAPID_SUBJECT; - if (typeof configured === 'string' && configured.trim().length > 0) { - return configured.trim(); - } - - const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN; - if (typeof originEnv === 'string' && originEnv.trim().length > 0) { - const trimmed = originEnv.trim(); - // Convert http://localhost to mailto for VAPID compatibility - if (trimmed.startsWith('http://localhost')) { - return 'mailto:openchamber@localhost'; - } - return trimmed; - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const stored = settings?.publicOrigin; - if (typeof stored === 'string' && stored.trim().length > 0) { - const trimmed = stored.trim(); - // Convert http://localhost to mailto for VAPID compatibility - if (trimmed.startsWith('http://localhost')) { - return 'mailto:openchamber@localhost'; - } - return trimmed; - } - } catch { - // ignore - } - - return 'mailto:openchamber@localhost'; -}; - -const ensurePushInitialized = async () => { - if (pushInitialized) return; - const keys = await getOrCreateVapidKeys(); - const subject = await resolveVapidSubject(); - - if (subject === 'mailto:openchamber@localhost') { - console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.'); - } - - webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey); - pushInitialized = true; -}; - -const persistSettings = async (changes) => { - // Serialize concurrent calls using lock - persistSettingsLock = persistSettingsLock.then(async () => { - console.log(`[persistSettings] Called with changes:`, JSON.stringify(changes, null, 2)); - const current = await readSettingsFromDisk(); - console.log(`[persistSettings] Current projects count:`, Array.isArray(current.projects) ? current.projects.length : 'N/A'); - const sanitized = sanitizeSettingsUpdate(changes); - let next = mergePersistedSettings(current, sanitized); - - const normalizedState = normalizeSettingsPaths(next); - if (normalizedState.changed) { - next = normalizedState.settings; - } - - if (Array.isArray(next.projects)) { - console.log(`[persistSettings] Validating ${next.projects.length} projects...`); - const validated = await validateProjectEntries(next.projects); - console.log(`[persistSettings] After validation: ${validated.length} projects remain`); - next = { ...next, projects: validated }; - } - - if (Array.isArray(next.projects) && next.projects.length > 0) { - const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : ''; - const active = next.projects.find((project) => project.id === activeId) || null; - if (!active) { - console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`); - next = { ...next, activeProjectId: next.projects[0].id }; - } - } else if (next.activeProjectId) { - console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`); - next = { ...next, activeProjectId: undefined }; - } - - if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) { - await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets); - } - - if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) { - const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry])); - const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens) - .map(([presetId, token]) => { - const preset = presetsById.get(presetId); - if (!preset || typeof token !== 'string' || token.trim().length === 0) { - return null; - } - return { - id: preset.id, - name: preset.name, - hostname: preset.hostname, - token: token.trim(), - }; - }) - .filter(Boolean); - - for (const update of updates) { - await upsertManagedRemoteTunnelToken(update); - } - } - - await writeSettingsToDisk(next); - console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`); - return formatSettingsResponse(next); - }); - - return persistSettingsLock; -}; +const sessionRuntime = createSessionRuntime({ + writeSseEvent, + getNotificationClients: () => uiNotificationClients, +}); // HMR-persistent state via globalThis // These values survive Vite HMR reloads to prevent zombie OpenCode processes -const HMR_STATE_KEY = '__openchamberHmrState'; -const getHmrState = () => { - if (!globalThis[HMR_STATE_KEY]) { - globalThis[HMR_STATE_KEY] = { - openCodeProcess: null, - openCodePort: null, - openCodeWorkingDirectory: os.homedir(), - isShuttingDown: false, - signalsAttached: false, - userProvidedOpenCodePassword: undefined, - openCodeAuthPassword: null, - openCodeAuthSource: null, - }; - } - return globalThis[HMR_STATE_KEY]; -}; -const hmrState = getHmrState(); - -const normalizeOpenCodePassword = (value) => { - if (typeof value !== 'string') { - return ''; - } - return value.trim(); -}; - -if (typeof hmrState.userProvidedOpenCodePassword === 'undefined') { - const initialPassword = normalizeOpenCodePassword(process.env.OPENCODE_SERVER_PASSWORD); - hmrState.userProvidedOpenCodePassword = initialPassword || null; -} +const hmrStateRuntime = createHmrStateRuntime({ + globalThisLike: globalThis, + os, + processLike: process, + stateKey: '__openchamberHmrState', +}); +const hmrState = hmrStateRuntime.getOrCreateHmrState(); +hmrStateRuntime.ensureUserProvidedOpenCodePassword(hmrState); // Non-HMR state (safe to reset on reload) let healthCheckInterval = null; let server = null; -let cachedModelsMetadata = null; -let cachedModelsMetadataTimestamp = 0; let expressApp = null; let currentRestartPromise = null; let isRestartingOpenCode = false; @@ -3636,48 +334,43 @@ tunnelProviderRegistry.seal(); const tunnelAuthController = createTunnelAuth(); let runtimeManagedRemoteTunnelToken = ''; let runtimeManagedRemoteTunnelHostname = ''; -let terminalInputWsServer = null; -const userProvidedOpenCodePassword = - typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0 - ? hmrState.userProvidedOpenCodePassword - : null; -let openCodeAuthPassword = - typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 - ? hmrState.openCodeAuthPassword - : userProvidedOpenCodePassword; -let openCodeAuthSource = - typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 - ? hmrState.openCodeAuthSource - : (userProvidedOpenCodePassword ? 'user-env' : null); +let terminalRuntime = null; +const userProvidedOpenCodePassword = hmrStateRuntime.getUserProvidedOpenCodePassword(hmrState); +const initialOpenCodeAuthState = hmrStateRuntime.resolveOpenCodeAuthFromState({ + hmrState, + userProvidedOpenCodePassword, +}); +let openCodeAuthPassword = initialOpenCodeAuthState.openCodeAuthPassword; +let openCodeAuthSource = initialOpenCodeAuthState.openCodeAuthSource; // Sync helper - call after modifying any HMR state variable const syncToHmrState = () => { - hmrState.openCodeProcess = openCodeProcess; - hmrState.openCodePort = openCodePort; - hmrState.openCodeBaseUrl = openCodeBaseUrl; - hmrState.isShuttingDown = isShuttingDown; - hmrState.signalsAttached = signalsAttached; - hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory; - hmrState.openCodeAuthPassword = openCodeAuthPassword; - hmrState.openCodeAuthSource = openCodeAuthSource; + hmrStateRuntime.syncStateFromRuntime(hmrState, { + openCodeProcess, + openCodePort, + openCodeBaseUrl, + isShuttingDown, + signalsAttached, + openCodeWorkingDirectory, + openCodeAuthPassword, + openCodeAuthSource, + }); }; // Sync helper - call to restore state from HMR (e.g., on module reload) const syncFromHmrState = () => { - openCodeProcess = hmrState.openCodeProcess; - openCodePort = hmrState.openCodePort; - openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null; - isShuttingDown = hmrState.isShuttingDown; - signalsAttached = hmrState.signalsAttached; - openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; - openCodeAuthPassword = - typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 - ? hmrState.openCodeAuthPassword - : userProvidedOpenCodePassword; - openCodeAuthSource = - typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 - ? hmrState.openCodeAuthSource - : (userProvidedOpenCodePassword ? 'user-env' : null); + const restored = hmrStateRuntime.restoreRuntimeFromState({ + hmrState, + userProvidedOpenCodePassword, + }); + openCodeProcess = restored.openCodeProcess; + openCodePort = restored.openCodePort; + openCodeBaseUrl = restored.openCodeBaseUrl; + isShuttingDown = restored.isShuttingDown; + signalsAttached = restored.signalsAttached; + openCodeWorkingDirectory = restored.openCodeWorkingDirectory; + openCodeAuthPassword = restored.openCodeAuthPassword; + openCodeAuthSource = restored.openCodeAuthSource; }; // Module-level variables that shadow HMR state @@ -3689,119 +382,15 @@ let isShuttingDown = hmrState.isShuttingDown; let signalsAttached = hmrState.signalsAttached; let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; -/** - * Check if an existing OpenCode process is still alive and responding - * Used to reuse process across HMR reloads - */ -async function isOpenCodeProcessHealthy() { - if (!openCodeProcess || !openCodePort) { - return false; - } - - // Health check via HTTP since SDK object doesn't expose exitCode - try { - const response = await fetch(`http://127.0.0.1:${openCodePort}/session`, { - method: 'GET', - headers: getOpenCodeAuthHeaders(), - signal: AbortSignal.timeout(2000), - }); - return response.ok; - } catch { - return false; - } -} - -/** - * Probe if an external OpenCode instance is already running on the given port. - * Unlike isOpenCodeProcessHealthy(), this doesn't require openCodeProcess to be set. - * Used to auto-detect and connect to an existing OpenCode instance on startup. - */ -async function probeExternalOpenCode(port, origin) { - if (!port || port <= 0) { - return false; - } - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - const base = origin ?? `http://127.0.0.1:${port}`; - const response = await fetch(`${base}/global/health`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: controller.signal, - }); - clearTimeout(timeout); - if (!response.ok) return false; - const body = await response.json().catch(() => null); - return body?.healthy === true; - } catch { - return false; - } -} - -const ENV_CONFIGURED_OPENCODE_PORT = (() => { - const raw = - process.env.OPENCODE_PORT || - process.env.OPENCHAMBER_OPENCODE_PORT || - process.env.OPENCHAMBER_INTERNAL_PORT; - if (!raw) { - return null; - } - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; -})(); - -const ENV_CONFIGURED_OPENCODE_HOST = (() => { - const raw = process.env.OPENCODE_HOST?.trim(); - if (!raw) return null; - - const warnInvalidHost = (reason) => { - console.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`); - }; - - let url; - try { - url = new URL(raw); - } catch { - warnInvalidHost('not a valid URL'); - return null; - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`); - return null; - } - const port = parseInt(url.port, 10); - if (!Number.isFinite(port) || port <= 0) { - warnInvalidHost('must include an explicit port (example: http://hostname:4096)'); - return null; - } - if (url.pathname !== '/' || url.search || url.hash) { - warnInvalidHost('must not include path, query, or hash'); - return null; - } - return { origin: url.origin, port }; -})(); - -// OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set -const ENV_EFFECTIVE_PORT = ENV_CONFIGURED_OPENCODE_HOST?.port ?? ENV_CONFIGURED_OPENCODE_PORT; - -const ENV_CONFIGURED_OPENCODE_HOSTNAME = (() => { - const raw = process.env.OPENCHAMBER_OPENCODE_HOSTNAME; - if (typeof raw !== 'string') { - return '127.0.0.1'; - } - const trimmed = raw.trim(); - if (!trimmed) { - console.warn( - `[config] Ignoring OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: empty after trimming`, - ); - return '127.0.0.1'; - } - return trimmed; -})(); +const { + configuredOpenCodePort: ENV_CONFIGURED_OPENCODE_PORT, + configuredOpenCodeHost: ENV_CONFIGURED_OPENCODE_HOST, + effectivePort: ENV_EFFECTIVE_PORT, + configuredOpenCodeHostname: ENV_CONFIGURED_OPENCODE_HOSTNAME, +} = resolveOpenCodeEnvConfig({ + env: process.env, + logger: console, +}); const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; @@ -3816,239 +405,45 @@ const ENV_CONFIGURED_OPENCODE_WSL_DISTRO = : null ); -// OpenCode server authentication (Basic Auth with username "opencode") +const openCodeAuthStateRuntime = createOpenCodeAuthStateRuntime({ + crypto, + process, + getAuthPassword: () => openCodeAuthPassword, + setAuthPassword: (value) => { + openCodeAuthPassword = value; + }, + getAuthSource: () => openCodeAuthSource, + setAuthSource: (value) => { + openCodeAuthSource = value; + }, + getUserProvidedPassword: () => userProvidedOpenCodePassword, + syncToHmrState, +}); -/** - * Returns auth headers for OpenCode server requests if OPENCODE_SERVER_PASSWORD is set. - * Uses Basic Auth with username "opencode" and the password from the env variable. - */ -function getOpenCodeAuthHeaders() { - const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || ''); - - if (!password) { - return {}; - } - - const credentials = Buffer.from(`opencode:${password}`).toString('base64'); - return { Authorization: `Basic ${credentials}` }; -} +const getOpenCodeAuthHeaders = (...args) => openCodeAuthStateRuntime.getOpenCodeAuthHeaders(...args); +const isOpenCodeConnectionSecure = (...args) => openCodeAuthStateRuntime.isOpenCodeConnectionSecure(...args); +const ensureLocalOpenCodeServerPassword = (...args) => openCodeAuthStateRuntime.ensureLocalOpenCodeServerPassword(...args); -function isOpenCodeConnectionSecure() { - return Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization'); -} +const openCodeNetworkState = {}; +Object.defineProperties(openCodeNetworkState, { + openCodePort: { get: () => openCodePort, set: (value) => { openCodePort = value; } }, + openCodeBaseUrl: { get: () => openCodeBaseUrl, set: (value) => { openCodeBaseUrl = value; } }, + openCodeApiPrefix: { get: () => openCodeApiPrefix, set: (value) => { openCodeApiPrefix = value; } }, + openCodeApiPrefixDetected: { get: () => openCodeApiPrefixDetected, set: (value) => { openCodeApiPrefixDetected = value; } }, + openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } }, +}); -function generateSecureOpenCodePassword() { - return crypto - .randomBytes(32) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, ''); -} +const openCodeNetworkRuntime = createOpenCodeNetworkRuntime({ + state: openCodeNetworkState, + getOpenCodeAuthHeaders, +}); -function isValidOpenCodePassword(password) { - return typeof password === 'string' && password.trim().length > 0; -} - -function setOpenCodeAuthState(password, source) { - const normalized = normalizeOpenCodePassword(password); - if (!isValidOpenCodePassword(normalized)) { - openCodeAuthPassword = null; - openCodeAuthSource = null; - delete process.env.OPENCODE_SERVER_PASSWORD; - syncToHmrState(); - return null; - } - - openCodeAuthPassword = normalized; - openCodeAuthSource = source; - process.env.OPENCODE_SERVER_PASSWORD = normalized; - syncToHmrState(); - return normalized; -} - -async function ensureLocalOpenCodeServerPassword({ rotateManaged = false } = {}) { - if (isValidOpenCodePassword(userProvidedOpenCodePassword)) { - return setOpenCodeAuthState(userProvidedOpenCodePassword, 'user-env'); - } - - if (rotateManaged) { - const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated'); - console.log('Rotated secure password for managed local OpenCode instance'); - return rotatedPassword; - } - - if (isValidOpenCodePassword(openCodeAuthPassword)) { - return setOpenCodeAuthState(openCodeAuthPassword, openCodeAuthSource || 'generated'); - } - - const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated'); - console.log('Generated secure password for managed local OpenCode instance'); - return generatedPassword; -} - -let cachedLoginShellEnvSnapshot = undefined; - -function parseNullSeparatedEnvSnapshot(raw) { - if (typeof raw !== 'string' || raw.length === 0) { - return null; - } - - const result = {}; - const entries = raw.split('\0'); - for (const entry of entries) { - if (!entry) { - continue; - } - const idx = entry.indexOf('='); - if (idx <= 0) { - continue; - } - const key = entry.slice(0, idx); - const value = entry.slice(idx + 1); - result[key] = value; - } - - return Object.keys(result).length > 0 ? result : null; -} - -function getLoginShellEnvSnapshot() { - if (cachedLoginShellEnvSnapshot !== undefined) { - return cachedLoginShellEnvSnapshot; - } - - if (process.platform === 'win32') { - const windowsSnapshot = getWindowsShellEnvSnapshot(); - cachedLoginShellEnvSnapshot = windowsSnapshot; - return windowsSnapshot; - } - - const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - - for (const shellPath of shellCandidates) { - if (!isExecutable(shellPath)) { - continue; - } - - try { - const result = spawnSync(shellPath, ['-lic', 'env -0'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - windowsHide: true, - }); - - if (result.status !== 0) { - continue; - } - - const parsed = parseNullSeparatedEnvSnapshot(result.stdout || ''); - if (parsed) { - cachedLoginShellEnvSnapshot = parsed; - return parsed; - } - } catch { - // ignore - } - } - - cachedLoginShellEnvSnapshot = null; - return null; -} - -function getWindowsShellEnvSnapshot() { - const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : ''); - - const psScript = - "Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }"; - - const powershellCandidates = [ - 'pwsh.exe', - 'powershell.exe', - path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), - ]; - - for (const shellPath of powershellCandidates) { - try { - const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - windowsHide: true, - }); - if (result.status !== 0) { - continue; - } - const parsed = parseResult(result.stdout); - if (parsed) { - return parsed; - } - } catch { - // ignore - } - } - - const comspec = process.env.ComSpec || 'cmd.exe'; - try { - const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - windowsHide: true, - }); - if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) { - return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0')); - } - } catch { - // ignore - } - - return null; -} - -function mergePathValues(preferred, fallback) { - const merged = new Set(); - - const addSegments = (value) => { - if (typeof value !== 'string' || !value) { - return; - } - for (const segment of value.split(path.delimiter)) { - if (segment) { - merged.add(segment); - } - } - }; - - addSegments(preferred); - addSegments(fallback); - - return Array.from(merged).join(path.delimiter); -} - -function applyLoginShellEnvSnapshot() { - const snapshot = getLoginShellEnvSnapshot(); - if (!snapshot) { - return; - } - - const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']); - - for (const [key, value] of Object.entries(snapshot)) { - if (skipKeys.has(key)) { - continue; - } - const existing = process.env[key]; - if (typeof existing === 'string' && existing.length > 0) { - continue; - } - process.env[key] = value; - } - - process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || ''); -} - -applyLoginShellEnvSnapshot(); +const waitForReady = (...args) => openCodeNetworkRuntime.waitForReady(...args); +const normalizeApiPrefix = (...args) => openCodeNetworkRuntime.normalizeApiPrefix(...args); +const setDetectedOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.setDetectedOpenCodeApiPrefix(...args); +const buildOpenCodeUrl = (...args) => openCodeNetworkRuntime.buildOpenCodeUrl(...args); +const ensureOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.ensureOpenCodeApiPrefix(...args); +const scheduleOpenCodeApiDetection = (...args) => openCodeNetworkRuntime.scheduleOpenCodeApiDetection(...args); const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || '' @@ -4058,8 +453,7 @@ const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( console.warn('Ignoring configured OpenCode API prefix; API runs at root.'); } -let globalEventWatcherAbortController = null; - +let cachedLoginShellEnvSnapshot; let resolvedOpencodeBinary = null; let resolvedOpencodeBinarySource = null; let resolvedNodeBinary = null; @@ -4070,3051 +464,320 @@ let resolvedWslBinary = null; let resolvedWslOpencodePath = null; let resolvedWslDistro = null; -function resolveGitBinaryForSpawn() { - if (process.platform !== 'win32') { - return 'git'; - } - - if (resolvedGitBinary) { - return resolvedGitBinary; - } - - const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY] - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter(Boolean); - for (const candidate of explicit) { - if (isExecutable(candidate)) { - resolvedGitBinary = candidate; - return resolvedGitBinary; - } - } - - const candidates = []; - const normalizeGitCandidate = (candidate) => { - if (typeof candidate !== 'string') { - return ''; - } - const trimmed = candidate.trim(); - if (!trimmed) { - return ''; - } - const ext = path.extname(trimmed).toLowerCase(); - if (ext === '.cmd' || ext === '.bat' || ext === '.com') { - const exeCandidate = trimmed.slice(0, -ext.length) + '.exe'; - if (isExecutable(exeCandidate)) { - return exeCandidate; - } - } - return trimmed; - }; - - const pathCandidate = normalizeGitCandidate(searchPathFor('git')); - if (pathCandidate && isExecutable(pathCandidate)) { - candidates.push(pathCandidate); - } - - const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe')); - if (pathExeCandidate && isExecutable(pathExeCandidate)) { - candidates.push(pathExeCandidate); - } - - const programRoots = [ - process.env.ProgramFiles, - process.env['ProgramFiles(x86)'], - process.env.LocalAppData, - ] - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter(Boolean); - for (const root of programRoots) { - const installCandidates = [ - path.join(root, 'Git', 'cmd', 'git.exe'), - path.join(root, 'Git', 'bin', 'git.exe'), - path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'), - path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'), - path.join(root, 'Programs', 'Git', 'bin', 'git.exe'), - ]; - for (const candidate of installCandidates) { - const normalized = normalizeGitCandidate(candidate); - if (normalized && isExecutable(normalized)) { - candidates.push(normalized); - } - } - } - - const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe')); - resolvedGitBinary = preferredExe || candidates[0] || 'git.exe'; - return resolvedGitBinary; -} - -function isExecutable(filePath) { - try { - const stat = fs.statSync(filePath); - if (!stat.isFile()) return false; - if (process.platform === 'win32') { - const ext = path.extname(filePath).toLowerCase(); - if (!ext) return true; - return ['.exe', '.cmd', '.bat', '.com'].includes(ext); - } - fs.accessSync(filePath, fs.constants.X_OK); - return true; - } catch { - return false; - } -} - -function prependToPath(dir) { - const trimmed = typeof dir === 'string' ? dir.trim() : ''; - if (!trimmed) return; - const current = process.env.PATH || ''; - const parts = current.split(path.delimiter).filter(Boolean); - if (parts.includes(trimmed)) return; - process.env.PATH = [trimmed, ...parts].join(path.delimiter); -} - -function searchPathFor(binaryName) { - const current = process.env.PATH || ''; - const parts = current.split(path.delimiter).filter(Boolean); - for (const dir of parts) { - const candidate = path.join(dir, binaryName); - if (isExecutable(candidate)) { - return candidate; - } - } - return null; -} - -function isWslExecutableValue(value) { - if (typeof value !== 'string') return false; - const trimmed = value.trim(); - if (!trimmed) return false; - return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); -} - -function clearWslOpencodeResolution() { - useWslForOpencode = false; - resolvedWslBinary = null; - resolvedWslOpencodePath = null; - resolvedWslDistro = null; -} - -function resolveWslExecutablePath() { - if (process.platform !== 'win32') { - return null; - } - - const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - return candidate; - } - } - - try { - const result = spawnSync('where', ['wsl'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) { - return found; - } - } - } catch { - // ignore - } - - const systemRoot = process.env.SystemRoot || 'C:\\Windows'; - const fallback = path.join(systemRoot, 'System32', 'wsl.exe'); - if (isExecutable(fallback)) { - return fallback; - } - - return null; -} - -function buildWslExecArgs(execArgs, distroOverride = null) { - const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0 - ? distroOverride.trim() - : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; - - const prefix = distro ? ['-d', distro] : []; - return [...prefix, '--exec', ...execArgs]; -} - -function probeWslForOpencode() { - if (process.platform !== 'win32') { - return null; - } - - const wslBinary = resolveWslExecutablePath(); - if (!wslBinary) { - return null; - } - - try { - const result = spawnSync( - wslBinary, - buildWslExecArgs(['sh', '-lc', 'command -v opencode']), - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 6000, - windowsHide: true, - }, - ); - - if (result.status !== 0) { - return null; - } - - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines[0] || ''; - if (!found) { - return null; - } - - return { - wslBinary, - opencodePath: found, - distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO, - }; - } catch { - return null; - } -} - -function applyWslOpencodeResolution({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) { - const resolvedWsl = wslBinary || resolveWslExecutablePath(); - if (!resolvedWsl) { - return null; - } - - useWslForOpencode = true; - resolvedWslBinary = resolvedWsl; - resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0 - ? opencodePath.trim() - : 'opencode'; - resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; - resolvedOpencodeBinary = `wsl:${resolvedWslOpencodePath}`; - resolvedOpencodeBinarySource = source; - - // Keep OPENCODE_BINARY empty in WSL mode to avoid native spawn attempts. - delete process.env.OPENCODE_BINARY; - return resolvedOpencodeBinary; -} - -function resolveOpencodeCliPath() { - const explicit = [ - process.env.OPENCODE_BINARY, - process.env.OPENCODE_PATH, - process.env.OPENCHAMBER_OPENCODE_PATH, - process.env.OPENCHAMBER_OPENCODE_BIN, - ] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'env'; - return candidate; - } - } - - const resolvedFromPath = searchPathFor('opencode'); - if (resolvedFromPath) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'path'; - return resolvedFromPath; - } - - const home = os.homedir(); - const unixFallbacks = [ - path.join(home, '.opencode', 'bin', 'opencode'), - path.join(home, '.bun', 'bin', 'opencode'), - path.join(home, '.local', 'bin', 'opencode'), - path.join(home, 'bin', 'opencode'), - '/opt/homebrew/bin/opencode', - '/usr/local/bin/opencode', - '/usr/bin/opencode', - '/bin/opencode', - ]; - - const winFallbacks = (() => { - const userProfile = process.env.USERPROFILE || home; - const appData = process.env.APPDATA || ''; - const localAppData = process.env.LOCALAPPDATA || ''; - const programData = process.env.ProgramData || 'C:\\ProgramData'; - - return [ - path.join(userProfile, '.opencode', 'bin', 'opencode.exe'), - path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'), - path.join(appData, 'npm', 'opencode.cmd'), - path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'), - path.join(programData, 'chocolatey', 'bin', 'opencode.exe'), - path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), - path.join(userProfile, '.bun', 'bin', 'opencode.exe'), - path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', - ].filter(Boolean); - })(); - - const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; - for (const candidate of fallbacks) { - if (isExecutable(candidate)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'fallback'; - return candidate; - } - } - - if (process.platform === 'win32') { - try { - const result = spawnSync('where', ['opencode'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'where'; - return found; - } - } - } catch { - // ignore - } - const wsl = probeWslForOpencode(); - if (wsl) { - return applyWslOpencodeResolution({ - wslBinary: wsl.wslBinary, - opencodePath: wsl.opencodePath, - source: 'wsl', - distro: wsl.distro, - }); - } - return null; - } - - const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - for (const shell of shells) { - if (!isExecutable(shell)) continue; - try { - const result = spawnSync(shell, ['-lic', 'command -v opencode'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; - if (found && isExecutable(found)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinarySource = 'shell'; - return found; - } - } - } catch { - // ignore - } - } - - return null; -} - -function resolveNodeCliPath() { - const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - return candidate; - } - } - - const resolvedFromPath = searchPathFor('node'); - if (resolvedFromPath) { - return resolvedFromPath; - } - - const unixFallbacks = [ - '/opt/homebrew/bin/node', - '/usr/local/bin/node', - '/usr/bin/node', - '/bin/node', - ]; - for (const candidate of unixFallbacks) { - if (isExecutable(candidate)) { - return candidate; - } - } - - if (process.platform === 'win32') { - try { - const result = spawnSync('where', ['node'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) return found; - } - } catch { - // ignore - } - return null; - } - - const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - for (const shell of shells) { - if (!isExecutable(shell)) continue; - try { - const result = spawnSync(shell, ['-lic', 'command -v node'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; - if (found && isExecutable(found)) { - return found; - } - } - } catch { - // ignore - } - } - - return null; -} - -function resolveBunCliPath() { - const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY] - .map((v) => (typeof v === 'string' ? v.trim() : '')) - .filter(Boolean); - - for (const candidate of explicit) { - if (isExecutable(candidate)) { - return candidate; - } - } - - const resolvedFromPath = searchPathFor('bun'); - if (resolvedFromPath) { - return resolvedFromPath; - } - - const home = os.homedir(); - const unixFallbacks = [ - path.join(home, '.bun', 'bin', 'bun'), - '/opt/homebrew/bin/bun', - '/usr/local/bin/bun', - '/usr/bin/bun', - '/bin/bun', - ]; - for (const candidate of unixFallbacks) { - if (isExecutable(candidate)) { - return candidate; - } - } - - if (process.platform === 'win32') { - const userProfile = process.env.USERPROFILE || home; - const winFallbacks = [ - path.join(userProfile, '.bun', 'bin', 'bun.exe'), - path.join(userProfile, '.bun', 'bin', 'bun.cmd'), - ]; - for (const candidate of winFallbacks) { - if (isExecutable(candidate)) return candidate; - } - - try { - const result = spawnSync('where', ['bun'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const lines = (result.stdout || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); - if (found) return found; - } - } catch { - // ignore - } - return null; - } - - const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); - for (const shell of shells) { - if (!isExecutable(shell)) continue; - try { - const result = spawnSync(shell, ['-lic', 'command -v bun'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.status === 0) { - const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; - if (found && isExecutable(found)) { - return found; - } - } - } catch { - // ignore - } - } - - return null; -} - -function ensureBunCliEnv() { - if (resolvedBunBinary) { - return resolvedBunBinary; - } - - const resolved = resolveBunCliPath(); - if (resolved) { - prependToPath(path.dirname(resolved)); - resolvedBunBinary = resolved; - return resolved; - } - - return null; -} - -function ensureNodeCliEnv() { - if (resolvedNodeBinary) { - return resolvedNodeBinary; - } - - const resolved = resolveNodeCliPath(); - if (resolved) { - prependToPath(path.dirname(resolved)); - resolvedNodeBinary = resolved; - return resolved; - } - - return null; -} - -function readShebang(opencodePath) { - if (!opencodePath || typeof opencodePath !== 'string') { - return null; - } - try { - // Best effort: detect "#!/usr/bin/env " without reading whole file. - const fd = fs.openSync(opencodePath, 'r'); - try { - const buf = Buffer.alloc(256); - const bytes = fs.readSync(fd, buf, 0, buf.length, 0); - const head = buf.subarray(0, bytes).toString('utf8'); - const firstLine = head.split(/\r?\n/, 1)[0] || ''; - if (!firstLine.startsWith('#!')) { - return null; - } - const shebang = firstLine.slice(2).trim(); - if (!shebang) { - return null; - } - return shebang; - } finally { - try { - fs.closeSync(fd); - } catch { - // ignore - } - } - } catch { - return null; - } -} - -function opencodeShimInterpreter(opencodePath) { - const shebang = readShebang(opencodePath); - if (!shebang) return null; - if (/\bnode\b/i.test(shebang)) return 'node'; - if (/\bbun\b/i.test(shebang)) return 'bun'; - return null; -} - -function ensureOpencodeShimRuntime(opencodePath) { - const runtime = opencodeShimInterpreter(opencodePath); - if (runtime === 'node') { - ensureNodeCliEnv(); - } - if (runtime === 'bun') { - ensureBunCliEnv(); - } -} - -function normalizeOpencodeBinarySetting(raw) { - if (typeof raw !== 'string') { - return null; - } - const trimmed = normalizeDirectoryPath(raw).trim(); - if (!trimmed) { - return ''; - } - - try { - const stat = fs.statSync(trimmed); - if (stat.isDirectory()) { - const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; - return path.join(trimmed, bin); - } - } catch { - // ignore - } - - return trimmed; -} - -async function applyOpencodeBinaryFromSettings() { - try { - const settings = await readSettingsFromDiskMigrated(); - if (!settings || typeof settings !== 'object') { - return null; - } - if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) { - return null; - } - - const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary); - - if (normalized === '') { - delete process.env.OPENCODE_BINARY; - resolvedOpencodeBinary = null; - resolvedOpencodeBinarySource = null; - clearWslOpencodeResolution(); - return null; - } - - const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; - - const explicitWslPath = process.platform === 'win32' && typeof raw === 'string' - ? raw.match(/^wsl:\s*(.+)$/i) - : null; - - if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) { - const probe = probeWslForOpencode(); - const applied = applyWslOpencodeResolution({ - wslBinary: probe?.wslBinary || resolveWslExecutablePath(), - opencodePath: explicitWslPath[1].trim(), - source: 'settings-wsl-path', - distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, - }); - if (applied) { - return applied; - } - } - - if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) { - const probe = probeWslForOpencode(); - const applied = applyWslOpencodeResolution({ - wslBinary: probe?.wslBinary || normalized || raw || null, - opencodePath: probe?.opencodePath || 'opencode', - source: 'settings-wsl', - distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, - }); - if (applied) { - return applied; - } - } - - if (normalized && isExecutable(normalized)) { - clearWslOpencodeResolution(); - process.env.OPENCODE_BINARY = normalized; - prependToPath(path.dirname(normalized)); - resolvedOpencodeBinary = normalized; - resolvedOpencodeBinarySource = 'settings'; - ensureOpencodeShimRuntime(normalized); - return normalized; - } - - if (raw) { - console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); - } - } catch { - // ignore - } - - return null; -} - -function ensureOpencodeCliEnv() { - if (resolvedOpencodeBinary) { - if (useWslForOpencode) { - return resolvedOpencodeBinary; - } - ensureOpencodeShimRuntime(resolvedOpencodeBinary); - return resolvedOpencodeBinary; - } - - const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; - if (existing && isExecutable(existing)) { - clearWslOpencodeResolution(); - resolvedOpencodeBinary = existing; - resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env'; - prependToPath(path.dirname(existing)); - ensureOpencodeShimRuntime(existing); - return resolvedOpencodeBinary; - } - - const resolved = resolveOpencodeCliPath(); - if (resolved) { - if (useWslForOpencode) { - resolvedOpencodeBinary = resolved; - resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'wsl'; - console.log(`Resolved opencode CLI via WSL: ${resolvedWslOpencodePath || 'opencode'}`); - return resolved; - } - - process.env.OPENCODE_BINARY = resolved; - prependToPath(path.dirname(resolved)); - ensureOpencodeShimRuntime(resolved); - resolvedOpencodeBinary = resolved; - resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'unknown'; - console.log(`Resolved opencode CLI: ${resolved}`); - return resolved; - } - - clearWslOpencodeResolution(); - return null; -} - -const startGlobalEventWatcher = async () => { - if (globalEventWatcherAbortController) { - return; - } - - await waitForOpenCodePort(); - - globalEventWatcherAbortController = new AbortController(); - const signal = globalEventWatcherAbortController.signal; - - let attempt = 0; - - const run = async () => { - while (!signal.aborted) { - attempt += 1; - let upstream; - let reader; - try { - const url = buildOpenCodeUrl('/global/event', ''); - upstream = await fetch(url, { - headers: { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...getOpenCodeAuthHeaders(), - }, - signal, - }); - - if (!upstream.ok || !upstream.body) { - throw new Error(`bad status ${upstream.status}`); - } - - console.log('[PushWatcher] connected'); - - const decoder = new TextDecoder(); - reader = upstream.body.getReader(); - let buffer = ''; - - while (!signal.aborted) { - const { value, done } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - - let separatorIndex = buffer.indexOf('\n\n'); - while (separatorIndex !== -1) { - const block = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - separatorIndex = buffer.indexOf('\n\n'); - const payload = parseSseDataPayload(block); - // Cache session titles from session.updated/session.created events - maybeCacheSessionInfoFromEvent(payload); - void maybeSendPushForTrigger(payload); - // Track session activity independently of UI (mirrors Tauri desktop behavior) - const transitions = deriveSessionActivityTransitions(payload); - if (transitions && transitions.length > 0) { - for (const activity of transitions) { - setSessionActivityPhase(activity.sessionId, activity.phase); - } - } - - // Update authoritative session state from OpenCode events - if (payload && payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, { - attempt: update.attempt, - message: update.message, - next: update.next, - }); - } - } - } - } - } catch (error) { - if (signal.aborted) { - return; - } - console.warn('[PushWatcher] disconnected', error?.message ?? error); - } finally { - try { - if (reader) { - await reader.cancel(); - reader.releaseLock(); - } else if (upstream?.body && !upstream.body.locked) { - await upstream.body.cancel(); - } - } catch { - // ignore - } - } - - const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000); - await new Promise((r) => setTimeout(r, backoffMs)); - } - }; - - void run(); -}; - -const stopGlobalEventWatcher = () => { - if (!globalEventWatcherAbortController) { - return; - } - try { - globalEventWatcherAbortController.abort(); - } catch { - // ignore - } - globalEventWatcherAbortController = null; -}; - - -function setOpenCodePort(port) { - if (!Number.isFinite(port) || port <= 0) { - return; - } - - const numericPort = Math.trunc(port); - const portChanged = openCodePort !== numericPort; - - if (portChanged || openCodePort === null) { - openCodePort = numericPort; - syncToHmrState(); - console.log(`Detected OpenCode port: ${openCodePort}`); - - if (portChanged) { - isOpenCodeReady = false; - } - openCodeNotReadySince = Date.now(); - } - - lastOpenCodeError = null; -} - -async function waitForOpenCodePort(timeoutMs = 15000) { - if (openCodePort !== null) { - return openCodePort; - } - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 50)); - if (openCodePort !== null) { - return openCodePort; - } - } - - throw new Error('Timed out waiting for OpenCode port'); -} - -function getLoginShellPath() { - const snapshot = getLoginShellEnvSnapshot(); - if (!snapshot || typeof snapshot.PATH !== 'string' || snapshot.PATH.length === 0) { - return null; - } - return snapshot.PATH; -} - -function buildAugmentedPath() { - const augmented = new Set(); - - const loginShellPath = getLoginShellPath(); - if (loginShellPath) { - for (const segment of loginShellPath.split(path.delimiter)) { - if (segment) { - augmented.add(segment); - } - } - } - - const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean); - for (const segment of current) { - augmented.add(segment); - } - - return Array.from(augmented).join(path.delimiter); -} - -const API_PREFIX_CANDIDATES = ['']; - -async function waitForReady(url, timeoutMs = 10000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - const res = await fetch(`${url.replace(/\/+$/, '')}/global/health`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: controller.signal - }); - clearTimeout(timeout); - - if (res.ok) { - const body = await res.json().catch(() => null); - if (body?.healthy === true) { - return true; - } - } - } catch { - // ignore - } - await new Promise(r => setTimeout(r, 100)); - } - return false; -} - -function normalizeApiPrefix(prefix) { - if (!prefix) { - return ''; - } - - if (prefix.includes('://')) { - try { - const parsed = new URL(prefix); - return normalizeApiPrefix(parsed.pathname); - } catch (error) { - return ''; - } - } - - const trimmed = prefix.trim(); - if (!trimmed || trimmed === '/') { - return ''; - } - const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading; -} - -function setDetectedOpenCodeApiPrefix() { - openCodeApiPrefix = ''; - openCodeApiPrefixDetected = true; - if (openCodeApiDetectionTimer) { - clearTimeout(openCodeApiDetectionTimer); - openCodeApiDetectionTimer = null; - } -} - -function getCandidateApiPrefixes() { - return API_PREFIX_CANDIDATES; -} - -function buildOpenCodeUrl(path, prefixOverride) { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : ''); - const fullPath = `${prefix}${normalizedPath}`; - const base = openCodeBaseUrl ?? `http://localhost:${openCodePort}`; - return `${base}${fullPath}`; -} - -function parseSseDataPayload(block) { - if (!block || typeof block !== 'string') { - return null; - } - const dataLines = block - .split('\n') - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).replace(/^\s/, '')); - - if (dataLines.length === 0) { - return null; - } - - const payloadText = dataLines.join('\n').trim(); - if (!payloadText) { - return null; - } - - try { - const parsed = JSON.parse(payloadText); - if ( - parsed && - typeof parsed === 'object' && - typeof parsed.payload === 'object' && - parsed.payload !== null - ) { - return parsed.payload; - } - return parsed; - } catch { - return null; - } -} - -function extractSessionStatusUpdate(payload) { - if (!payload || typeof payload !== 'object' || payload.type !== 'session.status') { - return null; - } - - const props = payload.properties ?? {}; - const status = - props.status ?? - props.session?.status ?? - props.sessionInfo?.status; - const metadata = - props.metadata ?? - (typeof status === 'object' && status !== null ? status.metadata : null); - - const sessionId = props.sessionID ?? props.sessionId; - if (typeof sessionId !== 'string' || sessionId.length === 0) { - return null; - } - - const statusType = - typeof status === 'string' - ? status - : typeof status?.type === 'string' - ? status.type - : typeof status?.status === 'string' - ? status.status - : typeof props.type === 'string' - ? props.type - : typeof props.phase === 'string' - ? props.phase - : typeof props.state === 'string' - ? props.state - : null; - - const normalizedType = - statusType === 'idle' || statusType === 'busy' || statusType === 'retry' - ? statusType - : null; - - if (!normalizedType) { - return null; - } - - const attempt = - typeof status?.attempt === 'number' - ? status.attempt - : typeof props.attempt === 'number' - ? props.attempt - : typeof metadata?.attempt === 'number' - ? metadata.attempt - : undefined; - const message = - typeof status?.message === 'string' - ? status.message - : typeof props.message === 'string' - ? props.message - : typeof metadata?.message === 'string' - ? metadata.message - : undefined; - const next = - typeof status?.next === 'number' - ? status.next - : typeof props.next === 'number' - ? props.next - : typeof metadata?.next === 'number' - ? metadata.next - : undefined; - - return { - sessionId, - type: normalizedType, - attempt, - message, - next, - eventId: typeof props.eventId === 'string' ? props.eventId : null, - }; -} - -function emitDesktopNotification(payload) { - if (!ENV_DESKTOP_NOTIFY) { - return; - } - - if (!payload || typeof payload !== 'object') { - return; - } - - try { - // One-line protocol consumed by the Tauri shell. - process.stdout.write(`${DESKTOP_NOTIFY_PREFIX}${JSON.stringify(payload)}\n`); - } catch { - // ignore - } -} - -function broadcastUiNotification(payload) { - if (!payload || typeof payload !== 'object') { - return; - } - - if (uiNotificationClients.size === 0) { - return; - } - - for (const res of uiNotificationClients) { - try { - writeSseEvent(res, { - type: 'openchamber:notification', - properties: { - ...payload, - // Tell the UI whether the sidecar stdout notification channel is active. - // When true, the desktop UI should skip this SSE notification to avoid duplicates. - // When false (e.g. tauri dev), the UI must handle this SSE notification itself. - desktopStdoutActive: ENV_DESKTOP_NOTIFY, - }, - }); - } catch { - // ignore - } - } -} - -function isStreamingAssistantPart(properties) { - if (!properties || typeof properties !== 'object') { - return false; - } - - const info = properties?.info; - const role = info?.role; - if (role !== 'assistant') { - return false; - } - - const part = properties?.part; - const partType = part?.type; - return ( - partType === 'step-start' || - partType === 'text' || - partType === 'tool' || - partType === 'reasoning' || - partType === 'file' || - partType === 'patch' - ); -} - -function deriveSessionActivityTransitions(payload) { - if (!payload || typeof payload !== 'object') { - return []; - } - - if (payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - const phase = update.type === 'busy' || update.type === 'retry' ? 'busy' : 'idle'; - return [{ sessionId: update.sessionId, phase }]; - } - } - - if (payload.type === 'message.updated') { - const info = payload.properties?.info; - const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId; - const role = info?.role; - const finish = info?.finish; - if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { - return [{ sessionId, phase: 'cooldown' }]; - } - } - - if (payload.type === 'message.part.updated' || payload.type === 'message.part.delta') { - const info = payload.properties?.info; - const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId; - const role = info?.role; - const finish = info?.finish; - - if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant') { - const transitions = []; - - // Desktop parity: mark busy when we see assistant parts streaming. - if (isStreamingAssistantPart(payload.properties)) { - transitions.push({ sessionId, phase: 'busy' }); - } - - // Desktop parity: enter cooldown when finish==stop. - if (finish === 'stop') { - transitions.push({ sessionId, phase: 'cooldown' }); - } - - return transitions; - } - } - - if (payload.type === 'session.idle') { - const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId; - if (typeof sessionId === 'string' && sessionId.length > 0) { - return [{ sessionId, phase: 'idle' }]; - } - } - - return []; -} - -const PUSH_READY_COOLDOWN_MS = 5000; -const PUSH_QUESTION_DEBOUNCE_MS = 500; -const PUSH_PERMISSION_DEBOUNCE_MS = 500; -const pushQuestionDebounceTimers = new Map(); -const pushPermissionDebounceTimers = new Map(); -const notifiedPermissionRequests = new Set(); -const lastReadyNotificationAt = new Map(); - -// Cache: sessionId -> parentID (string) or null (no parent). Undefined = unknown. -const sessionParentIdCache = new Map(); -const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000; - -const getCachedSessionParentId = (sessionId) => { - const entry = sessionParentIdCache.get(sessionId); - if (!entry) return undefined; - if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) { - sessionParentIdCache.delete(sessionId); - return undefined; - } - return entry.parentID; -}; - -const setCachedSessionParentId = (sessionId, parentID) => { - sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() }); -}; - -const fetchSessionParentId = async (sessionId) => { - if (!sessionId) return undefined; - - const cached = getCachedSessionParentId(sessionId); - if (cached !== undefined) return cached; - - try { - const response = await fetch(buildOpenCodeUrl('/session', ''), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(2000), - }); - if (!response.ok) { - return undefined; - } - const data = await response.json().catch(() => null); - if (!Array.isArray(data)) { - return undefined; - } - - const match = data.find((s) => s && typeof s === 'object' && s.id === sessionId); - const parentID = match?.parentID ? match.parentID : null; - setCachedSessionParentId(sessionId, parentID); - return parentID; - } catch { - return undefined; - } -}; - -const extractSessionIdFromPayload = (payload) => { - if (!payload || typeof payload !== 'object') return null; - const props = payload.properties; - const info = props?.info; - const sessionId = - info?.sessionID ?? - info?.sessionId ?? - props?.sessionID ?? - props?.sessionId ?? - props?.session ?? - null; - return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null; -}; - -const maybeSendPushForTrigger = async (payload) => { - if (!payload || typeof payload !== 'object') { - return; - } - - const sessionId = extractSessionIdFromPayload(payload); - - const formatMode = (raw) => { - const value = typeof raw === 'string' ? raw.trim() : ''; - const normalized = value.length > 0 ? value : 'agent'; - return normalized - .split(/[-_\s]+/) - .filter(Boolean) - .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) - .join(' '); - }; - - const formatModelId = (raw) => { - const value = typeof raw === 'string' ? raw.trim() : ''; - if (!value) { - return 'Assistant'; - } - - const tokens = value.split(/[-_]+/).filter(Boolean); - const result = []; - for (let i = 0; i < tokens.length; i += 1) { - const current = tokens[i]; - const next = tokens[i + 1]; - if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) { - result.push(`${current}.${next}`); - i += 1; - continue; - } - result.push(current); - } - - return result - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); - }; - - if (payload.type === 'message.updated') { - const info = payload.properties?.info; - if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) { - // Check if this is a subtask and if we should notify for subtasks - const settings = await readSettingsFromDisk(); - - if (settings.notifyOnSubtasks === false) { - // Prefer parentID on payload (if present), else fetch from sessions list. - const sessionInfo = payload.properties?.session; - const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID; - const parentID = parentIDFromPayload - ? parentIDFromPayload - : await fetchSessionParentId(sessionId); - - // Fail open: if parentID cannot be resolved, send notification. - if (parentID) { - return; - } - } - - // Check if completion notifications are enabled - if (settings.notifyOnCompletion === false) { - return; - } - - const now = Date.now(); - const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0; - if (now - lastAt < PUSH_READY_COOLDOWN_MS) { - return; - } - lastReadyNotificationAt.set(sessionId, now); - - // Resolve templates with fallback to legacy hardcoded values - let title = `${formatMode(info?.mode)} agent is ready`; - let body = `${formatModelId(info?.modelID)} completed the task`; - - try { - const templates = settings.notificationTemplates || {}; - const isSubtask = await fetchSessionParentId(sessionId); - const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false - ? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }) - : (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }); - - const variables = await buildTemplateVariables(payload, sessionId); - - // Try fast-path (inline parts) first, then fetch from API - const messageId = info?.id; - let lastMessage = extractLastMessageText(payload); - if (!lastMessage) { - lastMessage = await fetchLastAssistantMessageText(sessionId, messageId); - } - - const notifZenModel = await resolveZenModel(settings?.zenModel); - variables.last_message = await prepareNotificationLastMessage({ - message: lastMessage, - settings, - summarize: (text, len) => summarizeText(text, len, notifZenModel), - }); - - const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - const notificationPayload = { - title, - body, - tag: `ready-${sessionId}`, - kind: 'ready', - sessionId, - requireHidden: settings.notificationMode !== 'always', - }; - emitDesktopNotification(notificationPayload); - broadcastUiNotification(notificationPayload); - } - - await sendPushToAllUiSessions( - { - title, - body, - tag: `ready-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'ready', - } - }, - { requireNoSse: true } - ); - } - - // Check for error finish - if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) { - const settings = await readSettingsFromDisk(); - if (settings.notifyOnError === false) return; - - let title = 'Tool error'; - let body = 'An error occurred'; - - try { - const variables = await buildTemplateVariables(payload, sessionId); - - // Try fast-path (inline parts) first, then fetch from API - const errorMessageId = info?.id; - let lastMessage = extractLastMessageText(payload); - if (!lastMessage) { - lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId); - } - - const errZenModel = await resolveZenModel(settings?.zenModel); - variables.last_message = await prepareNotificationLastMessage({ - message: lastMessage, - settings, - summarize: (text, len) => summarizeText(text, len, errZenModel), - }); - - const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' }; - const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Error template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - const notificationPayload = { - title, - body, - tag: `error-${sessionId}`, - kind: 'error', - sessionId, - requireHidden: settings.notificationMode !== 'always', - }; - emitDesktopNotification(notificationPayload); - broadcastUiNotification(notificationPayload); - } - - await sendPushToAllUiSessions( - { - title, - body, - tag: `error-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'error', - } - }, - { requireNoSse: true } - ); - } - - return; - } - - - if (payload.type === 'question.asked' && sessionId) { - const existingTimer = pushQuestionDebounceTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(async () => { - pushQuestionDebounceTimers.delete(sessionId); - - const settings = await readSettingsFromDisk(); - - // Check if question notifications are enabled - if (settings.notifyOnQuestion === false) { - return; - } - - if (!settings.nativeNotificationsEnabled) { - // Still send push even if native notifications are disabled - } - - const firstQuestion = payload.properties?.questions?.[0]; - const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; - const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; - - // Legacy fallback title - let title = /plan\s*mode/i.test(header) - ? 'Switch to plan mode' - : /build\s*agent/i.test(header) - ? 'Switch to build mode' - : header || 'Input needed'; - let body = questionText || 'Agent is waiting for your response'; - - try { - // Build template variables - const variables = await buildTemplateVariables(payload, sessionId); - variables.last_message = questionText || header || ''; - - // Get question template - const templates = settings.notificationTemplates || {}; - const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' }; - - // Resolve templates with fallback to legacy behavior - const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Question template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - emitDesktopNotification({ - kind: 'question', - title, - body, - tag: `question-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - - broadcastUiNotification({ - kind: 'question', - title, - body, - tag: `question-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - } - - void sendPushToAllUiSessions( - { - title, - body, - tag: `question-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'question', - } - }, - { requireNoSse: true } - ); - }, PUSH_QUESTION_DEBOUNCE_MS); - - pushQuestionDebounceTimers.set(sessionId, timer); - return; - } - - if (payload.type === 'permission.asked' && sessionId) { - const requestId = payload.properties?.id; - const permission = payload.properties?.permission; - const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; - if (requestKey && notifiedPermissionRequests.has(requestKey)) { - return; - } - - const existingTimer = pushPermissionDebounceTimers.get(sessionId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(async () => { - pushPermissionDebounceTimers.delete(sessionId); - const settings = await readSettingsFromDisk(); - - // Permission requests use the question event toggle (since permission requests are a type of "agent needs input") - if (settings.notifyOnQuestion === false) { - return; - } - - if (!settings.nativeNotificationsEnabled) { - // Still send push even if native notifications are disabled - } - - const sessionTitle = payload.properties?.sessionTitle; - const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : ''; - const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0 - ? sessionTitle.trim() - : permissionText || 'Agent is waiting for your approval'; - - let title = 'Permission required'; - let body = fallbackMessage; - - try { - // Build template variables - const variables = await buildTemplateVariables(payload, sessionId); - variables.last_message = fallbackMessage; - - // Get question template (permission uses question template since it's an input request) - const templates = settings.notificationTemplates || {}; - const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' }; - - // Resolve templates with fallback to legacy behavior - const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); - const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); - if (resolvedTitle) title = resolvedTitle; - if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; - } catch (err) { - console.warn('[Notification] Permission template resolution failed, using defaults:', err?.message || err); - } - - if (settings.nativeNotificationsEnabled) { - emitDesktopNotification({ - kind: 'permission', - title, - body, - tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - - broadcastUiNotification({ - kind: 'permission', - title, - body, - tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, - sessionId, - requireHidden: settings.notificationMode !== 'always', - }); - } - - if (requestKey) { - notifiedPermissionRequests.add(requestKey); - } - - void sendPushToAllUiSessions( - { - title, - body, - tag: `permission-${sessionId}`, - data: { - url: buildSessionDeepLinkUrl(sessionId), - sessionId, - type: 'permission', - } - }, - { requireNoSse: true } - ); - }, PUSH_PERMISSION_DEBOUNCE_MS); - - pushPermissionDebounceTimers.set(sessionId, timer); - } -}; - -function writeSseEvent(res, payload) { - res.write(`data: ${JSON.stringify(payload)}\n\n`); -} - -function extractApiPrefixFromUrl() { - return ''; -} - -function detectOpenCodeApiPrefix() { - openCodeApiPrefixDetected = true; - openCodeApiPrefix = ''; - return true; -} - -function ensureOpenCodeApiPrefix() { - return detectOpenCodeApiPrefix(); -} - -function scheduleOpenCodeApiDetection() { - return; -} - -function parseArgs(argv = process.argv.slice(2)) { - const args = Array.isArray(argv) ? [...argv] : []; - const envPassword = - process.env.OPENCHAMBER_UI_PASSWORD || - process.env.OPENCODE_UI_PASSWORD || - null; - const envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true'; - const envTunnelProvider = process.env.OPENCHAMBER_TUNNEL_PROVIDER || undefined; - const envTunnelMode = process.env.OPENCHAMBER_TUNNEL_MODE || undefined; - const envTunnelConfigRaw = process.env.OPENCHAMBER_TUNNEL_CONFIG; - const envTunnelConfig = typeof envTunnelConfigRaw === 'string' - ? (envTunnelConfigRaw.trim().length > 0 ? envTunnelConfigRaw.trim() : null) - : undefined; - const envTunnelToken = process.env.OPENCHAMBER_TUNNEL_TOKEN || undefined; - const envTunnelHostname = process.env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined; - - const options = { - port: DEFAULT_PORT, - host: undefined, - uiPassword: envPassword, - tryCfTunnel: envCfTunnel, - tunnelProvider: envTunnelProvider, - tunnelMode: envTunnelMode, - tunnelConfigPath: envTunnelConfig, - tunnelToken: envTunnelToken, - tunnelHostname: envTunnelHostname, - }; - - const consumeValue = (currentIndex, inlineValue) => { - if (typeof inlineValue === 'string') { - return { value: inlineValue, nextIndex: currentIndex }; - } - const nextArg = args[currentIndex + 1]; - if (typeof nextArg === 'string' && !nextArg.startsWith('--')) { - return { value: nextArg, nextIndex: currentIndex + 1 }; - } - return { value: undefined, nextIndex: currentIndex }; - }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (!arg.startsWith('--')) { - continue; - } - - const eqIndex = arg.indexOf('='); - const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2); - const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined; - - if (optionName === 'port' || optionName === 'p') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - const parsedPort = parseInt(value ?? '', 10); - options.port = Number.isFinite(parsedPort) ? parsedPort : DEFAULT_PORT; - continue; - } - - if (optionName === 'host') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.host = typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; - continue; - } - - if (optionName === 'ui-password') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.uiPassword = typeof value === 'string' ? value : ''; - continue; - } - - if (optionName === 'try-cf-tunnel') { - options.tryCfTunnel = true; - continue; - } - - if (optionName === 'tunnel-provider') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelProvider = typeof value === 'string' ? value : options.tunnelProvider; - continue; - } - - if (optionName === 'tunnel-mode') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelMode = typeof value === 'string' ? value : options.tunnelMode; - continue; - } - - if (optionName === 'tunnel-config') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelConfigPath = typeof value === 'string' ? value : null; - continue; - } - - if (optionName === 'tunnel-token') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelToken = typeof value === 'string' ? value : options.tunnelToken; - continue; - } - - if (optionName === 'tunnel-hostname') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelHostname = typeof value === 'string' ? value : options.tunnelHostname; - continue; - } - - if (optionName === 'tunnel') { - const { value, nextIndex } = consumeValue(i, inlineValue); - i = nextIndex; - options.tunnelProvider = TUNNEL_PROVIDER_CLOUDFLARE; - options.tunnelMode = TUNNEL_MODE_MANAGED_LOCAL; - options.tunnelConfigPath = typeof value === 'string' ? value : null; - continue; - } - } - - return options; -} - -function killProcessOnPort(port) { - if (!port) return; - try { - // Kill any process listening on our port to clean up orphaned children. - const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true }); - const output = result.stdout || ''; - const myPid = process.pid; - for (const pidStr of output.split(/\s+/)) { - const pid = parseInt(pidStr.trim(), 10); - if (pid && pid !== myPid) { - try { - spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 }); - } catch { - // Ignore - } - } - } - } catch { - // Ignore - process may already be dead - } -} - -async function createManagedOpenCodeServerProcess({ - hostname, - port, - timeout, - cwd, - env, -}) { - let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; - let args = ['serve', '--hostname', hostname, '--port', String(port)]; - - if (process.platform === 'win32' && useWslForOpencode) { - const wslBinary = resolvedWslBinary || resolveWslExecutablePath(); - if (!wslBinary) { - throw new Error('WSL executable not found while attempting to launch OpenCode from WSL'); - } - - const wslOpencode = resolvedWslOpencodePath && resolvedWslOpencodePath.trim().length > 0 - ? resolvedWslOpencodePath.trim() - : 'opencode'; - const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname; - - binary = wslBinary; - args = buildWslExecArgs([ - wslOpencode, - 'serve', - '--hostname', - serveHost, - '--port', - String(port), - ], resolvedWslDistro); - } - - // On Windows, Bun/Node cannot directly spawn shell wrapper scripts (#!/bin/sh). - // Detect if the resolved binary is a shim that wraps a Node/Bun script and - // resolve the actual target so we can spawn it with the correct interpreter. - if (process.platform === 'win32' && !useWslForOpencode) { - const interpreter = opencodeShimInterpreter(binary); - if (interpreter) { - // Binary itself has a node/bun shebang – spawn via that interpreter. - args.unshift(binary); - binary = interpreter; - } else { - // The wrapper might be a shell shim generated by npm. Try to find the - // real JS entry point next to it (e.g. node_modules/opencode-ai/bin/opencode). - try { - const shimContent = fs.readFileSync(binary, 'utf8'); - const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/); - if (jsMatch) { - const candidate = path.resolve(path.dirname(binary), jsMatch[0]); - if (fs.existsSync(candidate)) { - const realInterp = opencodeShimInterpreter(candidate); - if (realInterp) { - args.unshift(candidate); - binary = realInterp; - } - } - } - } catch { - // ignore – fall through to default spawn - } - } - } - - const child = spawn(binary, args, { - cwd, - env, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const url = await new Promise((resolve, reject) => { - let output = ''; - let done = false; - const finish = (handler, value) => { - if (done) return; - done = true; - clearTimeout(timer); - child.stdout?.off('data', onStdout); - child.stderr?.off('data', onStderr); - child.off('exit', onExit); - child.off('error', onError); - handler(value); - }; - - const onStdout = (chunk) => { - output += chunk.toString(); - const lines = output.split('\n'); - for (const line of lines) { - if (!line.startsWith('opencode server listening')) continue; - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - if (!match) { - finish(reject, new Error(`Failed to parse server url from output: ${line}`)); - return; - } - finish(resolve, match[1]); - return; - } - }; - - const onStderr = (chunk) => { - output += chunk.toString(); - }; - - const onExit = (code) => { - finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`)); - }; - - const onError = (error) => { - finish(reject, error); - }; - - const timer = setTimeout(() => { - finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`)); - }, timeout); - - child.stdout?.on('data', onStdout); - child.stderr?.on('data', onStderr); - child.on('exit', onExit); - child.on('error', onError); - }); - - return { - url, - close() { - try { - child.kill('SIGTERM'); - } catch { - // ignore - } - }, - }; -} - -async function resolveManagedOpenCodePort(requestedPort, hostname = '127.0.0.1') { - if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) { - return requestedPort; - } - - return await new Promise((resolve, reject) => { - const server = net.createServer(); - const cleanup = () => { - server.removeAllListeners('error'); - server.removeAllListeners('listening'); - }; - - server.once('error', (error) => { - cleanup(); - reject(error); - }); - - server.once('listening', () => { - const address = server.address(); - const port = address && typeof address === 'object' ? address.port : 0; - server.close(() => { - cleanup(); - if (port > 0) { - resolve(port); - return; - } - reject(new Error('Failed to allocate OpenCode port')); - }); - }); - - server.listen(0, hostname); - }); -} - -async function startOpenCode() { - const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0; - const spawnPort = await resolveManagedOpenCodePort(desiredPort, ENV_CONFIGURED_OPENCODE_HOSTNAME); - console.log( - desiredPort > 0 - ? `Starting OpenCode on requested port ${desiredPort}...` - : `Starting OpenCode on allocated port ${spawnPort}...` - ); - - await applyOpencodeBinaryFromSettings(); - ensureOpencodeCliEnv(); - const openCodePassword = await ensureLocalOpenCodeServerPassword({ - rotateManaged: true, - }); - - try { - const serverInstance = await createManagedOpenCodeServerProcess({ - hostname: ENV_CONFIGURED_OPENCODE_HOSTNAME, - port: spawnPort, - timeout: 30000, - cwd: openCodeWorkingDirectory, - env: { - ...process.env, - OPENCODE_SERVER_PASSWORD: openCodePassword, - }, - }); - - if (!serverInstance || !serverInstance.url) { - throw new Error('OpenCode server started but URL is missing'); - } - - const url = new URL(serverInstance.url); - const port = parseInt(url.port, 10); - const prefix = normalizeApiPrefix(url.pathname); - - if (await waitForReady(serverInstance.url, 10000)) { - setOpenCodePort(port); - setDetectedOpenCodeApiPrefix(prefix); // SDK URL typically includes the prefix if any - - isOpenCodeReady = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - - return serverInstance; - } else { - try { - serverInstance.close(); - } catch { - // ignore - } - throw new Error('Server started but health check failed (timeout)'); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - lastOpenCodeError = message; - openCodePort = null; - syncToHmrState(); - console.error(`Failed to start OpenCode: ${message}`); - throw error; - } -} - -async function restartOpenCode() { - if (isShuttingDown) return; - if (currentRestartPromise) { - await currentRestartPromise; - return; - } - - currentRestartPromise = (async () => { - isRestartingOpenCode = true; +const openCodeEnvState = {}; +Object.defineProperties(openCodeEnvState, { + cachedLoginShellEnvSnapshot: { get: () => cachedLoginShellEnvSnapshot, set: (value) => { cachedLoginShellEnvSnapshot = value; } }, + resolvedOpencodeBinary: { get: () => resolvedOpencodeBinary, set: (value) => { resolvedOpencodeBinary = value; } }, + resolvedOpencodeBinarySource: { get: () => resolvedOpencodeBinarySource, set: (value) => { resolvedOpencodeBinarySource = value; } }, + resolvedNodeBinary: { get: () => resolvedNodeBinary, set: (value) => { resolvedNodeBinary = value; } }, + resolvedBunBinary: { get: () => resolvedBunBinary, set: (value) => { resolvedBunBinary = value; } }, + resolvedGitBinary: { get: () => resolvedGitBinary, set: (value) => { resolvedGitBinary = value; } }, + useWslForOpencode: { get: () => useWslForOpencode, set: (value) => { useWslForOpencode = value; } }, + resolvedWslBinary: { get: () => resolvedWslBinary, set: (value) => { resolvedWslBinary = value; } }, + resolvedWslOpencodePath: { get: () => resolvedWslOpencodePath, set: (value) => { resolvedWslOpencodePath = value; } }, + resolvedWslDistro: { get: () => resolvedWslDistro, set: (value) => { resolvedWslDistro = value; } }, +}); + +const openCodeEnvRuntime = createOpenCodeEnvRuntime({ + state: openCodeEnvState, + normalizeDirectoryPath, + readSettingsFromDiskMigrated, + ENV_CONFIGURED_OPENCODE_WSL_DISTRO, +}); + +const applyLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.applyLoginShellEnvSnapshot(...args); +const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEnvSnapshot(...args); +const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args); +const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args); +const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args); +const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args); +const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args); +const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args); +const resolveWslExecutablePath = (...args) => openCodeEnvRuntime.resolveWslExecutablePath(...args); +const buildWslExecArgs = (...args) => openCodeEnvRuntime.buildWslExecArgs(...args); +const opencodeShimInterpreter = (...args) => openCodeEnvRuntime.opencodeShimInterpreter(...args); +const clearResolvedOpenCodeBinary = (...args) => openCodeEnvRuntime.clearResolvedOpenCodeBinary(...args); +const openCodeResolutionRuntime = createOpenCodeResolutionRuntime({ + path, + resolveOpencodeCliPath, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + opencodeShimInterpreter, + getResolvedState: () => ({ + resolvedOpencodeBinary, + resolvedOpencodeBinarySource, + useWslForOpencode, + resolvedWslBinary, + resolvedWslOpencodePath, + resolvedWslDistro, + resolvedNodeBinary, + resolvedBunBinary, + }), + setResolvedOpencodeBinarySource: (value) => { + resolvedOpencodeBinarySource = value; + }, +}); +const getOpenCodeResolutionSnapshot = (...args) => + openCodeResolutionRuntime.getOpenCodeResolutionSnapshot(...args); + +applyLoginShellEnvSnapshot(); + +notificationTemplateRuntime = createNotificationTemplateRuntime({ + readSettingsFromDisk, + persistSettings, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + resolveGitBinaryForSpawn, +}); + +const notificationTriggerRuntime = createNotificationTriggerRuntime({ + readSettingsFromDisk, + prepareNotificationLastMessage, + summarizeText, + resolveZenModel, + buildTemplateVariables, + extractLastMessageText, + fetchLastAssistantMessageText, + resolveNotificationTemplate, + shouldApplyResolvedTemplateMessage, + emitDesktopNotification, + broadcastUiNotification, + sendPushToAllUiSessions, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, +}); + +const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); + +const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ + waitForOpenCodePort: (...args) => waitForOpenCodePort(...args), + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + parseSseDataPayload: (...args) => parseSseDataPayload(...args), + onPayload: (payload) => { + maybeCacheSessionInfoFromEvent(payload); + void maybeSendPushForTrigger(payload); + sessionRuntime.processOpenCodeSsePayload(payload); + }, +}); + + +const serverUtilsRuntime = createServerUtilsRuntime({ + fs, + os, + path, + process, + openCodeReadyGraceMs: OPEN_CODE_READY_GRACE_MS, + longRequestTimeoutMs: LONG_REQUEST_TIMEOUT_MS, + getRuntime: () => ({ + openCodePort, + openCodeNotReadySince, + isOpenCodeReady, + isRestartingOpenCode, + }), + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + getUiNotificationClients: () => uiNotificationClients, + getOpenCodePort: () => openCodePort, + setOpenCodePortState: (value) => { + openCodePort = value; + }, + syncToHmrState, + markOpenCodeNotReady: () => { isOpenCodeReady = false; - openCodeNotReadySince = Date.now(); - console.log('Restarting OpenCode process...'); - - // For external OpenCode servers, re-probe instead of kill + respawn - if (isExternalOpenCode) { - console.log('Re-probing external OpenCode server...'); - const probePort = openCodePort || ENV_CONFIGURED_OPENCODE_PORT || 4096; - const probeOrigin = openCodeBaseUrl ?? ENV_CONFIGURED_OPENCODE_HOST?.origin; - const healthy = await probeExternalOpenCode(probePort, probeOrigin); - if (healthy) { - console.log(`External OpenCode server on port ${probePort} is healthy`); - setOpenCodePort(probePort); - isOpenCodeReady = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else { - lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`; - console.error(lastOpenCodeError); - throw new Error(lastOpenCodeError); - } - - if (expressApp) { - setupProxy(expressApp); - ensureOpenCodeApiPrefix(); - } - return; - } - - const portToKill = openCodePort; - - if (openCodeProcess) { - console.log('Stopping existing OpenCode process...'); - try { - openCodeProcess.close(); - } catch (error) { - console.warn('Error closing OpenCode process:', error); - } - openCodeProcess = null; - syncToHmrState(); - } - - killProcessOnPort(portToKill); - - // Brief delay to allow port release - await new Promise((resolve) => setTimeout(resolve, 250)); - - if (ENV_CONFIGURED_OPENCODE_PORT) { - console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); - setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); - } else { - openCodePort = null; - syncToHmrState(); - } - - openCodeApiPrefixDetected = true; - openCodeApiPrefix = ''; - if (openCodeApiDetectionTimer) { - clearTimeout(openCodeApiDetectionTimer); - openCodeApiDetectionTimer = null; - } - + }, + setOpenCodeNotReadySince: (value) => { + openCodeNotReadySince = value; + }, + clearLastOpenCodeError: () => { lastOpenCodeError = null; - openCodeProcess = await startOpenCode(); - syncToHmrState(); - - if (expressApp) { - setupProxy(expressApp); - // Ensure prefix is set correctly (SDK usually handles this, but just in case) - ensureOpenCodeApiPrefix(); + }, + getLoginShellPath: () => { + const snapshot = getLoginShellEnvSnapshot(); + if (!snapshot || typeof snapshot.PATH !== 'string' || snapshot.PATH.length === 0) { + return null; } - })(); + return snapshot.PATH; + }, +}); - try { - await currentRestartPromise; - } catch (error) { - console.error(`Failed to restart OpenCode: ${error.message}`); - lastOpenCodeError = error.message; - if (!ENV_CONFIGURED_OPENCODE_PORT) { - openCodePort = null; - syncToHmrState(); - } - openCodeApiPrefixDetected = true; - openCodeApiPrefix = ''; - throw error; - } finally { - currentRestartPromise = null; - isRestartingOpenCode = false; - } -} +const setOpenCodePort = (...args) => serverUtilsRuntime.setOpenCodePort(...args); +const waitForOpenCodePort = (...args) => serverUtilsRuntime.waitForOpenCodePort(...args); +const buildAugmentedPath = (...args) => serverUtilsRuntime.buildAugmentedPath(...args); +const parseSseDataPayload = (...args) => serverUtilsRuntime.parseSseDataPayload(...args); +const staticRoutesRuntime = createStaticRoutesRuntime({ + fs, + path, + process, + __dirname, + express, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, +}); +const featureRoutesRuntime = createFeatureRoutesRuntime({ + clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, +}); +const bootstrapRuntime = createBootstrapRuntime({ + createUiAuth, + registerServerStatusRoutes, + registerCommonRequestMiddleware, + registerAuthAndAccessRoutes, + registerTtsRoutes, + registerNotificationRoutes, + registerOpenChamberRoutes, + express, +}); +const tunnelWiringRuntime = createTunnelWiringRuntime({ + crypto, + URL, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActiveTunnelController: () => activeTunnelController, + setActiveTunnelController: (value) => { + activeTunnelController = value; + }, + getRuntimeManagedRemoteTunnelHostname: () => runtimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname: (value) => { + runtimeManagedRemoteTunnelHostname = value; + }, + getRuntimeManagedRemoteTunnelToken: () => runtimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken: (value) => { + runtimeManagedRemoteTunnelToken = value; + }, +}); +const startupPipelineRuntime = createStartupPipelineRuntime({ + createTerminalRuntime, + createServerStartupRuntime, +}); -async function waitForOpenCodeReady(timeoutMs = 20000, intervalMs = 400) { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } +const openCodeLifecycleState = {}; +Object.defineProperties(openCodeLifecycleState, { + openCodeProcess: { get: () => openCodeProcess, set: (value) => { openCodeProcess = value; } }, + openCodePort: { get: () => openCodePort, set: (value) => { openCodePort = value; } }, + openCodeBaseUrl: { get: () => openCodeBaseUrl, set: (value) => { openCodeBaseUrl = value; } }, + openCodeWorkingDirectory: { get: () => openCodeWorkingDirectory, set: (value) => { openCodeWorkingDirectory = value; } }, + currentRestartPromise: { get: () => currentRestartPromise, set: (value) => { currentRestartPromise = value; } }, + isRestartingOpenCode: { get: () => isRestartingOpenCode, set: (value) => { isRestartingOpenCode = value; } }, + openCodeApiPrefix: { get: () => openCodeApiPrefix, set: (value) => { openCodeApiPrefix = value; } }, + openCodeApiPrefixDetected: { get: () => openCodeApiPrefixDetected, set: (value) => { openCodeApiPrefixDetected = value; } }, + openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } }, + lastOpenCodeError: { get: () => lastOpenCodeError, set: (value) => { lastOpenCodeError = value; } }, + isOpenCodeReady: { get: () => isOpenCodeReady, set: (value) => { isOpenCodeReady = value; } }, + openCodeNotReadySince: { get: () => openCodeNotReadySince, set: (value) => { openCodeNotReadySince = value; } }, + isExternalOpenCode: { get: () => isExternalOpenCode, set: (value) => { isExternalOpenCode = value; } }, + isShuttingDown: { get: () => isShuttingDown, set: (value) => { isShuttingDown = value; } }, + healthCheckInterval: { get: () => healthCheckInterval, set: (value) => { healthCheckInterval = value; } }, + expressApp: { get: () => expressApp, set: (value) => { expressApp = value; } }, + useWslForOpencode: { get: () => useWslForOpencode, set: (value) => { useWslForOpencode = value; } }, + resolvedWslBinary: { get: () => resolvedWslBinary, set: (value) => { resolvedWslBinary = value; } }, + resolvedWslOpencodePath: { get: () => resolvedWslOpencodePath, set: (value) => { resolvedWslOpencodePath = value; } }, + resolvedWslDistro: { get: () => resolvedWslDistro, set: (value) => { resolvedWslDistro = value; } }, +}); - const deadline = Date.now() + timeoutMs; - let lastError = null; +const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ + state: openCodeLifecycleState, + env: { + ENV_CONFIGURED_OPENCODE_PORT, + ENV_CONFIGURED_OPENCODE_HOST, + ENV_EFFECTIVE_PORT, + ENV_CONFIGURED_OPENCODE_HOSTNAME, + ENV_SKIP_OPENCODE_START, + }, + syncToHmrState, + syncFromHmrState, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + waitForReady, + normalizeApiPrefix, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + ensureLocalOpenCodeServerPassword, + buildWslExecArgs, + resolveWslExecutablePath, + opencodeShimInterpreter, + setOpenCodePort, + setDetectedOpenCodeApiPrefix, + setupProxy: (...args) => setupProxy(...args), + ensureOpenCodeApiPrefix, + clearResolvedOpenCodeBinary, +}); - while (Date.now() < deadline) { - try { - const [configResult, agentResult] = await Promise.all([ - fetch(buildOpenCodeUrl('/config', ''), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }).catch((error) => error), - fetch(buildOpenCodeUrl('/agent', ''), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }).catch((error) => error) - ]); - - if (configResult instanceof Error) { - lastError = configResult; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - if (!configResult.ok) { - lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`); - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - await configResult.json().catch(() => null); - - if (agentResult instanceof Error) { - lastError = agentResult; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - if (!agentResult.ok) { - lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`); - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - await agentResult.json().catch(() => []); - - isOpenCodeReady = true; - lastOpenCodeError = null; - return; - } catch (error) { - lastError = error; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - if (lastError) { - lastOpenCodeError = lastError.message || String(lastError); - throw lastError; - } - - const timeoutError = new Error('Timed out waiting for OpenCode to become ready'); - lastOpenCodeError = timeoutError.message; - throw timeoutError; -} - -async function waitForAgentPresence(agentName, timeoutMs = 15000, intervalMs = 300) { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - try { - const response = await fetch(buildOpenCodeUrl('/agent'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }); - - if (response.ok) { - const agents = await response.json(); - if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) { - return; - } - } - } catch (error) { - - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - throw new Error(`Agent "${agentName}" not available after OpenCode restart`); -} - -async function fetchAgentsSnapshot() { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const response = await fetch(buildOpenCodeUrl('/agent'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } +const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args); +const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args); +const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args); +const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args); +const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL); +const bootstrapOpenCodeAtStartup = async (...args) => { + await openCodeLifecycleRuntime.bootstrapOpenCodeAtStartup(...args); + scheduleOpenCodeApiDetection(); + startHealthMonitoring(); + void openCodeWatcherRuntime.start().catch((error) => { + console.warn(`Global event watcher startup failed: ${error?.message || error}`); }); - - if (!response.ok) { - throw new Error(`Failed to fetch agents snapshot (status ${response.status})`); - } - - const agents = await response.json().catch(() => null); - if (!Array.isArray(agents)) { - throw new Error('Invalid agents payload from OpenCode'); - } - return agents; -} - -async function fetchProvidersSnapshot() { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const response = await fetch(buildOpenCodeUrl('/provider'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch providers snapshot (status ${response.status})`); - } - - const providers = await response.json().catch(() => null); - if (!Array.isArray(providers)) { - throw new Error('Invalid providers payload from OpenCode'); - } - return providers; -} - -async function fetchModelsSnapshot() { - if (!openCodePort) { - throw new Error('OpenCode port is not available'); - } - - const response = await fetch(buildOpenCodeUrl('/model'), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch models snapshot (status ${response.status})`); - } - - const models = await response.json().catch(() => null); - if (!Array.isArray(models)) { - throw new Error('Invalid models payload from OpenCode'); - } - return models; -} - -async function refreshOpenCodeAfterConfigChange(reason, options = {}) { - const { agentName } = options; - - console.log(`Refreshing OpenCode after ${reason}`); - - // Settings might include a new opencodeBinary; drop cache before restart. - resolvedOpencodeBinary = null; - await applyOpencodeBinaryFromSettings(); - - await restartOpenCode(); - - try { - await waitForOpenCodeReady(); - isOpenCodeReady = true; - openCodeNotReadySince = 0; - - if (agentName) { - await waitForAgentPresence(agentName); - } - - isOpenCodeReady = true; - openCodeNotReadySince = 0; - } catch (error) { - - isOpenCodeReady = false; - openCodeNotReadySince = Date.now(); - console.error(`Failed to refresh OpenCode after ${reason}:`, error.message); - throw error; - } -} - -async function bootstrapOpenCodeAtStartup() { - try { - syncFromHmrState(); - if (await isOpenCodeProcessHealthy()) { - console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); - } else if (ENV_SKIP_OPENCODE_START && ENV_EFFECTIVE_PORT) { - const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; - console.log(`Using external OpenCode server at ${label} (skip-start mode)`); - openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; - setOpenCodePort(ENV_EFFECTIVE_PORT); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else if (ENV_EFFECTIVE_PORT && await probeExternalOpenCode(ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOST?.origin)) { - const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`; - console.log(`Auto-detected existing OpenCode server at ${label}`); - openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; - setOpenCodePort(ENV_EFFECTIVE_PORT); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else if (!ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { - console.log('Auto-detected existing OpenCode server on default port 4096'); - setOpenCodePort(4096); - isOpenCodeReady = true; - isExternalOpenCode = true; - lastOpenCodeError = null; - openCodeNotReadySince = 0; - syncToHmrState(); - } else { - if (ENV_EFFECTIVE_PORT) { - console.log(`Using OpenCode port from environment: ${ENV_EFFECTIVE_PORT}`); - setOpenCodePort(ENV_EFFECTIVE_PORT); - } else { - openCodePort = null; - syncToHmrState(); - } - - lastOpenCodeError = null; - openCodeProcess = await startOpenCode(); - syncToHmrState(); - } - await waitForOpenCodePort(); - try { - await waitForOpenCodeReady(); - } catch (error) { - console.error(`OpenCode readiness check failed: ${error.message}`); - scheduleOpenCodeApiDetection(); - } - scheduleOpenCodeApiDetection(); - startHealthMonitoring(); - void startGlobalEventWatcher().catch((error) => { - console.warn(`Global event watcher startup failed: ${error?.message || error}`); - }); - } catch (error) { - console.error(`Failed to start OpenCode: ${error.message}`); - console.log('Continuing without OpenCode integration...'); - lastOpenCodeError = error.message; - scheduleOpenCodeApiDetection(); - } -} - -function setupProxy(app) { - if (app.get('opencodeProxyConfigured')) { - return; - } - - if (openCodePort) { - console.log(`Setting up proxy to OpenCode on port ${openCodePort}`); - } else { - console.log('Setting up OpenCode API gate (OpenCode not started yet)'); - } - app.set('opencodeProxyConfigured', true); - - const stripApiPrefix = (rawUrl) => { - if (typeof rawUrl !== 'string' || !rawUrl) { - return '/'; - } - if (rawUrl === '/api') { - return '/'; - } - if (rawUrl.startsWith('/api/')) { - return rawUrl.slice(4); - } - return rawUrl; - }; - - // Keep route matching stable; only rewrite the proxied upstream path. - const rewriteWindowsDirectoryParam = (upstreamPath) => { - if (process.platform !== 'win32') { - return upstreamPath; - } - try { - const parsed = new URL(upstreamPath, 'http://openchamber.local'); - const pathname = parsed.pathname || '/'; - if (pathname === '/session' || pathname.startsWith('/session/')) { - return upstreamPath; - } - const directory = parsed.searchParams.get('directory'); - if (!directory || !directory.includes('/')) { - return upstreamPath; - } - const fixed = directory.replace(/\//g, '\\'); - parsed.searchParams.set('directory', fixed); - const rewritten = `${parsed.pathname}${parsed.search}${parsed.hash}`; - if (rewritten !== upstreamPath) { - console.log(`[Win32PathFix] Rewrote directory: "${directory}" → "${fixed}"`); - console.log(`[Win32PathFix] URL: "${upstreamPath}" → "${rewritten}"`); - } - return rewritten; - } catch { - return upstreamPath; - } - }; - - const getUpstreamPathForRequest = (req) => { - const rawUrl = (typeof req.originalUrl === 'string' && req.originalUrl) - ? req.originalUrl - : (typeof req.url === 'string' ? req.url : '/'); - return rewriteWindowsDirectoryParam(stripApiPrefix(rawUrl)); - }; - - app.use('/api', (req, res, next) => { - if ( - req.path.startsWith('/themes/custom') || - req.path.startsWith('/push') || - req.path.startsWith('/config/agents') || - req.path.startsWith('/config/opencode-resolution') || - req.path.startsWith('/config/settings') || - req.path.startsWith('/config/skills') || - req.path === '/config/reload' || - req.path === '/health' - ) { - return next(); - } - - const waitElapsed = openCodeNotReadySince === 0 ? 0 : Date.now() - openCodeNotReadySince; - const stillWaiting = - (!isOpenCodeReady && (openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) || - isRestartingOpenCode || - !openCodePort; - - if (stillWaiting) { - return res.status(503).json({ - error: 'OpenCode is restarting', - restarting: true, - }); - } - - next(); - }); - - const isSseApiPath = (path) => path === '/event' || path === '/global/event'; - - const forwardSseRequest = async (req, res) => { - const startedAt = Date.now(); - const upstreamPath = getUpstreamPathForRequest(req); - const targetUrl = buildOpenCodeUrl(upstreamPath, ''); - const authHeaders = getOpenCodeAuthHeaders(); - - const requestHeaders = { - ...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }), - 'cache-control': 'no-cache', - connection: 'keep-alive', - ...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}), - }; - - const controller = new AbortController(); - let connectTimer = null; - let idleTimer = null; - let heartbeatTimer = null; - let endedBy = 'upstream-end'; - - const cleanup = () => { - if (connectTimer) { - clearTimeout(connectTimer); - connectTimer = null; - } - if (idleTimer) { - clearTimeout(idleTimer); - idleTimer = null; - } - if (heartbeatTimer) { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - req.off('close', onClientClose); - }; - - const resetIdleTimeout = () => { - if (idleTimer) { - clearTimeout(idleTimer); - } - idleTimer = setTimeout(() => { - endedBy = 'idle-timeout'; - controller.abort(); - }, 5 * 60 * 1000); - }; - - const onClientClose = () => { - endedBy = 'client-disconnect'; - controller.abort(); - }; - - req.on('close', onClientClose); - - try { - connectTimer = setTimeout(() => { - endedBy = 'connect-timeout'; - controller.abort(); - }, 10 * 1000); - - const upstreamResponse = await fetch(targetUrl, { - method: 'GET', - headers: requestHeaders, - signal: controller.signal, - }); - - if (connectTimer) { - clearTimeout(connectTimer); - connectTimer = null; - } - - if (!upstreamResponse.ok || !upstreamResponse.body) { - const body = await upstreamResponse.text().catch(() => ''); - cleanup(); - if (!res.headersSent) { - if (upstreamResponse.headers.has('content-type')) { - res.setHeader('content-type', upstreamResponse.headers.get('content-type')); - } - res.status(upstreamResponse.status).send(body); - } - return; - } - - const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream'; - res.status(upstreamResponse.status); - res.setHeader('content-type', upstreamContentType); - res.setHeader('cache-control', 'no-cache'); - res.setHeader('connection', 'keep-alive'); - res.setHeader('x-accel-buffering', 'no'); - res.setHeader('x-content-type-options', 'nosniff'); - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - resetIdleTimeout(); - heartbeatTimer = setInterval(() => { - if (res.writableEnded || controller.signal.aborted) { - return; - } - try { - res.write(': ping\n\n'); - resetIdleTimeout(); - } catch { - } - }, 30 * 1000); - - const reader = upstreamResponse.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - endedBy = endedBy === 'upstream-end' ? 'upstream-finished' : endedBy; - break; - } - if (controller.signal.aborted) { - break; - } - if (value && value.length > 0) { - res.write(Buffer.from(value)); - resetIdleTimeout(); - } - } - } finally { - try { - reader.releaseLock(); - } catch { - } - } - - cleanup(); - if (!res.writableEnded) { - res.end(); - } - console.log(`SSE forward ${upstreamPath} closed (${endedBy}) in ${Date.now() - startedAt}ms`); - } catch (error) { - cleanup(); - const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; - if (!res.headersSent) { - res.status(isTimeout ? 504 : 503).json({ - error: isTimeout ? 'OpenCode SSE forward timed out' : 'OpenCode SSE forward failed', - }); - } else if (!res.writableEnded) { - res.end(); - } - console.warn(`SSE forward ${upstreamPath} failed (${endedBy}):`, error?.message || error); - } - }; - - app.get('/api/event', forwardSseRequest); - app.get('/api/global/event', forwardSseRequest); - - app.use('/api', (_req, _res, next) => { - ensureOpenCodeApiPrefix(); - next(); - }); - - app.use('/api', (req, res, next) => { - if ( - req.path.startsWith('/themes/custom') || - req.path.startsWith('/config/agents') || - req.path.startsWith('/config/opencode-resolution') || - req.path.startsWith('/config/settings') || - req.path.startsWith('/config/skills') || - req.path === '/health' - ) { - return next(); - } - console.log(`API → OpenCode: ${req.method} ${req.path}`); - next(); - }); - - - const hopByHopRequestHeaders = new Set([ - 'host', - 'connection', - 'content-length', - 'transfer-encoding', - 'keep-alive', - 'te', - 'trailer', - 'upgrade', - ]); - - const hopByHopResponseHeaders = new Set([ - 'connection', - 'content-length', - 'transfer-encoding', - 'keep-alive', - 'te', - 'trailer', - 'upgrade', - 'www-authenticate', - ]); - - const collectForwardHeaders = (req) => { - const authHeaders = getOpenCodeAuthHeaders(); - const headers = {}; - - for (const [key, value] of Object.entries(req.headers || {})) { - if (!value) continue; - const lowerKey = key.toLowerCase(); - if (hopByHopRequestHeaders.has(lowerKey)) continue; - headers[lowerKey] = Array.isArray(value) ? value.join(', ') : String(value); - } - - if (authHeaders.Authorization) { - headers.Authorization = authHeaders.Authorization; - } - - return headers; - }; - - const collectRequestBodyBuffer = async (req) => { - if (Buffer.isBuffer(req.body)) { - return req.body; - } - - if (typeof req.body === 'string') { - return Buffer.from(req.body); - } - - if (req.body && typeof req.body === 'object') { - return Buffer.from(JSON.stringify(req.body)); - } - - if (req.readableEnded) { - return Buffer.alloc(0); - } - - return await new Promise((resolve, reject) => { - const chunks = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); - }; - - const forwardGenericApiRequest = async (req, res) => { - try { - const upstreamPath = getUpstreamPathForRequest(req); - const targetUrl = buildOpenCodeUrl(upstreamPath, ''); - const headers = collectForwardHeaders(req); - const method = String(req.method || 'GET').toUpperCase(); - const hasBody = method !== 'GET' && method !== 'HEAD'; - const bodyBuffer = hasBody ? await collectRequestBodyBuffer(req) : null; - - const upstreamResponse = await fetch(targetUrl, { - method, - headers, - body: hasBody ? bodyBuffer : undefined, - signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS), - }); - - for (const [key, value] of upstreamResponse.headers.entries()) { - const lowerKey = key.toLowerCase(); - if (hopByHopResponseHeaders.has(lowerKey)) { - continue; - } - res.setHeader(key, value); - } - - const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer()); - res.status(upstreamResponse.status).send(upstreamBody); - } catch (error) { - if (!res.headersSent) { - const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; - res.status(isTimeout ? 504 : 503).json({ - error: isTimeout ? 'OpenCode request timed out' : 'OpenCode service unavailable', - }); - } - } - }; - - // Dedicated forwarder for large session message payloads. - // This avoids edge-cases in generic proxy streaming for multi-file attachments. - app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { - try { - const upstreamPath = getUpstreamPathForRequest(req); - const targetUrl = buildOpenCodeUrl(upstreamPath, ''); - const authHeaders = getOpenCodeAuthHeaders(); - - const headers = { - ...(typeof req.headers['content-type'] === 'string' ? { 'content-type': req.headers['content-type'] } : { 'content-type': 'application/json' }), - ...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : {}), - ...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}), - }; - - const bodyBuffer = Buffer.isBuffer(req.body) - ? req.body - : Buffer.from(typeof req.body === 'string' ? req.body : ''); - - const upstreamResponse = await fetch(targetUrl, { - method: 'POST', - headers, - body: bodyBuffer, - signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS), - }); - - const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer()); - - if (upstreamResponse.headers.has('content-type')) { - res.setHeader('content-type', upstreamResponse.headers.get('content-type')); - } - - res.status(upstreamResponse.status).send(upstreamBody); - } catch (error) { - if (!res.headersSent) { - const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError'; - res.status(isTimeout ? 504 : 503).json({ - error: isTimeout ? 'OpenCode message forward timed out' : 'OpenCode message forward failed', - }); - } - } - }); - - app.use('/api', async (req, res, next) => { - if (isSseApiPath(req.path)) { - return next(); - } - - if (req.method === 'POST' && /\/session\/[^/]+\/message$/.test(req.path || '')) { - return next(); - } - - // Windows: Merge sessions from all project directories on bare GET /session - if (process.platform === 'win32' && req.method === 'GET' && req.path === '/session') { - const rawUrl = req.originalUrl || req.url || ''; - if (!rawUrl.includes('directory=')) { - try { - const authHeaders = getOpenCodeAuthHeaders(); - const fetchOpts = { - method: 'GET', - headers: { Accept: 'application/json', ...authHeaders }, - signal: AbortSignal.timeout(10000), - }; - const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts); - const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : []; - const globalSessions = Array.isArray(globalPayload) ? globalPayload : []; - - const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); - let projectDirs = []; - try { - const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); - const settings = JSON.parse(settingsRaw); - projectDirs = (settings.projects || []) - .map((project) => (typeof project?.path === 'string' ? project.path.trim() : '')) - .filter(Boolean); - } catch {} - - const seen = new Set( - globalSessions - .map((session) => (session && typeof session.id === 'string' ? session.id : null)) - .filter((id) => typeof id === 'string') - ); - const extraSessions = []; - for (const dir of projectDirs) { - const candidates = Array.from(new Set([ - dir, - dir.replace(/\\/g, '/'), - dir.replace(/\//g, '\\'), - ])); - for (const candidateDir of candidates) { - const encoded = encodeURIComponent(candidateDir); - try { - const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); - if (dirRes.ok) { - const dirPayload = await dirRes.json().catch(() => []); - const dirSessions = Array.isArray(dirPayload) ? dirPayload : []; - for (const session of dirSessions) { - const id = session && typeof session.id === 'string' ? session.id : null; - if (id && !seen.has(id)) { - seen.add(id); - extraSessions.push(session); - } - } - } - } catch {} - } - } - - const merged = [...globalSessions, ...extraSessions]; - merged.sort((a, b) => { - const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0; - const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0; - return bTime - aTime; - }); - console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); - return res.json(merged); - } catch (error) { - console.log(`[SessionMerge] Error: ${error.message}, falling through`); - } - } - } - - return forwardGenericApiRequest(req, res); - }); -} - -function startHealthMonitoring() { - if (healthCheckInterval) { - clearInterval(healthCheckInterval); - } - - healthCheckInterval = setInterval(async () => { - if (!openCodeProcess || isShuttingDown || isRestartingOpenCode) return; - - try { - const healthy = await isOpenCodeProcessHealthy(); - if (!healthy) { - console.log('OpenCode process not running, restarting...'); - await restartOpenCode(); - } - } catch (error) { - console.error(`Health check error: ${error.message}`); - } - }, HEALTH_CHECK_INTERVAL); -} - -async function gracefulShutdown(options = {}) { - if (isShuttingDown) return; - - isShuttingDown = true; - syncToHmrState(); - console.log('Starting graceful shutdown...'); - const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown; - - stopGlobalEventWatcher(); - - if (healthCheckInterval) { - clearInterval(healthCheckInterval); - } - - if (terminalInputWsServer) { - try { - for (const client of terminalInputWsServer.clients) { - try { - client.terminate(); - } catch { - } - } - - await new Promise((resolve) => { - terminalInputWsServer.close(() => resolve()); - }); - } catch { - } finally { - terminalInputWsServer = null; - } - } - - // Only stop OpenCode if we started it ourselves (not when using external server) - if (!ENV_SKIP_OPENCODE_START && !isExternalOpenCode) { - const portToKill = openCodePort; - - if (openCodeProcess) { - console.log('Stopping OpenCode process...'); - try { - openCodeProcess.close(); - } catch (error) { - console.warn('Error closing OpenCode process:', error); - } - openCodeProcess = null; - } - - killProcessOnPort(portToKill); - } else { - console.log('Skipping OpenCode shutdown (external server)'); - } - - if (server) { - await Promise.race([ - new Promise((resolve) => { - server.close(() => { - console.log('HTTP server closed'); - resolve(); - }); - }), - new Promise((resolve) => { - setTimeout(() => { - console.warn('Server close timeout reached, forcing shutdown'); - resolve(); - }, SHUTDOWN_TIMEOUT); - }) - ]); - } - - if (uiAuthController) { - uiAuthController.dispose(); - uiAuthController = null; - } - - if (activeTunnelController) { - console.log('Stopping active tunnel...'); - activeTunnelController.stop(); - activeTunnelController = null; - tunnelAuthController.clearActiveTunnel(); - } - - console.log('Graceful shutdown complete'); - if (exitProcess) { - process.exit(0); - } -} +}; +const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); + +const fetchAgentsSnapshot = (...args) => serverUtilsRuntime.fetchAgentsSnapshot(...args); +const fetchProvidersSnapshot = (...args) => serverUtilsRuntime.fetchProvidersSnapshot(...args); +const fetchModelsSnapshot = (...args) => serverUtilsRuntime.fetchModelsSnapshot(...args); +const setupProxy = (...args) => serverUtilsRuntime.setupProxy(...args); +const gracefulShutdownRuntime = createGracefulShutdownRuntime({ + process, + shutdownTimeoutMs: SHUTDOWN_TIMEOUT, + getExitOnShutdown: () => exitOnShutdown, + getIsShuttingDown: () => isShuttingDown, + setIsShuttingDown: (value) => { + isShuttingDown = value; + }, + syncToHmrState, + openCodeWatcherRuntime, + sessionRuntime, + getHealthCheckInterval: () => healthCheckInterval, + clearHealthCheckInterval: (value) => clearInterval(value), + getTerminalRuntime: () => terminalRuntime, + setTerminalRuntime: (value) => { + terminalRuntime = value; + }, + shouldSkipOpenCodeStop: () => ENV_SKIP_OPENCODE_START || isExternalOpenCode, + getOpenCodePort: () => openCodePort, + getOpenCodeProcess: () => openCodeProcess, + setOpenCodeProcess: (value) => { + openCodeProcess = value; + }, + killProcessOnPort, + getServer: () => server, + getUiAuthController: () => uiAuthController, + setUiAuthController: (value) => { + uiAuthController = value; + }, + getActiveTunnelController: () => activeTunnelController, + setActiveTunnelController: (value) => { + activeTunnelController = value; + }, + tunnelAuthController, +}); + +const gracefulShutdown = (...args) => gracefulShutdownRuntime.gracefulShutdown(...args); async function main(options = {}) { const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT; @@ -7151,33 +814,7 @@ async function main(options = {}) { console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); - // Check macOS Say TTS availability once at startup - let sayTTSCapability = { available: false, voices: [], reason: 'Not checked' }; - if (process.platform === 'darwin') { - try { - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - const { stdout } = await execAsync('say -v "?"'); - const voices = stdout.split('\n') - .filter(line => line.trim()) - .map(line => { - const match = line.match(/^(.+?)\s+([a-zA-Z]{2}_[a-zA-Z]{2,3})\s+#/); - if (match) { - return { name: match[1].trim(), locale: match[2] }; - } - return null; - }) - .filter(Boolean); - sayTTSCapability = { available: true, voices }; - console.log(`macOS Say TTS available with ${voices.length} voices`); - } catch (error) { - sayTTSCapability = { available: false, voices: [], reason: 'say command not available' }; - console.log('macOS Say TTS not available:', error.message); - } - } else { - sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' }; - } + const sayTTSCapability = await detectSayTtsCapability(process); // Startup model validation is best-effort and runs in background. void validateZenModelAtStartup(); @@ -7188,11 +825,15 @@ async function main(options = {}) { expressApp = app; server = http.createServer(app); - app.get('/health', (req, res) => { - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - openCodePort: openCodePort, + const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null; + const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, { + process, + openchamberVersion: OPENCHAMBER_VERSION, + runtimeName: process.env.OPENCHAMBER_RUNTIME || 'web', + serverStartedAt, + gracefulShutdown, + getHealthSnapshot: () => ({ + openCodePort, openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode), openCodeSecureConnection: isOpenCodeConnectionSecure(), openCodeAuthSource: openCodeAuthSource || null, @@ -7209,7175 +850,117 @@ async function main(options = {}) { opencodeWslDistro: resolvedWslDistro || null, nodeBinaryResolved: resolvedNodeBinary || null, bunBinaryResolved: resolvedBunBinary || null, - }); + }), + uiPassword, + tunnelAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + resolveZenModel, + sayTTSCapability, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + sessionRuntime, + setPushInitialized, + fs, + os, + path, + server, + __dirname, + openchamberDataDir: OPENCHAMBER_DATA_DIR, + modelsDevApiUrl: MODELS_DEV_API_URL, + modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL, + fetchFreeZenModels, + getCachedZenModels, + }); + uiAuthController = bootstrapResult.uiAuthController; + + const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port); + const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext; + + await featureRoutesRuntime.registerRoutes(app, { + crypto, + fs, + os, + path, + fsPromises, + spawn, + resolveGitBinaryForSpawn, + createFsSearchRuntime: createFsSearchRuntimeFactory, + openchamberDataDir: OPENCHAMBER_DATA_DIR, + openchamberUserConfigRoot: OPENCHAMBER_USER_CONFIG_ROOT, + normalizeDirectoryPath, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + validateDirectoryPath, + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort: () => openCodePort, + buildAugmentedPath, }); - app.post('/api/system/shutdown', (req, res) => { - res.json({ ok: true }); - gracefulShutdown({ exitProcess: false }).catch((error) => { - console.error('Shutdown request failed:', error?.message || error); - }); - }); - - app.get('/api/system/info', (req, res) => { - res.json({ - openchamberVersion: OPENCHAMBER_VERSION, - runtime: process.env.OPENCHAMBER_RUNTIME || 'web', - pid: process.pid, - startedAt: serverStartedAt, - }); - }); - - app.use((req, res, next) => { - if ( - req.path.startsWith('/api/config/agents') || - req.path.startsWith('/api/config/commands') || - req.path.startsWith('/api/config/mcp') || - req.path.startsWith('/api/config/settings') || - req.path.startsWith('/api/config/skills') || - req.path.startsWith('/api/projects') || - req.path.startsWith('/api/fs') || - req.path.startsWith('/api/git') || - req.path.startsWith('/api/prompts') || - req.path.startsWith('/api/terminal') || - req.path.startsWith('/api/opencode') || - req.path.startsWith('/api/push') || - req.path.startsWith('/api/voice') || - req.path.startsWith('/api/tts') || - req.path.startsWith('/api/openchamber/tunnel') - ) { - - express.json({ limit: '50mb' })(req, res, next); - } else if (req.path.startsWith('/api')) { - - next(); - } else { - - express.json({ limit: '50mb' })(req, res, next); - } - }); - app.use(express.urlencoded({ extended: true, limit: '50mb' })); - - app.use((req, res, next) => { - console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); - next(); - }); - - const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null; - uiAuthController = createUiAuth({ password: uiPassword }); - if (uiAuthController.enabled) { - console.log('UI password protection enabled for browser sessions'); - } - - app.get('/auth/session', async (req, res) => { - const requestScope = tunnelAuthController.classifyRequestScope(req); - if (requestScope === 'tunnel' || requestScope === 'unknown-public') { - const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req); - if (tunnelSession) { - return res.json({ authenticated: true, scope: 'tunnel' }); - } - tunnelAuthController.clearTunnelSessionCookie(req, res); - return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true }); - } - - try { - await uiAuthController.handleSessionStatus(req, res); - } catch (err) { - res.status(500).json({ error: 'Internal server error' }); - } - }); - app.post('/auth/session', (req, res) => { - const requestScope = tunnelAuthController.classifyRequestScope(req); - if (requestScope === 'tunnel' || requestScope === 'unknown-public') { - return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true }); - } - return uiAuthController.handleSessionCreate(req, res); - }); - - app.get('/connect', async (req, res) => { - try { - const token = typeof req.query?.t === 'string' ? req.query.t : ''; - const settings = await readSettingsFromDiskMigrated(); - const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); - - const exchange = tunnelAuthController.exchangeBootstrapToken({ - req, - res, - token, - sessionTtlMs: tunnelSessionTtlMs, - }); - - res.setHeader('Cache-Control', 'no-store'); - - if (!exchange.ok) { - if (exchange.reason === 'rate-limited') { - res.setHeader('Retry-After', String(exchange.retryAfter || 60)); - return res.status(429).type('text/plain').send('Too many attempts. Please try again later.'); - } - return res.status(401).type('text/plain').send('Connection link is invalid or expired.'); - } - - return res.redirect(302, '/'); - } catch (error) { - return res.status(500).type('text/plain').send('Failed to process connect request.'); - } - }); - - app.use('/api', async (req, res, next) => { - try { - const requestScope = tunnelAuthController.classifyRequestScope(req); - if (requestScope === 'tunnel' || requestScope === 'unknown-public') { - return tunnelAuthController.requireTunnelSession(req, res, next); - } - await uiAuthController.requireAuth(req, res, next); - } catch (err) { - next(err); - } - }); - - const parsePushSubscribeBody = (body) => { - if (!body || typeof body !== 'object') return null; - const endpoint = body.endpoint; - const keys = body.keys; - const p256dh = keys?.p256dh; - const auth = keys?.auth; - - if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; - if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null; - if (typeof auth !== 'string' || auth.trim().length === 0) return null; - - return { - endpoint: endpoint.trim(), - keys: { p256dh: p256dh.trim(), auth: auth.trim() }, - }; - }; - - const parsePushUnsubscribeBody = (body) => { - if (!body || typeof body !== 'object') return null; - const endpoint = body.endpoint; - if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; - return { endpoint: endpoint.trim() }; - }; - - app.get('/api/push/vapid-public-key', async (req, res) => { - try { - await ensurePushInitialized(); - const keys = await getOrCreateVapidKeys(); - res.json({ publicKey: keys.publicKey }); - } catch (error) { - console.warn('[Push] Failed to load VAPID key:', error); - res.status(500).json({ error: 'Failed to load push key' }); - } - }); - - app.post('/api/push/subscribe', async (req, res) => { - await ensurePushInitialized(); - - const uiToken = uiAuthController?.ensureSessionToken - ? await uiAuthController.ensureSessionToken(req, res) - : getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - const parsed = parsePushSubscribeBody(req.body); - if (!parsed) { - return res.status(400).json({ error: 'Invalid body' }); - } - - const { endpoint, keys } = parsed; - - const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : ''; - if (origin.startsWith('http://') || origin.startsWith('https://')) { - try { - const settings = await readSettingsFromDiskMigrated(); - if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) { - await writeSettingsToDisk({ - ...settings, - publicOrigin: origin, - }); - // allow next sends to pick it up - pushInitialized = false; - } - } catch { - // ignore - } - } - - await addOrUpdatePushSubscription( - uiToken, - { - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - }, - req.headers['user-agent'] - ); - - res.json({ ok: true }); - }); - - - app.delete('/api/push/subscribe', async (req, res) => { - await ensurePushInitialized(); - - const uiToken = uiAuthController?.ensureSessionToken - ? await uiAuthController.ensureSessionToken(req, res) - : getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - const parsed = parsePushUnsubscribeBody(req.body); - if (!parsed) { - return res.status(400).json({ error: 'Invalid body' }); - } - - await removePushSubscription(uiToken, parsed.endpoint); - res.json({ ok: true }); - }); - - app.post('/api/push/visibility', async (req, res) => { - const uiToken = uiAuthController?.ensureSessionToken - ? await uiAuthController.ensureSessionToken(req, res) - : getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - const visible = req.body && typeof req.body === 'object' ? req.body.visible : null; - updateUiVisibility(uiToken, visible === true); - res.json({ ok: true }); - }); - - app.get('/api/push/visibility', (req, res) => { - const uiToken = getUiSessionTokenFromRequest(req); - if (!uiToken) { - return res.status(401).json({ error: 'UI session missing' }); - } - - res.json({ - ok: true, - visible: isUiVisible(uiToken), - }); - }); - - // Session activity status endpoint - returns tracked activity phases for all sessions - // Used by UI on visibility restore to get accurate status without waiting for SSE - app.get('/api/session-activity', (_req, res) => { - res.json(getSessionActivitySnapshot()); - }); - - // Voice token endpoint - returns OpenAI TTS availability status - app.post('/api/voice/token', async (req, res) => { - console.log('[Voice] Token request received:', { - contentType: req.headers['content-type'] || null, - }); - try { - const openaiApiKey = process.env.OPENAI_API_KEY; - console.log('[Voice] OpenAI API Key present:', !!openaiApiKey); - - if (!openaiApiKey) { - return res.status(503).json({ - allowed: false, - error: 'OpenAI voice service not configured. Set OPENAI_API_KEY environment variable.' - }); - } - - // Return success - OpenAI TTS is available - res.json({ - allowed: true, - provider: 'openai', - message: 'OpenAI TTS is available' - }); - } catch (error) { - console.error('[Voice] Token generation error:', error); - res.status(500).json({ - allowed: false, - error: 'Voice service error' - }); - } - }); - - // Server-side TTS endpoint - streams audio from OpenAI TTS API - app.post('/api/tts/speak', async (req, res) => { - try { - const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey } = req.body || {}; - - console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey }); - - if (!text || typeof text !== 'string' || !text.trim()) { - return res.status(400).json({ error: 'Text is required' }); - } - - // Dynamically import the TTS service (ESM) - const { ttsService } = await import('./lib/tts/index.js'); - - // Check availability - either server-configured or client-provided API key - const hasServerKey = ttsService.isAvailable(); - const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0; - - if (!hasServerKey && !hasClientKey) { - return res.status(503).json({ - error: 'TTS service not available. Please configure OpenAI in OpenCode or provide an API key in settings.' - }); - } - - let textToSpeak = text.trim(); - - // Optionally summarize long text before speaking using zen API - if (summarize && textToSpeak.length > threshold) { - try { - const { summarizeText } = await import('./lib/tts/index.js'); - const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel }); - - if (result.summarized && result.summary) { - textToSpeak = result.summary; - } - } catch (summarizeError) { - console.error('[TTS/speak] Summarization failed:', summarizeError); - // Continue with original text if summarization fails - } - } - - const result = await ttsService.generateSpeechStream({ - text: textToSpeak, - voice, - model, - speed, - instructions, - apiKey: hasClientKey ? apiKey.trim() : undefined - }); - - // Set headers for audio streaming - // Note: Don't set Transfer-Encoding manually - Express handles it automatically - res.setHeader('Content-Type', result.contentType); - res.setHeader('Cache-Control', 'no-cache'); - - // Collect the full audio buffer and send it - // This avoids chunked encoding issues with proxies - const reader = result.stream.getReader(); - const chunks = []; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(Buffer.from(value)); - } - const audioBuffer = Buffer.concat(chunks); - res.setHeader('Content-Length', audioBuffer.length); - res.send(audioBuffer); - } catch (streamError) { - console.error('[TTS] Stream error:', streamError); - if (!res.headersSent) { - res.status(500).json({ error: 'Stream error' }); - } else { - res.end(); - } - } - } catch (error) { - console.error('[TTS] Error:', error); - if (!res.headersSent) { - res.status(500).json({ - error: error instanceof Error ? error.message : 'TTS generation failed' - }); - } - } - }); - - // Import summarization service - const { summarizeText, sanitizeForTTS } = await import('./lib/tts/index.js'); - - app.post('/api/tts/summarize', async (req, res) => { - try { - const { text, threshold = 200, maxLength = 500 } = req.body || {}; - - if (!text || typeof text !== 'string' || !text.trim()) { - return res.status(400).json({ error: 'Text is required' }); - } - - const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel }); - - return res.json(result); - } catch (error) { - console.error('[Summarize] Error:', error); - const sanitized = sanitizeForTTS(req.body?.text || ''); - return res.json({ summary: sanitized, summarized: false, reason: error.message }); - } - }); - - - // TTS status endpoint - app.get('/api/tts/status', async (_req, res) => { - try { - const { ttsService } = await import('./lib/tts/index.js'); - res.json({ - available: ttsService.isAvailable(), - voices: [ - 'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', - 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar' - ] - }); - } catch (error) { - res.status(500).json({ error: 'Failed to check TTS status' }); - } - }); - - // macOS 'say' command TTS status endpoint - returns cached capability from startup - app.get('/api/tts/say/status', (_req, res) => { - res.json(sayTTSCapability); - }); - - // macOS 'say' command TTS speak endpoint - app.post('/api/tts/say/speak', async (req, res) => { - try { - const { text, voice = 'Samantha', rate = 200 } = req.body || {}; - - if (!text || typeof text !== 'string' || !text.trim()) { - return res.status(400).json({ error: 'Text is required' }); - } - - // Check if we're on macOS - if (process.platform !== 'darwin') { - return res.status(503).json({ error: 'macOS say command not available on this platform' }); - } - - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const fs = await import('fs'); - const os = await import('os'); - const path = await import('path'); - const execAsync = promisify(exec); - - // Create temp file for audio output (use m4a for browser compatibility) - const tempDir = os.tmpdir(); - const tempFile = path.join(tempDir, `say-${Date.now()}.m4a`); - - // Escape text for shell - escape both single quotes and double quotes - const escapedText = text.trim().replace(/'/g, "'\\''").replace(/"/g, '\\"'); - - // Generate audio file using 'say' command - // -o outputs to file, -r sets rate (words per minute) - // --data-format=aac outputs as m4a which browsers can decode - const cmd = `say -v "${voice}" -r ${rate} -o "${tempFile}" --data-format=aac '${escapedText}'`; - console.log('[TTS-Say] Generating speech:', { textLength: text.length, voice, rate }); - - await execAsync(cmd); - - // Read the generated audio file - const audioBuffer = await fs.promises.readFile(tempFile); - - // Clean up temp file - fs.promises.unlink(tempFile).catch(() => {}); - - // Send audio response - res.setHeader('Content-Type', 'audio/mp4'); - res.setHeader('Content-Length', audioBuffer.length); - res.send(audioBuffer); - - } catch (error) { - console.error('[TTS-Say] Error:', error); - res.status(500).json({ - error: error instanceof Error ? error.message : 'Say command failed' - }); - } - }); - - // New authoritative session status endpoints - // Server maintains the source of truth, clients only query - - // GET /api/sessions/snapshot - Combined status + attention snapshot - app.get('/api/sessions/snapshot', (_req, res) => { - res.json({ - statusSessions: getSessionStateSnapshot(), - attentionSessions: getSessionAttentionSnapshot(), - serverTime: Date.now() - }); - }); - - // GET /api/sessions/status - Get status for all sessions - app.get('/api/sessions/status', (_req, res) => { - const snapshot = getSessionStateSnapshot(); - res.json({ - sessions: snapshot, - serverTime: Date.now() - }); - }); - - // GET /api/sessions/:id/status - Get status for a specific session - app.get('/api/sessions/:id/status', (req, res) => { - const sessionId = req.params.id; - const state = getSessionState(sessionId); - - if (!state) { - return res.status(404).json({ - error: 'Session not found or no state available', - sessionId - }); - } - - res.json({ - sessionId, - ...state - }); - }); - - // Session attention tracking endpoints - // GET /api/sessions/attention - Get attention state for all sessions - app.get('/api/sessions/attention', (_req, res) => { - const snapshot = getSessionAttentionSnapshot(); - res.json({ - sessions: snapshot, - serverTime: Date.now() - }); - }); - - // GET /api/sessions/:id/attention - Get attention state for a specific session - app.get('/api/sessions/:id/attention', (req, res) => { - const sessionId = req.params.id; - const state = getSessionAttentionState(sessionId); - - if (!state) { - return res.status(404).json({ - error: 'Session not found or no attention state available', - sessionId - }); - } - - res.json({ - sessionId, - ...state - }); - }); - - // POST /api/sessions/:id/view - Client reports viewing this session - app.post('/api/sessions/:id/view', (req, res) => { - const sessionId = req.params.id; - const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; - - markSessionViewed(sessionId, clientId); - - res.json({ - success: true, - sessionId, - viewed: true - }); - }); - - // POST /api/sessions/:id/unview - Client reports leaving this session - app.post('/api/sessions/:id/unview', (req, res) => { - const sessionId = req.params.id; - const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; - - markSessionUnviewed(sessionId, clientId); - - res.json({ - success: true, - sessionId, - viewed: false - }); - }); - - // POST /api/sessions/:id/message-sent - User sent a message in this session - app.post('/api/sessions/:id/message-sent', (req, res) => { - const sessionId = req.params.id; - - markUserMessageSent(sessionId); - - res.json({ - success: true, - sessionId, - messageSent: true - }); - }); - - app.get('/api/openchamber/update-check', async (req, res) => { - try { - const { checkForUpdates } = await import('./lib/package-manager.js'); - const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); - const parseReportUsage = (value) => { - if (typeof value !== 'string') return true; - const normalized = value.trim().toLowerCase(); - if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; - return true; - }; - const inferDeviceClass = (ua) => { - const value = (ua || '').toLowerCase(); - if (!value) return 'unknown'; - if (value.includes('ipad') || value.includes('tablet')) return 'tablet'; - if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile'; - return 'desktop'; - }; - const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''; - - const updateInfo = await checkForUpdates({ - appType: parseString(req.query.appType), - deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent), - platform: parseString(req.query.platform), - arch: parseString(req.query.arch), - instanceMode: parseString(req.query.instanceMode), - currentVersion: parseString(req.query.currentVersion), - reportUsage: parseReportUsage(parseString(req.query.reportUsage)), - }); - res.json(updateInfo); - } catch (error) { - console.error('Failed to check for updates:', error); - res.status(500).json({ - available: false, - error: error instanceof Error ? error.message : 'Failed to check for updates', - }); - } - }); - - app.post('/api/openchamber/update-install', async (_req, res) => { - try { - const { spawn: spawnChild } = await import('child_process'); - const { - checkForUpdates, - getUpdateCommand, - detectPackageManager, - } = await import('./lib/package-manager.js'); - - // Verify update is available - const updateInfo = await checkForUpdates(); - if (!updateInfo.available) { - return res.status(400).json({ error: 'No update available' }); - } - - const pm = detectPackageManager(); - const updateCmd = getUpdateCommand(pm); - const isContainer = - fs.existsSync('/.dockerenv') || - Boolean(process.env.CONTAINER) || - process.env.container === 'docker'; - - if (isContainer) { - res.json({ - success: true, - message: 'Update starting, server will stay online', - version: updateInfo.version, - packageManager: pm, - autoRestart: false, - }); - - setTimeout(() => { - console.log(`\nInstalling update using ${pm} (container mode)...`); - console.log(`Running: ${updateCmd}`); - - const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh'; - const shellFlag = process.platform === 'win32' ? '/c' : '-c'; - const child = spawnChild(shell, [shellFlag, updateCmd], { - detached: true, - stdio: 'ignore', - env: process.env, - }); - child.unref(); - }, 500); - - return; - } - - // Get current server port for restart - const currentPort = server.address()?.port || 3000; - - // Try to read stored instance options for restart - const tmpDir = os.tmpdir(); - const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`); - let storedOptions = { port: currentPort, daemon: true }; - try { - const content = await fs.promises.readFile(instanceFilePath, 'utf8'); - storedOptions = JSON.parse(content); - } catch { - // Use defaults - } - - const isWindows = process.platform === 'win32'; - - const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`; - const quoteCmd = (value) => { - const stringValue = String(value); - return `"${stringValue.replace(/"/g, '""')}"`; - }; - - // Build restart command using explicit runtime + CLI path. - // Avoids relying on `openchamber` being in PATH for service environments. - const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js'); - const restartParts = [ - isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath), - isWindows ? quoteCmd(cliPath) : quotePosix(cliPath), - 'serve', - '--port', - String(storedOptions.port), - '--daemon', - ]; - let restartCmdPrimary = restartParts.join(' '); - let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`; - if (storedOptions.uiPassword) { - if (isWindows) { - // Escape for cmd.exe quoted argument - const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); - restartCmdPrimary += ` --ui-password "${escapedPw}"`; - restartCmdFallback += ` --ui-password "${escapedPw}"`; - } else { - // Escape for POSIX single-quoted argument - const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''"); - restartCmdPrimary += ` --ui-password '${escapedPw}'`; - restartCmdFallback += ` --ui-password '${escapedPw}'`; - } - } - const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`; - - // Respond immediately - update will happen after response - res.json({ - success: true, - message: 'Update starting, server will restart shortly', - version: updateInfo.version, - packageManager: pm, - autoRestart: true, - }); - - // Give time for response to be sent - setTimeout(() => { - console.log(`\nInstalling update using ${pm}...`); - console.log(`Running: ${updateCmd}`); - - // Create a script that will: - // 1. Wait for current process to exit - // 2. Run the update - // 3. Restart the server with original options - const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh'; - const shellFlag = isWindows ? '/c' : '-c'; - const script = isWindows - ? ` - timeout /t 2 /nobreak >nul - ${updateCmd} - if %ERRORLEVEL% EQU 0 ( - echo Update successful, restarting OpenChamber... - ${restartCmd} - ) else ( - echo Update failed - exit /b 1 - ) - ` - : ` - sleep 2 - ${updateCmd} - if [ $? -eq 0 ]; then - echo "Update successful, restarting OpenChamber..." - ${restartCmd} - else - echo "Update failed" - exit 1 - fi - `; - - // Spawn detached shell to run update after we exit. - // Capture output to disk so restart failures are diagnosable. - const updateLogPath = path.join(OPENCHAMBER_DATA_DIR, 'update-install.log'); - let logFd = null; - try { - fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); - logFd = fs.openSync(updateLogPath, 'a'); - } catch (logError) { - console.warn('Failed to open update log file, continuing without log capture:', logError); - } - - const child = spawnChild(shell, [shellFlag, script], { - detached: true, - stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore', - env: process.env, - }); - child.unref(); - - if (logFd !== null) { - try { - fs.closeSync(logFd); - } catch { - // ignore - } - } - - console.log('Update process spawned, shutting down server...'); - - // Give child process time to start, then exit - setTimeout(() => { - process.exit(0); - }, 500); - }, 500); - } catch (error) { - console.error('Failed to install update:', error); - res.status(500).json({ - error: error instanceof Error ? error.message : 'Failed to install update', - }); - } - }); - - app.get('/api/openchamber/models-metadata', async (req, res) => { - const now = Date.now(); - - if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < MODELS_METADATA_CACHE_TTL) { - res.setHeader('Cache-Control', 'public, max-age=60'); - return res.json(cachedModelsMetadata); - } - - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; - - try { - const response = await fetch(MODELS_DEV_API_URL, { - signal: controller?.signal, - headers: { - Accept: 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`models.dev responded with status ${response.status}`); - } - - const metadata = await response.json(); - cachedModelsMetadata = metadata; - cachedModelsMetadataTimestamp = Date.now(); - - res.setHeader('Cache-Control', 'public, max-age=300'); - res.json(metadata); - } catch (error) { - console.warn('Failed to fetch models.dev metadata via server:', error); - - if (cachedModelsMetadata) { - res.setHeader('Cache-Control', 'public, max-age=60'); - res.json(cachedModelsMetadata); - } else { - const statusCode = error?.name === 'AbortError' ? 504 : 502; - res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); - } - } finally { - if (timeout) { - clearTimeout(timeout); - } - } - }); - - // Zen models endpoint - returns available free models from the zen API - app.get('/api/zen/models', async (_req, res) => { - try { - const models = await fetchFreeZenModels(); - res.setHeader('Cache-Control', 'public, max-age=300'); - res.json({ models }); - } catch (error) { - console.warn('Failed to fetch zen models:', error); - // Serve stale cache if available - if (cachedZenModels) { - res.setHeader('Cache-Control', 'public, max-age=60'); - res.json(cachedZenModels); - } else { - const statusCode = error?.name === 'AbortError' ? 504 : 502; - res.status(statusCode).json({ error: 'Failed to retrieve zen models' }); - } - } - }); - - const tunnelService = createTunnelService({ - registry: tunnelProviderRegistry, - getController: () => activeTunnelController, - setController: (controller) => { - activeTunnelController = controller; + const startupPipelineResult = await startupPipelineRuntime.run({ + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + terminalHeartbeatIntervalMs: TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS, + terminalRebindWindowMs: TERMINAL_INPUT_WS_REBIND_WINDOW_MS, + terminalMaxRebindsPerWindow: TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW, + setupProxy, + scheduleOpenCodeApiDetection, + bootstrapOpenCodeAtStartup, + staticRoutesRuntime, + process, + crypto, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached: () => signalsAttached, + setSignalsAttached: (value) => { + signalsAttached = value; }, - getActivePort: () => activePort, - onQuickTunnelWarning: () => { - printTunnelWarning(); - }, - }); - - const resolveActiveNormalizedTunnelMode = () => { - const mode = tunnelService.resolveActiveMode(); - if (mode === TUNNEL_MODE_MANAGED_LOCAL) { - return TUNNEL_MODE_MANAGED_LOCAL; - } - if (mode === TUNNEL_MODE_MANAGED_REMOTE) { - return TUNNEL_MODE_MANAGED_REMOTE; - } - return TUNNEL_MODE_QUICK; - }; - - const resolveNormalizedTunnelHost = (publicUrl) => { - if (typeof publicUrl !== 'string' || publicUrl.trim().length === 0) { - return null; - } - try { - return new URL(publicUrl).hostname.toLowerCase(); - } catch { - return null; - } - }; - - const resolvePreferredTunnelProvider = async (reqBody = null) => { - if (typeof reqBody?.provider === 'string' && reqBody.provider.trim().length > 0) { - return normalizeTunnelProvider(reqBody.provider); - } - const activeProvider = tunnelService.resolveActiveProvider(); - if (activeProvider) { - return normalizeTunnelProvider(activeProvider); - } - const settings = await readSettingsFromDiskMigrated(); - return normalizeTunnelProvider(settings?.tunnelProvider); - }; - - const startTunnelWithNormalizedRequest = async ({ - provider, - mode, - intent, - hostname, - token, - configPath, - selectedPresetId, - selectedPresetName, - }) => { - if (provider === TUNNEL_PROVIDER_CLOUDFLARE && mode === TUNNEL_MODE_MANAGED_REMOTE) { - runtimeManagedRemoteTunnelHostname = hostname; - runtimeManagedRemoteTunnelToken = token; - - if (token && hostname) { - await upsertManagedRemoteTunnelToken({ - id: selectedPresetId || hostname, - name: selectedPresetName || hostname, - hostname, - token, - }); - } - } - - const result = await tunnelService.start({ - provider, - mode, - intent, - configPath, - token, - hostname, - }); - - console.log(`Tunnel active (${result.provider}): ${result.publicUrl}`); - return { - publicUrl: result.publicUrl, - mode: result.activeMode, - provider: result.provider, - providerMetadata: result.providerMetadata, - }; - }; - - const createGenericModeChecks = ({ modeKey, requiredFields, doctorRequest, startupReady }) => { - const checks = [ - { - id: 'startup_readiness', - label: 'Provider startup readiness', - status: startupReady ? 'pass' : 'fail', - detail: startupReady - ? 'Provider dependency checks passed.' - : 'Resolve provider checks before starting tunnels.', - }, - ]; - - for (const field of requiredFields) { - const value = doctorRequest?.[field]; - const present = typeof value === 'string' ? value.trim().length > 0 : Boolean(value); - checks.push({ - id: `requirement_${field}`, - label: `Required: ${field}`, - status: present ? 'pass' : 'fail', - detail: present - ? `${field} is configured.` - : `${field} is required for ${modeKey}.`, - }); - } - - const failures = checks.filter((entry) => entry.status === 'fail').length; - const warnings = checks.filter((entry) => entry.status === 'warn').length; - return { - mode: modeKey, - checks, - summary: { - ready: failures === 0, - failures, - warnings, - }, - ready: failures === 0, - blockers: checks - .filter((entry) => entry.status === 'fail' && entry.id !== 'startup_readiness') - .map((entry) => entry.detail || entry.label || entry.id), - }; - }; - - const runTunnelDoctor = async ({ providerId, modeFilter, doctorRequest }) => { - const provider = tunnelProviderRegistry.get(providerId); - if (!provider) { - throw new TunnelServiceError('provider_unsupported', `Unsupported tunnel provider: ${providerId}`); - } - - const capabilities = provider.capabilities || {}; - const modeKeys = Array.isArray(capabilities.modes) - ? capabilities.modes.map((entry) => entry?.key).filter((key) => typeof key === 'string' && key.length > 0) - : []; - - if (modeFilter && !modeKeys.includes(modeFilter)) { - throw new TunnelServiceError('mode_unsupported', `Provider '${providerId}' does not support mode '${modeFilter}'`); - } - - if (typeof provider.diagnose === 'function') { - const diagnosed = await provider.diagnose({ - ...doctorRequest, - mode: modeFilter || doctorRequest?.mode, - }, { - capabilities, - }); - const providerChecks = Array.isArray(diagnosed?.providerChecks) ? diagnosed.providerChecks : []; - const allModes = Array.isArray(diagnosed?.modes) ? diagnosed.modes : []; - const modes = modeFilter ? allModes.filter((entry) => entry?.mode === modeFilter) : allModes; - return { - ok: true, - provider: providerId, - providerChecks, - modes, - }; - } - - const availability = await tunnelService.checkAvailability(providerId); - const dependencyAvailable = Boolean(availability?.available); - const providerChecks = [{ - id: 'dependency', - label: 'Provider dependency', - status: dependencyAvailable ? 'pass' : 'fail', - detail: dependencyAvailable - ? (availability?.version || 'available') - : (availability?.message || 'Required provider dependency is unavailable.'), - }]; - - const targetModes = (Array.isArray(capabilities.modes) ? capabilities.modes : []) - .filter((entry) => !modeFilter || entry?.key === modeFilter); - const modes = targetModes.map((entry) => createGenericModeChecks({ - modeKey: entry.key, - requiredFields: Array.isArray(entry?.requires) ? entry.requires : [], - doctorRequest, - startupReady: dependencyAvailable, - })); - - return { - ok: true, - provider: providerId, - providerChecks, - modes, - }; - }; - - // ── Tunnel API ───────────────────────────────────────────────────── - - app.get('/api/openchamber/tunnel/check', async (req, res) => { - try { - const requestedProvider = typeof req?.query?.provider === 'string' && req.query.provider.trim().length > 0 - ? normalizeTunnelProvider(req.query.provider) - : await resolvePreferredTunnelProvider(); - const result = await tunnelService.checkAvailability(requestedProvider); - res.json({ - available: result.available, - provider: requestedProvider, - version: result.version || null, - }); - } catch (error) { - console.warn('Tunnel dependency check failed:', error); - res.json({ available: false, provider: null, version: null }); - } - }); - - // Accept both POST (preferred, tokens in body) and GET (backward compat, no tokens in URL). - const handleTunnelDoctor = async (req, res) => { - try { - const params = req.query || {}; - // Sensitive fields (tokens) are read from the request body only, never from query params. - const body = req.body || {}; - - const providerId = typeof params.provider === 'string' && params.provider.trim().length > 0 - ? normalizeTunnelProvider(params.provider) - : await resolvePreferredTunnelProvider(); - const modeFilter = typeof params.mode === 'string' && params.mode.trim().length > 0 - ? params.mode.trim().toLowerCase() - : null; - - const settings = await readSettingsFromDiskMigrated(); - const selectedPresetId = typeof params.managedRemoteTunnelPresetId === 'string' - ? params.managedRemoteTunnelPresetId.trim() - : ''; - const requestConfigPath = normalizeOptionalPath(params.configPath) - ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); - const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(params.managedRemoteTunnelHostname); - const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(params.tunnelHostname); - const requestHostname = normalizeManagedRemoteTunnelHostname(params.hostname); - const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); - const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; - - const requestManagedRemoteToken = typeof body.managedRemoteTunnelToken === 'string' - ? body.managedRemoteTunnelToken.trim() - : ''; - const requestTunnelToken = typeof body.tunnelToken === 'string' - ? body.tunnelToken.trim() - : ''; - const requestToken = typeof body.token === 'string' - ? body.token.trim() - : ''; - const requestTokenProvided = body.managedRemoteTunnelTokenProvided === true - || body.tunnelTokenProvided === true - || body.tokenProvided === true; - const requestHostnameProvided = body.managedRemoteTunnelHostnameProvided === true - || body.tunnelHostnameProvided === true - || body.hostnameProvided === true; - const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' - ? settings.managedRemoteTunnelToken.trim() - : ''; - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - const serverHasSavedManagedRemoteProfile = managedRemoteTunnelConfig.tunnels.some((entry) => { - const savedHostname = normalizeManagedRemoteTunnelHostname(entry?.hostname); - const savedToken = typeof entry?.token === 'string' ? entry.token.trim() : ''; - return Boolean(savedHostname && savedToken); - }); - const cliHasSavedManagedRemoteProfile = params.hasSavedManagedRemoteProfile === '1'; - const hasSavedManagedRemoteProfile = serverHasSavedManagedRemoteProfile || cliHasSavedManagedRemoteProfile; - const configManagedRemoteToken = providerId === TUNNEL_PROVIDER_CLOUDFLARE - ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) - : ''; - const token = requestToken - || requestTunnelToken - || requestManagedRemoteToken - || ((runtimeManagedRemoteTunnelHostname && hostname && runtimeManagedRemoteTunnelHostname === hostname) ? runtimeManagedRemoteTunnelToken : '') - || configManagedRemoteToken - || storedManagedRemoteToken; - - const doctorRequest = { - mode: modeFilter, - hostname, - token, - tokenProvided: requestTokenProvided, - hostnameProvided: requestHostnameProvided, - configPath: requestConfigPath, - hasSavedManagedRemoteProfile, - }; - - const result = await runTunnelDoctor({ - providerId, - modeFilter, - doctorRequest, - }); - return res.json(result); - } catch (error) { - if (error instanceof TunnelServiceError) { - return res.status(400).json({ ok: false, error: error.message, code: error.code }); - } - console.warn('Tunnel doctor failed:', error); - return res.status(500).json({ ok: false, error: 'Failed to run tunnel doctor' }); - } - }; - app.post('/api/openchamber/tunnel/doctor', handleTunnelDoctor); - app.get('/api/openchamber/tunnel/doctor', handleTunnelDoctor); - - app.get('/api/openchamber/tunnel/providers', (_req, res) => { - const providers = tunnelProviderRegistry.listCapabilities(); - return res.json({ providers }); - }); - - app.get('/api/openchamber/tunnel/status', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - const normalizedMode = normalizeTunnelMode(settings?.tunnelMode); - const managedRemoteHostname = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - const managedRemoteTunnelPresetSummaries = managedRemoteTunnelConfig.tunnels.map((entry) => ({ - id: entry.id, - name: entry.name, - hostname: entry.hostname, - })); - const hasStoredManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; - const hasManagedRemoteTunnelToken = runtimeManagedRemoteTunnelToken.length > 0 || managedRemoteTunnelConfig.tunnels.length > 0 || hasStoredManagedRemoteToken; - const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null - ? null - : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); - const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); - const activeSessions = tunnelAuthController.listTunnelSessions(); - const activeProvider = tunnelService.resolveActiveProvider(); - const provider = activeProvider || normalizeTunnelProvider(settings?.tunnelProvider); - - const publicUrl = tunnelService.getPublicUrl(); - if (!publicUrl) { - return res.json({ - active: false, - url: null, - mode: normalizedMode, - provider, - providerMetadata: null, - hasManagedRemoteTunnelToken, - managedRemoteTunnelHostname: managedRemoteHostname || null, - managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, - managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), - hasBootstrapToken: false, - bootstrapExpiresAt: null, - policy: 'tunnel-gated', - activeTunnelMode: tunnelAuthController.getActiveTunnelMode() || null, - activeSessions, - localPort: activePort, - ttlConfig: { - bootstrapTtlMs, - sessionTtlMs, - }, - }); - } - - const activeNormalizedMode = resolveActiveNormalizedTunnelMode(); - const activeTunnelId = tunnelAuthController.getActiveTunnelId(); - const activeTunnelHost = tunnelAuthController.getActiveTunnelHost(); - const resolvedTunnelHost = resolveNormalizedTunnelHost(publicUrl); - const activeTunnelMode = tunnelAuthController.getActiveTunnelMode(); - const needsActiveTunnelSync = !activeTunnelId - || !activeTunnelHost - || !resolvedTunnelHost - || activeTunnelHost !== resolvedTunnelHost - || activeTunnelMode !== activeNormalizedMode; - if (needsActiveTunnelSync) { - tunnelAuthController.setActiveTunnel({ - tunnelId: activeTunnelId || crypto.randomUUID(), - publicUrl, - mode: activeNormalizedMode, - }); - } - - const bootstrapStatus = tunnelAuthController.getBootstrapStatus(); - const providerMetadata = tunnelService.getProviderMetadata(); - - return res.json({ - active: true, - url: publicUrl, - mode: activeNormalizedMode, - provider, - providerMetadata, - hasManagedRemoteTunnelToken, - managedRemoteTunnelHostname: managedRemoteHostname || null, - managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, - managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), - hasBootstrapToken: bootstrapStatus.hasBootstrapToken, - bootstrapExpiresAt: bootstrapStatus.bootstrapExpiresAt, - policy: 'tunnel-gated', - activeTunnelMode: activeNormalizedMode, - activeSessions: tunnelAuthController.listTunnelSessions(), - localPort: activePort, - ttlConfig: { - bootstrapTtlMs, - sessionTtlMs, - }, - }); - } catch (error) { - return res.status(500).json({ error: 'Failed to get tunnel status' }); - } - }); - - app.put('/api/openchamber/tunnel/managed-remote-token', async (req, res) => { - try { - // Token presets are currently Cloudflare-specific. - const presetId = typeof req?.body?.presetId === 'string' ? req.body.presetId.trim() : ''; - const presetName = typeof req?.body?.presetName === 'string' ? req.body.presetName.trim() : ''; - const managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(req?.body?.managedRemoteTunnelHostname); - const managedRemoteTunnelToken = typeof req?.body?.managedRemoteTunnelToken === 'string' ? req.body.managedRemoteTunnelToken.trim() : ''; - - if (!presetId || !presetName || !managedRemoteTunnelHostname || !managedRemoteTunnelToken) { - return res.status(400).json({ ok: false, error: 'presetId, presetName, managedRemoteTunnelHostname and managedRemoteTunnelToken are required' }); - } - - await upsertManagedRemoteTunnelToken({ - id: presetId, - name: presetName, - hostname: managedRemoteTunnelHostname, - token: managedRemoteTunnelToken, - }); - - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - return res.json({ ok: true, managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) }); - } catch (error) { - return res.status(500).json({ ok: false, error: 'Failed to save managed remote tunnel token' }); - } - }); - - app.post('/api/openchamber/tunnel/start', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - // Reject explicitly supplied unknown providers/modes early, before normalization converts them to defaults. - if (typeof _req?.body?.provider === 'string' && _req.body.provider.trim().length > 0) { - const rawProvider = _req.body.provider.trim().toLowerCase(); - if (!tunnelProviderRegistry.get(rawProvider)) { - return res.status(422).json({ ok: false, error: `Unsupported tunnel provider: ${rawProvider}`, code: 'provider_unsupported' }); - } - } - const provider = normalizeTunnelProvider(_req?.body?.provider ?? settings?.tunnelProvider); - const modeInput = _req?.body?.mode ?? settings?.tunnelMode; - const intent = typeof _req?.body?.intent === 'string' ? _req.body.intent.trim().toLowerCase() : undefined; - const mode = typeof modeInput === 'string' - ? modeInput.trim().toLowerCase() - : normalizeTunnelMode(modeInput); - if (typeof _req?.body?.mode === 'string' && _req.body.mode.trim().length > 0 && !isSupportedTunnelMode(mode)) { - return res.status(422).json({ ok: false, error: `Unsupported tunnel mode: ${mode}`, code: 'mode_unsupported' }); - } - const selectedPresetId = typeof _req?.body?.managedRemoteTunnelPresetId === 'string' ? _req.body.managedRemoteTunnelPresetId.trim() : ''; - const selectedPresetName = typeof _req?.body?.managedRemoteTunnelPresetName === 'string' ? _req.body.managedRemoteTunnelPresetName.trim() : ''; - const requestConfigPath = normalizeOptionalPath(_req?.body?.configPath) - ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); - const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.managedRemoteTunnelHostname); - const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.tunnelHostname); - const requestHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.hostname); - const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); - const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; - const requestManagedRemoteToken = typeof _req?.body?.managedRemoteTunnelToken === 'string' ? _req.body.managedRemoteTunnelToken.trim() : ''; - const requestTunnelToken = typeof _req?.body?.tunnelToken === 'string' ? _req.body.tunnelToken.trim() : ''; - const requestToken = typeof _req?.body?.token === 'string' ? _req.body.token.trim() : ''; - const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' ? settings.managedRemoteTunnelToken.trim() : ''; - const configManagedRemoteToken = provider === TUNNEL_PROVIDER_CLOUDFLARE - ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) - : ''; - const token = requestToken - || requestTunnelToken - || requestManagedRemoteToken - || ((runtimeManagedRemoteTunnelHostname && hostname && runtimeManagedRemoteTunnelHostname === hostname) ? runtimeManagedRemoteTunnelToken : '') - || configManagedRemoteToken - || storedManagedRemoteToken; - const requestConnectTtlMs = typeof _req?.body?.connectTtlMs === 'number' && Number.isFinite(_req.body.connectTtlMs) - ? normalizeTunnelBootstrapTtlMs(_req.body.connectTtlMs) - : undefined; - const requestSessionTtlMs = typeof _req?.body?.sessionTtlMs === 'number' && Number.isFinite(_req.body.sessionTtlMs) - ? normalizeTunnelSessionTtlMs(_req.body.sessionTtlMs) - : undefined; - const bootstrapTtlMs = requestConnectTtlMs ?? (settings?.tunnelBootstrapTtlMs === null - ? null - : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs)); - const sessionTtlMs = requestSessionTtlMs ?? normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); - - const previousTunnelId = tunnelAuthController.getActiveTunnelId(); - const previousMode = tunnelAuthController.getActiveTunnelMode(); - const previousProvider = tunnelService.resolveActiveProvider(); - const previousUrl = tunnelService.getPublicUrl(); - - const { publicUrl, provider: activeProvider, providerMetadata } = await startTunnelWithNormalizedRequest({ - provider, - mode, - intent, - hostname, - token, - configPath: requestConfigPath, - selectedPresetId, - selectedPresetName, - }); - - const replacedTunnel = Boolean(previousTunnelId) && ( - previousMode !== mode - || previousProvider !== activeProvider - || previousUrl !== publicUrl - ); - let revokedBootstrapCount = 0; - let invalidatedSessionCount = 0; - if (replacedTunnel && previousTunnelId) { - const revoked = tunnelAuthController.revokeTunnelArtifacts(previousTunnelId); - revokedBootstrapCount = revoked.revokedBootstrapCount; - invalidatedSessionCount = revoked.invalidatedSessionCount; - } - - tunnelAuthController.setActiveTunnel({ - tunnelId: replacedTunnel || !previousTunnelId ? crypto.randomUUID() : previousTunnelId, - publicUrl, - mode, - }); - - const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); - const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; - const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); - const isCloudflareProvider = activeProvider === TUNNEL_PROVIDER_CLOUDFLARE; - - return res.json({ - ok: true, - url: publicUrl, - mode, - provider: activeProvider, - providerMetadata, - managedRemoteTunnelHostname: isCloudflareProvider ? (hostname || null) : null, - managedRemoteTunnelTokenPresetIds: isCloudflareProvider ? managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) : [], - connectUrl, - bootstrapExpiresAt: bootstrapToken.expiresAt, - replacedTunnel, - replaced: replacedTunnel - ? { - mode: previousMode, - provider: previousProvider, - url: previousUrl, - } - : null, - revokedBootstrapCount, - invalidatedSessionCount, - policy: 'tunnel-gated', - activeTunnelMode: mode, - activeSessions: tunnelAuthController.listTunnelSessions(), - localPort: activePort, - ttlConfig: { - bootstrapTtlMs, - sessionTtlMs, - }, - }); - } catch (error) { - console.error('Failed to start tunnel:', error); - activeTunnelController = null; - tunnelAuthController.clearActiveTunnel(); - if (error instanceof TunnelServiceError) { - const status = error.code === 'missing_dependency' - ? 400 - : (error.code === 'validation_error' || error.code === 'provider_unsupported' || error.code === 'mode_unsupported' - ? 422 - : 500); - return res.status(status).json({ ok: false, error: error.message, code: error.code }); - } - return res.status(500).json({ ok: false, error: 'Failed to start tunnel', code: 'startup_failed' }); - } - }); - - app.post('/api/openchamber/tunnel/stop', (_req, res) => { - let revokedBootstrapCount = 0; - let invalidatedSessionCount = 0; - const activeTunnelId = tunnelAuthController.getActiveTunnelId(); - - if (activeTunnelId) { - const revoked = tunnelAuthController.revokeTunnelArtifacts(activeTunnelId); - revokedBootstrapCount = revoked.revokedBootstrapCount; - invalidatedSessionCount = revoked.invalidatedSessionCount; - } - - if (activeTunnelController) { - console.log('Stopping active tunnel (user requested)...'); - tunnelService.stop(); - } - - tunnelAuthController.clearActiveTunnel(); - res.json({ ok: true, revokedBootstrapCount, invalidatedSessionCount }); - }); - - // ── End Tunnel API ──────────────────────────────────────────────── - - app.get('/api/global/event', async (req, res) => { - let targetUrl; - try { - targetUrl = new URL(buildOpenCodeUrl('/global/event', '')); - } catch { - return res.status(503).json({ error: 'OpenCode service unavailable' }); - } - - const headers = { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...getOpenCodeAuthHeaders(), - }; - - const lastEventId = req.header('Last-Event-ID'); - if (typeof lastEventId === 'string' && lastEventId.length > 0) { - headers['Last-Event-ID'] = lastEventId; - } - - const controller = new AbortController(); - const cleanup = () => { - if (!controller.signal.aborted) { - controller.abort(); - } - }; - - req.on('close', cleanup); - req.on('error', cleanup); - - let upstream; - try { - upstream = await fetch(targetUrl.toString(), { - headers, - signal: controller.signal, - }); - } catch (error) { - return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' }); - } - - if (!upstream.ok || !upstream.body) { - return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` }); - } - - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - uiNotificationClients.add(res); - const cleanupClient = () => { - uiNotificationClients.delete(res); - }; - req.on('close', cleanupClient); - req.on('error', cleanupClient); - - const heartbeatInterval = setInterval(() => { - writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() }); - }, 15000); - - const decoder = new TextDecoder(); - const reader = upstream.body.getReader(); - let buffer = ''; - - const forwardBlock = (block) => { - if (!block) return; - const payload = parseSseDataPayload(block); - - res.write(`${block} - -`); - // Cache session titles from session.updated/session.created events (global stream) - maybeCacheSessionInfoFromEvent(payload); - - // Keep server-authoritative session state fresh even if the - // background watcher is disconnected. - if (payload && payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, { - attempt: update.attempt, - message: update.message, - next: update.next, - }); - } - } - - const transitions = deriveSessionActivityTransitions(payload); - if (transitions && transitions.length > 0) { - for (const activity of transitions) { - if (setSessionActivityPhase(activity.sessionId, activity.phase)) { - writeSseEvent(res, { - type: 'openchamber:session-activity', - properties: { - sessionId: activity.sessionId, - phase: activity.phase, - } - }); - } - } - } - }; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - - let separatorIndex = buffer.indexOf('\n\n'); - while (separatorIndex !== -1) { - const block = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - forwardBlock(block); - separatorIndex = buffer.indexOf('\n\n'); - } - } - - if (buffer.trim().length > 0) { - forwardBlock(buffer.trim()); - } - } catch (error) { - if (!controller.signal.aborted) { - console.warn('SSE proxy stream error:', error); - } - } finally { - clearInterval(heartbeatInterval); - cleanupClient(); - cleanup(); - try { - res.end(); - } catch { - // ignore - } - } - }); - - app.get('/api/event', async (req, res) => { - let targetUrl; - try { - targetUrl = new URL(buildOpenCodeUrl('/event', '')); - } catch { - return res.status(503).json({ error: 'OpenCode service unavailable' }); - } - - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const directoryParam = Array.isArray(req.query.directory) - ? req.query.directory[0] - : req.query.directory; - const resolvedDirectory = headerDirectory || directoryParam || null; - if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) { - targetUrl.searchParams.set('directory', resolvedDirectory.trim()); - } - - const headers = { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...getOpenCodeAuthHeaders(), - }; - - const lastEventId = req.header('Last-Event-ID'); - if (typeof lastEventId === 'string' && lastEventId.length > 0) { - headers['Last-Event-ID'] = lastEventId; - } - - const controller = new AbortController(); - const cleanup = () => { - if (!controller.signal.aborted) { - controller.abort(); - } - }; - - req.on('close', cleanup); - req.on('error', cleanup); - - let upstream; - try { - upstream = await fetch(targetUrl.toString(), { - headers, - signal: controller.signal, - }); - } catch (error) { - return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' }); - } - - if (!upstream.ok || !upstream.body) { - return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` }); - } - - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - const heartbeatInterval = setInterval(() => { - writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() }); - }, 15000); - - const decoder = new TextDecoder(); - const reader = upstream.body.getReader(); - let buffer = ''; - - const forwardBlock = (block) => { - if (!block) return; - const payload = parseSseDataPayload(block); - - res.write(`${block} - -`); - // Cache session titles from session.updated/session.created events (per-session stream) - maybeCacheSessionInfoFromEvent(payload); - - if (payload && payload.type === 'session.status') { - const update = extractSessionStatusUpdate(payload); - if (update) { - updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, { - attempt: update.attempt, - message: update.message, - next: update.next, - }); - } - } - - const transitions = deriveSessionActivityTransitions(payload); - if (transitions && transitions.length > 0) { - for (const activity of transitions) { - if (setSessionActivityPhase(activity.sessionId, activity.phase)) { - writeSseEvent(res, { - type: 'openchamber:session-activity', - properties: { - sessionId: activity.sessionId, - phase: activity.phase, - } - }); - } - } - } - }; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); - - let separatorIndex = buffer.indexOf('\n\n'); - while (separatorIndex !== -1) { - const block = buffer.slice(0, separatorIndex); - buffer = buffer.slice(separatorIndex + 2); - forwardBlock(block); - separatorIndex = buffer.indexOf('\n\n'); - } - } - - if (buffer.trim().length > 0) { - forwardBlock(buffer.trim()); - } - } catch (error) { - if (!controller.signal.aborted) { - console.warn('SSE proxy stream error:', error); - } - } finally { - clearInterval(heartbeatInterval); - cleanup(); - try { - res.end(); - } catch { - // ignore - } - } - }); - - app.get('/api/config/settings', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - res.json(formatSettingsResponse(settings)); - } catch (error) { - console.error('Failed to load settings:', error); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load settings' }); - } - }); - - app.get('/api/config/opencode-resolution', async (_req, res) => { - try { - const settings = await readSettingsFromDiskMigrated(); - const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null; - - const previousSource = resolvedOpencodeBinarySource; - const detectedNow = resolveOpencodeCliPath(); - const rawDetectedSourceNow = resolvedOpencodeBinarySource; - resolvedOpencodeBinarySource = previousSource; - - // Best-effort: apply configured override (if any) and resolve. - await applyOpencodeBinaryFromSettings(); - ensureOpencodeCliEnv(); - - const resolved = resolvedOpencodeBinary || null; - const source = resolvedOpencodeBinarySource || null; - const detectedSourceNow = - detectedNow && - resolved && - detectedNow === resolved && - rawDetectedSourceNow === 'env' && - source && - source !== 'env' - ? source - : rawDetectedSourceNow; - const shim = resolved ? opencodeShimInterpreter(resolved) : null; - - res.json({ - configured, - resolved, - resolvedDir: resolved ? path.dirname(resolved) : null, - source, - detectedNow, - detectedSourceNow, - shim, - viaWsl: useWslForOpencode, - wslBinary: resolvedWslBinary || null, - wslPath: resolvedWslOpencodePath || null, - wslDistro: resolvedWslDistro || null, - node: resolvedNodeBinary || null, - bun: resolvedBunBinary || null, - }); - } catch (error) { - console.error('Failed to build opencode resolution snapshot:', error); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to build snapshot' }); - } - }); - - app.get('/api/config/themes', async (_req, res) => { - try { - const customThemes = await readCustomThemesFromDisk(); - res.json({ themes: customThemes }); - } catch (error) { - console.error('Failed to load custom themes:', error); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' }); - } - }); - - app.put('/api/config/settings', async (req, res) => { - console.log(`[API:PUT /api/config/settings] Received request`); - try { - const updated = await persistSettings(req.body ?? {}); - console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`); - res.json(updated); - } catch (error) { - console.error(`[API:PUT /api/config/settings] Failed to save settings:`, error); - console.error(`[API:PUT /api/config/settings] Error stack:`, error.stack); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save settings' }); - } - }); - - app.get('/api/projects/:projectId/icon', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - const metadataMime = normalizeProjectIconMime(project.iconImage?.mime); - const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null; - const candidates = preferredPath - ? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)] - : projectIconPathCandidates(projectId); - - const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme; - const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery); - const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor; - const requestedIconColor = normalizeProjectIconColor(iconColorQuery); - - for (const iconPath of candidates) { - try { - const data = await fsPromises.readFile(iconPath); - const ext = path.extname(iconPath).slice(1).toLowerCase(); - const resolvedMime = metadataMime || PROJECT_ICON_EXTENSION_TO_MIME[ext] || 'application/octet-stream'; - const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime; - - if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) { - const svgMarkup = data.toString('utf8'); - const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - return res.send(themedSvgMarkup); - } - - if (resolvedMime === 'image/svg+xml' && requestedIconColor) { - const svgMarkup = data.toString('utf8'); - const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - return res.send(themedSvgMarkup); - } - - res.setHeader('Content-Type', contentType); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - return res.send(data); - } catch (error) { - if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { - console.warn('Failed to read project icon:', error); - return res.status(500).json({ error: 'Failed to read project icon' }); - } - } - } - - return res.status(404).json({ error: 'Project icon not found' }); - } catch (error) { - console.warn('Failed to load project icon:', error); - return res.status(500).json({ error: 'Failed to load project icon' }); - } - }); - - app.put('/api/projects/:projectId/icon', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - const parsed = parseProjectIconDataUrl(req.body?.dataUrl); - if (!parsed.ok) { - return res.status(400).json({ error: parsed.error }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { projects, project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - const iconPath = projectIconPathForMime(projectId, parsed.mime); - if (!iconPath) { - return res.status(400).json({ error: 'Unsupported icon format' }); - } - - await fsPromises.mkdir(PROJECT_ICONS_DIR_PATH, { recursive: true }); - await fsPromises.writeFile(iconPath, parsed.bytes); - await removeProjectIconFiles(projectId, iconPath); - - const updatedAt = Date.now(); - const nextProjects = projects.map((entry) => ( - entry.id === projectId - ? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } } - : entry - )); - const updatedSettings = await persistSettings({ projects: nextProjects }); - const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; - - return res.json({ project: updatedProject, settings: updatedSettings }); - } catch (error) { - console.warn('Failed to upload project icon:', error); - return res.status(500).json({ error: 'Failed to upload project icon' }); - } - }); - - app.delete('/api/projects/:projectId/icon', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { projects, project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - await removeProjectIconFiles(projectId); - - const nextProjects = projects.map((entry) => ( - entry.id === projectId - ? { ...entry, iconImage: null } - : entry - )); - const updatedSettings = await persistSettings({ projects: nextProjects }); - const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; - - return res.json({ project: updatedProject, settings: updatedSettings }); - } catch (error) { - console.warn('Failed to remove project icon:', error); - return res.status(500).json({ error: 'Failed to remove project icon' }); - } - }); - - app.post('/api/projects/:projectId/icon/discover', async (req, res) => { - const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; - if (!projectId) { - return res.status(400).json({ error: 'projectId is required' }); - } - - try { - const settings = await readSettingsFromDiskMigrated(); - const { projects, project } = findProjectById(settings, projectId); - if (!project) { - return res.status(404).json({ error: 'Project not found' }); - } - - const force = req.body?.force === true; - if (project.iconImage?.source === 'custom' && !force) { - return res.json({ - project, - skipped: true, - reason: 'custom-icon-present', - }); - } - - const faviconCandidates = await searchFilesystemFiles(project.path, { - limit: 200, - query: 'favicon', - includeHidden: true, - respectGitignore: false, - }); - - const filtered = faviconCandidates - .filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path)) - .sort((a, b) => a.path.length - b.path.length); - - const selected = filtered[0]; - if (!selected) { - return res.status(404).json({ error: 'No favicon found in project' }); - } - - const ext = path.extname(selected.path).slice(1).toLowerCase(); - const mime = PROJECT_ICON_EXTENSION_TO_MIME[ext] || null; - if (!mime) { - return res.status(415).json({ error: 'Unsupported favicon format' }); - } - - const bytes = await fsPromises.readFile(selected.path); - if (bytes.length === 0) { - return res.status(400).json({ error: 'Discovered icon is empty' }); - } - if (bytes.length > PROJECT_ICON_MAX_BYTES) { - return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' }); - } - - const iconPath = projectIconPathForMime(projectId, mime); - if (!iconPath) { - return res.status(415).json({ error: 'Unsupported favicon format' }); - } - - await fsPromises.mkdir(PROJECT_ICONS_DIR_PATH, { recursive: true }); - await fsPromises.writeFile(iconPath, bytes); - await removeProjectIconFiles(projectId, iconPath); - - const updatedAt = Date.now(); - const nextProjects = projects.map((entry) => ( - entry.id === projectId - ? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } } - : entry - )); - const updatedSettings = await persistSettings({ projects: nextProjects }); - const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; - - return res.json({ - project: updatedProject, - settings: updatedSettings, - discoveredPath: selected.path, - }); - } catch (error) { - console.warn('Failed to discover project icon:', error); - return res.status(500).json({ error: 'Failed to discover project icon' }); - } - }); - - const { - getAgentSources, - getAgentScope, - getAgentConfig, - createAgent, - updateAgent, - deleteAgent, - getCommandSources, - getCommandScope, - createCommand, - updateCommand, - deleteCommand, - getProviderSources, - removeProviderConfig, - AGENT_SCOPE, - COMMAND_SCOPE, - listMcpConfigs, - getMcpConfig, - createMcpConfig, - updateMcpConfig, - deleteMcpConfig, - } = await import('./lib/opencode/index.js'); - - app.get('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const sources = getAgentSources(agentName, directory); - - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - - res.json({ - name: agentName, - sources: sources, - scope, - isBuiltIn: !sources.md.exists && !sources.json.exists - }); - } catch (error) { - console.error('Failed to get agent sources:', error); - res.status(500).json({ error: 'Failed to get agent configuration metadata' }); - } - }); - - app.get('/api/config/agents/:name/config', async (req, res) => { - try { - const agentName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const configInfo = getAgentConfig(agentName, directory); - res.json(configInfo); - } catch (error) { - console.error('Failed to get agent config:', error); - res.status(500).json({ error: 'Failed to get agent configuration' }); - } - }); - - app.post('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const { scope, ...config } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log('[Server] Creating agent:', agentName); - console.log('[Server] Config received:', JSON.stringify(config, null, 2)); - console.log('[Server] Scope:', scope, 'Working directory:', directory); - - createAgent(agentName, config, directory, scope); - await refreshOpenCodeAfterConfigChange('agent creation', { - agentName - }); - - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to create agent:', error); - res.status(500).json({ error: error.message || 'Failed to create agent' }); - } - }); - - app.patch('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const updates = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log(`[Server] Updating agent: ${agentName}`); - console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); - console.log('[Server] Working directory:', directory); - - updateAgent(agentName, updates, directory); - await refreshOpenCodeAfterConfigChange('agent update'); - - console.log(`[Server] Agent ${agentName} updated successfully`); - - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to update agent:', error); - console.error('[Server] Error stack:', error.stack); - res.status(500).json({ error: error.message || 'Failed to update agent' }); - } - }); - - app.delete('/api/config/agents/:name', async (req, res) => { - try { - const agentName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - deleteAgent(agentName, directory); - await refreshOpenCodeAfterConfigChange('agent deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Agent ${agentName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to delete agent:', error); - res.status(500).json({ error: error.message || 'Failed to delete agent' }); - } - }); - - // ============================================================ - // MCP Config Routes - // ============================================================ - - app.get('/api/config/mcp', async (req, res) => { - try { - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - const configs = listMcpConfigs(directory); - res.json(configs); - } catch (error) { - console.error('[API:GET /api/config/mcp] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to list MCP configs' }); - } - }); - - app.get('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - const config = getMcpConfig(name, directory); - if (!config) { - return res.status(404).json({ error: `MCP server "${name}" not found` }); - } - res.json(config); - } catch (error) { - console.error('[API:GET /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to get MCP config' }); - } - }); - - app.post('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const { scope, ...config } = req.body || {}; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`); - - createMcpConfig(name, config, directory, scope); - await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name }); - - res.json({ - success: true, - requiresReload: true, - message: `MCP server "${name}" created. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[API:POST /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to create MCP server' }); - } - }); - - app.patch('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const updates = req.body; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`); - - updateMcpConfig(name, updates, directory); - await refreshOpenCodeAfterConfigChange('mcp update'); - - res.json({ - success: true, - requiresReload: true, - message: `MCP server "${name}" updated. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[API:PATCH /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to update MCP server' }); - } - }); - - app.delete('/api/config/mcp/:name', async (req, res) => { - try { - const name = req.params.name; - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`); - - deleteMcpConfig(name, directory); - await refreshOpenCodeAfterConfigChange('mcp deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `MCP server "${name}" deleted. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[API:DELETE /api/config/mcp/:name] Failed:', error); - res.status(500).json({ error: error.message || 'Failed to delete MCP server' }); - } - }); - - app.get('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const sources = getCommandSources(commandName, directory); - - const scope = sources.md.exists - ? sources.md.scope - : (sources.json.exists ? sources.json.scope : null); - - res.json({ - name: commandName, - sources: sources, - scope, - isBuiltIn: !sources.md.exists && !sources.json.exists - }); - } catch (error) { - console.error('Failed to get command sources:', error); - res.status(500).json({ error: 'Failed to get command configuration metadata' }); - } - }); - - app.post('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const { scope, ...config } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log('[Server] Creating command:', commandName); - console.log('[Server] Config received:', JSON.stringify(config, null, 2)); - console.log('[Server] Scope:', scope, 'Working directory:', directory); - - createCommand(commandName, config, directory, scope); - await refreshOpenCodeAfterConfigChange('command creation', { - commandName - }); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to create command:', error); - res.status(500).json({ error: error.message || 'Failed to create command' }); - } - }); - - app.patch('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const updates = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log(`[Server] Updating command: ${commandName}`); - console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); - console.log('[Server] Working directory:', directory); - - updateCommand(commandName, updates, directory); - await refreshOpenCodeAfterConfigChange('command update'); - - console.log(`[Server] Command ${commandName} updated successfully`); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to update command:', error); - console.error('[Server] Error stack:', error.stack); - res.status(500).json({ error: error.message || 'Failed to update command' }); - } - }); - - app.delete('/api/config/commands/:name', async (req, res) => { - try { - const commandName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - deleteCommand(commandName, directory); - await refreshOpenCodeAfterConfigChange('command deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to delete command:', error); - res.status(500).json({ error: error.message || 'Failed to delete command' }); - } - }); - - // ============== SKILL ENDPOINTS ============== - - const { - getSkillSources, - discoverSkills, - createSkill, - updateSkill, - deleteSkill, - readSkillSupportingFile, - writeSkillSupportingFile, - deleteSkillSupportingFile, - SKILL_SCOPE, - SKILL_DIR, - } = await import('./lib/opencode/index.js'); - - const findWorktreeRootForSkills = (workingDirectory) => { - if (!workingDirectory) return null; - let current = path.resolve(workingDirectory); - while (true) { - if (fs.existsSync(path.join(current, '.git'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; - } - }; - - const getSkillProjectAncestors = (workingDirectory) => { - if (!workingDirectory) return []; - const result = []; - let current = path.resolve(workingDirectory); - const stop = findWorktreeRootForSkills(workingDirectory) || current; - while (true) { - result.push(current); - if (current === stop) break; - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - return result; - }; - - const isPathInside = (candidatePath, parentPath) => { - if (!candidatePath || !parentPath) return false; - const normalizedCandidate = path.resolve(candidatePath); - const normalizedParent = path.resolve(parentPath); - return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); - }; - - const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => { - const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : ''; - const home = os.homedir(); - const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) - ? 'agents' - : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) - ? 'claude' - : 'opencode'; - - const projectAncestors = getSkillProjectAncestors(workingDirectory); - const isProjectScoped = projectAncestors.some((ancestor) => { - const candidates = [ - path.join(ancestor, '.opencode'), - path.join(ancestor, '.claude', 'skills'), - path.join(ancestor, '.agents', 'skills'), - ]; - return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); - }); - - if (isProjectScoped) { - return { scope: SKILL_SCOPE.PROJECT, source }; - } - - const userRoots = [ - path.join(home, '.config', 'opencode'), - path.join(home, '.opencode'), - path.join(home, '.claude', 'skills'), - path.join(home, '.agents', 'skills'), - process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, - ].filter(Boolean); - - if (userRoots.some((root) => isPathInside(resolvedPath, root))) { - return { scope: SKILL_SCOPE.USER, source }; - } - - return { scope: SKILL_SCOPE.USER, source }; - }; - - const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => { - if (!openCodePort) { - return null; - } - - try { - const url = new URL(buildOpenCodeUrl('/skill', '')); - if (workingDirectory) { - url.searchParams.set('directory', workingDirectory); - } - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(8_000), - }); - - if (!response.ok) { - return null; - } - - const payload = await response.json(); - if (!Array.isArray(payload)) { - return null; - } - - return payload - .map((item) => { - const name = typeof item?.name === 'string' ? item.name.trim() : ''; - const location = typeof item?.location === 'string' ? item.location : ''; - const description = typeof item?.description === 'string' ? item.description : ''; - if (!name || !location) { - return null; - } - const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory); - return { - name, - path: location, - scope: inferred.scope, - source: inferred.source, - description, - }; - }) - .filter(Boolean); - } catch { - return null; - } - }; - - // List all discovered skills - app.get('/api/config/skills', async (req, res) => { - try { - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory); - - // Enrich with full sources info - const enrichedSkills = skills.map(skill => { - const sources = getSkillSources(skill.name, directory, skill); - return { - ...skill, - sources - }; - }); - - res.json({ skills: enrichedSkills }); - } catch (error) { - console.error('Failed to list skills:', error); - res.status(500).json({ error: 'Failed to list skills' }); - } - }); - - // ============== SKILLS CATALOG + INSTALL ENDPOINTS ============== - - const { - getCuratedSkillsSources, - getCacheKey, - getCachedScan, - setCachedScan, - parseSkillRepoSource, - scanSkillsRepository, - installSkillsFromRepository, - scanClawdHubPage, - installSkillsFromClawdHub, - isClawdHubSource, - } = await import('./lib/skills-catalog/index.js'); - const { getProfiles, getProfile } = await import('./lib/git/index.js'); - - const listGitIdentitiesForResponse = () => { - try { - const profiles = getProfiles(); - return profiles.map((p) => ({ id: p.id, name: p.name })); - } catch { - return []; - } - }; - - const resolveGitIdentity = (profileId) => { - if (!profileId) { - return null; - } - try { - const profile = getProfile(profileId); - const sshKey = profile?.sshKey; - if (typeof sshKey === 'string' && sshKey.trim()) { - return { sshKey: sshKey.trim() }; - } - } catch { - // ignore - } - return null; - }; - - app.get('/api/config/skills/catalog', async (req, res) => { - try { - const { error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ error }); - } - - const curatedSources = getCuratedSkillsSources(); - const settings = await readSettingsFromDisk(); - const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; - - const customSources = customSourcesRaw.map((entry) => ({ - id: entry.id, - label: entry.label, - description: entry.source, - source: entry.source, - defaultSubpath: entry.subpath, - gitIdentityId: entry.gitIdentityId, - })); - - const sources = [...curatedSources, ...customSources]; - const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest); - - res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} }); - } catch (error) { - console.error('Failed to load skills catalog:', error); - res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } }); - } - }); - - app.get('/api/config/skills/catalog/source', async (req, res) => { - try { - const { directory, error } = await resolveOptionalProjectDirectory(req); - if (error) { - return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } }); - } - - const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null; - if (!sourceId) { - return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } }); - } - - const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; - const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null; - - const curatedSources = getCuratedSkillsSources(); - const settings = await readSettingsFromDisk(); - const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; - - const customSources = customSourcesRaw.map((entry) => ({ - id: entry.id, - label: entry.label, - description: entry.source, - source: entry.source, - defaultSubpath: entry.subpath, - gitIdentityId: entry.gitIdentityId, - })); - - const sources = [...curatedSources, ...customSources]; - const src = sources.find((entry) => entry.id === sourceId); - - if (!src) { - return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } }); - } - - const discovered = directory - ? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory)) - : []; - const installedByName = new Map(discovered.map((s) => [s.name, s])); - - if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { - const scanned = await scanClawdHubPage({ cursor: cursor || null }); - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - const items = (scanned.items || []).map((item) => { - const installed = installedByName.get(item.skillName); - return { - ...item, - sourceId: src.id, - installed: installed - ? { isInstalled: true, scope: installed.scope, source: installed.source } - : { isInstalled: false }, - }; - }); - - return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null }); - } - - const parsed = parseSkillRepoSource(src.source); - if (!parsed.ok) { - return res.status(400).json({ ok: false, error: parsed.error }); - } - - const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null; - const cacheKey = getCacheKey({ - normalizedRepo: parsed.normalizedRepo, - subpath: effectiveSubpath || '', - identityId: src.gitIdentityId || '', - }); - - let scanResult = !refresh ? getCachedScan(cacheKey) : null; - if (!scanResult) { - const scanned = await scanSkillsRepository({ - source: src.source, - subpath: src.defaultSubpath, - defaultSubpath: src.defaultSubpath, - identity: resolveGitIdentity(src.gitIdentityId), - }); - - if (!scanned.ok) { - return res.status(500).json({ ok: false, error: scanned.error }); - } - - scanResult = scanned; - setCachedScan(cacheKey, scanResult); - } - - const items = (scanResult.items || []).map((item) => { - const installed = installedByName.get(item.skillName); - return { - sourceId: src.id, - ...item, - gitIdentityId: src.gitIdentityId, - installed: installed - ? { isInstalled: true, scope: installed.scope, source: installed.source } - : { isInstalled: false }, - }; - }); - - return res.json({ ok: true, items }); - } catch (error) { - console.error('Failed to load catalog source:', error); - return res.status(500).json({ - ok: false, - error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' }, - }); - } - }); - - app.post('/api/config/skills/scan', async (req, res) => { - try { - const { source, subpath, gitIdentityId } = req.body || {}; - const identity = resolveGitIdentity(gitIdentityId); - - const result = await scanSkillsRepository({ - source, - subpath, - identity, - }); - - if (!result.ok) { - if (result.error?.kind === 'authRequired') { - return res.status(401).json({ - ok: false, - error: { - ...result.error, - identities: listGitIdentitiesForResponse(), - }, - }); - } - - return res.status(400).json({ ok: false, error: result.error }); - } - - res.json({ ok: true, items: result.items }); - } catch (error) { - console.error('Failed to scan skills repository:', error); - res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } }); - } - }); - - app.post('/api/config/skills/install', async (req, res) => { - try { - const { - source, - subpath, - gitIdentityId, - scope, - targetSource, - selections, - conflictPolicy, - conflictDecisions, - } = req.body || {}; - - let workingDirectory = null; - if (scope === 'project') { - const resolved = await resolveProjectDirectory(req); - if (!resolved.directory) { - return res.status(400).json({ - ok: false, - error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' }, - }); - } - workingDirectory = resolved.directory; - } - - // Handle ClawdHub sources (ZIP download based) - if (isClawdHubSource(source)) { - const result = await installSkillsFromClawdHub({ - scope, - targetSource, - workingDirectory, - userSkillDir: SKILL_DIR, - selections, - conflictPolicy, - conflictDecisions, - }); - - if (!result.ok) { - if (result.error?.kind === 'conflicts') { - return res.status(409).json({ ok: false, error: result.error }); - } - return res.status(400).json({ ok: false, error: result.error }); - } - - const installed = result.installed || []; - const skipped = result.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await refreshOpenCodeAfterConfigChange('skills install'); - } - - return res.json({ - ok: true, - installed, - skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, - }); - } - - // Handle GitHub sources (git clone based) - const identity = resolveGitIdentity(gitIdentityId); - - const result = await installSkillsFromRepository({ - source, - subpath, - identity, - scope, - targetSource, - workingDirectory, - userSkillDir: SKILL_DIR, - selections, - conflictPolicy, - conflictDecisions, - }); - - if (!result.ok) { - if (result.error?.kind === 'conflicts') { - return res.status(409).json({ ok: false, error: result.error }); - } - - if (result.error?.kind === 'authRequired') { - return res.status(401).json({ - ok: false, - error: { - ...result.error, - identities: listGitIdentitiesForResponse(), - }, - }); - } - - return res.status(400).json({ ok: false, error: result.error }); - } - - const installed = result.installed || []; - const skipped = result.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await refreshOpenCodeAfterConfigChange('skills install'); - } - - res.json({ - ok: true, - installed, - skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, - }); - } catch (error) { - console.error('Failed to install skills:', error); - res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } }); - } - }); - - // Get single skill sources - app.get('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - - res.json({ - name: skillName, - sources: sources, - scope: sources.md.scope, - source: sources.md.source, - exists: sources.md.exists - }); - } catch (error) { - console.error('Failed to get skill sources:', error); - res.status(500).json({ error: 'Failed to get skill configuration metadata' }); - } - }); - - // Get skill supporting file content - app.get('/api/config/skills/:name/files/*filePath', async (req, res) => { - try { - const skillName = req.params.name; - const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - if (isUnsafeSkillRelativePath(filePath)) { - return res.status(400).json({ error: 'Invalid file path' }); - } - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - if (!sources.md.exists || !sources.md.dir) { - return res.status(404).json({ error: 'Skill not found' }); - } - - const content = readSkillSupportingFile(sources.md.dir, filePath); - if (content === null) { - return res.status(404).json({ error: 'File not found' }); - } - - res.json({ path: filePath, content }); - } catch (error) { - if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to read skill file:', error); - res.status(500).json({ error: 'Failed to read skill file' }); - } - }); - - // Create new skill - app.post('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const { scope, source: skillSource, ...config } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log('[Server] Creating skill:', skillName); - console.log('[Server] Scope:', scope, 'Working directory:', directory); - - createSkill(skillName, { ...config, source: skillSource }, directory, scope); - await refreshOpenCodeAfterConfigChange('skill creation'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} created successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to create skill:', error); - res.status(500).json({ error: error.message || 'Failed to create skill' }); - } - }); - - // Update existing skill - app.patch('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const updates = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - console.log(`[Server] Updating skill: ${skillName}`); - console.log('[Server] Working directory:', directory); - - updateSkill(skillName, updates, directory); - await refreshOpenCodeAfterConfigChange('skill update'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} updated successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to update skill:', error); - res.status(500).json({ error: error.message || 'Failed to update skill' }); - } - }); - - // Update/create supporting file - app.put('/api/config/skills/:name/files/*filePath', async (req, res) => { - try { - const skillName = req.params.name; - const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - if (isUnsafeSkillRelativePath(filePath)) { - return res.status(400).json({ error: 'Invalid file path' }); - } - const { content } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - if (!sources.md.exists || !sources.md.dir) { - return res.status(404).json({ error: 'Skill not found' }); - } - - writeSkillSupportingFile(sources.md.dir, filePath, content || ''); - - res.json({ - success: true, - message: `File ${filePath} saved successfully`, - }); - } catch (error) { - if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to write skill file:', error); - res.status(500).json({ error: error.message || 'Failed to write skill file' }); - } - }); - - // Delete supporting file - app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => { - try { - const skillName = req.params.name; - const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path - if (isUnsafeSkillRelativePath(filePath)) { - return res.status(400).json({ error: 'Invalid file path' }); - } - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) - .find((skill) => skill.name === skillName) || null; - const sources = getSkillSources(skillName, directory, discoveredSkill); - if (!sources.md.exists || !sources.md.dir) { - return res.status(404).json({ error: 'Skill not found' }); - } - - deleteSkillSupportingFile(sources.md.dir, filePath); - - res.json({ - success: true, - message: `File ${filePath} deleted successfully`, - }); - } catch (error) { - if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to delete skill file:', error); - res.status(500).json({ error: error.message || 'Failed to delete skill file' }); - } - }); - - // Delete skill - app.delete('/api/config/skills/:name', async (req, res) => { - try { - const skillName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); - } - - deleteSkill(skillName, directory); - await refreshOpenCodeAfterConfigChange('skill deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} deleted successfully. Reloading interface…`, - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('Failed to delete skill:', error); - res.status(500).json({ error: error.message || 'Failed to delete skill' }); - } - }); - - app.post('/api/config/reload', async (req, res) => { - try { - console.log('[Server] Manual configuration reload requested'); - - await refreshOpenCodeAfterConfigChange('manual configuration reload'); - - res.json({ - success: true, - requiresReload: true, - message: 'Configuration reloaded successfully. Refreshing interface…', - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, - }); - } catch (error) { - console.error('[Server] Failed to reload configuration:', error); - res.status(500).json({ - error: error.message || 'Failed to reload configuration', - success: false - }); - } - }); - - let authLibrary = null; - const getAuthLibrary = async () => { - if (!authLibrary) { - authLibrary = await import('./lib/opencode/auth.js'); - } - return authLibrary; - }; - - let quotaProviders = null; - const getQuotaProviders = async () => { - if (!quotaProviders) { - quotaProviders = await import('./lib/quota/index.js'); - } - return quotaProviders; - }; - - // ================= GitHub OAuth (Device Flow) ================= - - // Note: scopes may be overridden via OPENCHAMBER_GITHUB_SCOPES or settings.json (see lib/github/auth.js). - - let githubLibraries = null; - const getGitHubLibraries = async () => { - if (!githubLibraries) { - githubLibraries = await import('./lib/github/index.js'); - } - return githubLibraries; - }; - - const getGitHubUserSummary = async (octokit) => { - const me = await octokit.rest.users.getAuthenticated(); - - let email = typeof me.data.email === 'string' ? me.data.email : null; - if (!email) { - try { - const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 }); - const list = Array.isArray(emails?.data) ? emails.data : []; - const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string'); - const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string'); - email = primaryVerified?.email || anyVerified?.email || null; - } catch { - // ignore (scope might be missing) - } - } - - return { - login: me.data.login, - id: me.data.id, - avatarUrl: me.data.avatar_url, - name: typeof me.data.name === 'string' ? me.data.name : null, - email, - }; - }; - - const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403; - const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404; - - app.get('/api/github/auth/status', async (_req, res) => { - try { - const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); - const auth = getGitHubAuth(); - const accounts = getGitHubAuthAccounts(); - if (!auth?.accessToken) { - return res.json({ connected: false, accounts }); - } - - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false, accounts }); - } - - let user = null; - try { - user = await getGitHubUserSummary(octokit); - } catch (error) { - if (isGitHubAuthInvalid(error)) { - clearGitHubAuth(); - return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); - } - } - - const fallback = auth.user; - const mergedUser = user || fallback; - - return res.json({ - connected: true, - user: mergedUser, - scope: auth.scope, - accounts, - }); - } catch (error) { - console.error('Failed to get GitHub auth status:', error); - return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' }); - } - }); - - app.post('/api/github/auth/start', async (_req, res) => { - try { - const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries(); - const clientId = getGitHubClientId(); - if (!clientId) { - return res.status(400).json({ - error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', - }); - } - - const scope = getGitHubScopes(); - - const payload = await startDeviceFlow({ - clientId, - scope, - }); - - return res.json({ - deviceCode: payload.device_code, - userCode: payload.user_code, - verificationUri: payload.verification_uri, - verificationUriComplete: payload.verification_uri_complete, - expiresIn: payload.expires_in, - interval: payload.interval, - scope, - }); - } catch (error) { - console.error('Failed to start GitHub device flow:', error); - return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' }); - } - }); - - app.post('/api/github/auth/complete', async (req, res) => { - try { - const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); - const clientId = getGitHubClientId(); - if (!clientId) { - return res.status(400).json({ - error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', - }); - } - - const deviceCode = typeof req.body?.deviceCode === 'string' - ? req.body.deviceCode - : (typeof req.body?.device_code === 'string' ? req.body.device_code : ''); - - if (!deviceCode) { - return res.status(400).json({ error: 'deviceCode is required' }); - } - - const payload = await exchangeDeviceCode({ clientId, deviceCode }); - - if (payload?.error) { - return res.json({ - connected: false, - status: payload.error, - error: payload.error_description || payload.error, - }); - } - - const accessToken = payload?.access_token; - if (!accessToken) { - return res.status(500).json({ error: 'Missing access_token from GitHub' }); - } - - const { Octokit } = await import('@octokit/rest'); - const octokit = new Octokit({ auth: accessToken }); - const user = await getGitHubUserSummary(octokit); - - setGitHubAuth({ - accessToken, - scope: typeof payload.scope === 'string' ? payload.scope : '', - tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer', - user, - }); - - return res.json({ - connected: true, - user, - scope: typeof payload.scope === 'string' ? payload.scope : '', - accounts: getGitHubAuthAccounts(), - }); - } catch (error) { - console.error('Failed to complete GitHub device flow:', error); - return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' }); - } - }); - - app.post('/api/github/auth/activate', async (req, res) => { - try { - const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); - const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : ''; - if (!accountId) { - return res.status(400).json({ error: 'accountId is required' }); - } - const activated = activateGitHubAuth(accountId); - if (!activated) { - return res.status(404).json({ error: 'GitHub account not found' }); - } - - const auth = getGitHubAuth(); - const accounts = getGitHubAuthAccounts(); - if (!auth?.accessToken) { - return res.json({ connected: false, accounts }); - } - - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false, accounts }); - } - - let user = auth.user || null; - try { - user = await getGitHubUserSummary(octokit); - } catch (error) { - if (isGitHubAuthInvalid(error)) { - clearGitHubAuth(); - return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); - } - } - - return res.json({ - connected: true, - user, - scope: auth.scope, - accounts, - }); - } catch (error) { - console.error('Failed to activate GitHub account:', error); - return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' }); - } - }); - - app.delete('/api/github/auth', async (_req, res) => { - try { - const { clearGitHubAuth } = await getGitHubLibraries(); - const removed = clearGitHubAuth(); - return res.json({ success: true, removed }); - } catch (error) { - console.error('Failed to disconnect GitHub:', error); - return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' }); - } - }); - - app.get('/api/github/me', async (_req, res) => { - try { - const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - let user; - try { - user = await getGitHubUserSummary(octokit); - } catch (error) { - if (isGitHubAuthInvalid(error)) { - clearGitHubAuth(); - return res.status(401).json({ error: 'GitHub token expired or revoked' }); - } - throw error; - } - return res.json(user); - } catch (error) { - console.error('Failed to fetch GitHub user:', error); - return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' }); - } - }); - - // ================= GitHub PR APIs ================= - - app.get('/api/github/pr/status', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : ''; - const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin'; - if (!directory || !branch) { - return res.status(400).json({ error: 'directory and branch are required' }); - } - - const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubPrStatus } = await import('./lib/github/pr-status.js'); - const resolvedStatus = await resolveGitHubPrStatus({ - octokit, - directory, - branch, - remoteName: remote, - }); - const searchRepo = resolvedStatus.repo; - const first = resolvedStatus.pr; - if (!searchRepo) { - return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null }); - } - if (!first) { - return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null }); - } - - // Enrich with mergeability fields - const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number }); - const prData = prFull?.data; - if (!prData) { - return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false }); - } - - // Checks summary: prefer check-runs (Actions), fallback to classic statuses. - let checks = null; - const sha = prData.head?.sha; - if (sha) { - try { - const runs = await octokit.rest.checks.listForRef({ - owner: searchRepo.owner, - repo: searchRepo.repo, - ref: sha, - per_page: 100, - }); - const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; - if (checkRuns.length > 0) { - const counts = { success: 0, failure: 0, pending: 0 }; - for (const run of checkRuns) { - const status = run?.status; - const conclusion = run?.conclusion; - if (status === 'queued' || status === 'in_progress') { - counts.pending += 1; - continue; - } - if (!conclusion) { - counts.pending += 1; - continue; - } - if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { - counts.success += 1; - } else { - counts.failure += 1; - } - } - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 - ? 'failure' - : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } - } catch { - // ignore and fall back - } - - if (!checks) { - try { - const combined = await octokit.rest.repos.getCombinedStatusForRef({ - owner: searchRepo.owner, - repo: searchRepo.repo, - ref: sha, - }); - const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; - const counts = { success: 0, failure: 0, pending: 0 }; - statuses.forEach((s) => { - if (s.state === 'success') counts.success += 1; - else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; - else if (s.state === 'pending') counts.pending += 1; - }); - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 - ? 'failure' - : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } catch { - checks = null; - } - } - } - - // Permission check (best-effort) - let canMerge = false; - try { - const auth = getGitHubAuth(); - const username = auth?.user?.login; - if (username) { - const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({ - owner: searchRepo.owner, - repo: searchRepo.repo, - username, - }); - const level = perm?.data?.permission; - canMerge = level === 'admin' || level === 'maintain' || level === 'write'; - } - } catch { - canMerge = false; - } - - const isMerged = Boolean(prData.merged || prData.merged_at); - const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); - - return res.json({ - connected: true, - repo: searchRepo, - branch, - pr: { - number: prData.number, - title: prData.title, - body: prData.body || '', - url: prData.html_url, - state: mergedState, - draft: Boolean(prData.draft), - base: prData.base?.ref, - head: prData.head?.ref, - headSha: prData.head?.sha, - mergeable: prData.mergeable, - mergeableState: prData.mergeable_state, - }, - checks, - canMerge, - defaultBranch: resolvedStatus.defaultBranch ?? null, - resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null, - }); - } catch (error) { - if (error?.status === 401) { - const { clearGitHubAuth } = await getGitHubLibraries(); - clearGitHubAuth(); - return res.json({ connected: false }); - } - if (isGitHubResourceUnavailable(error)) { - return res.json({ - connected: true, - repo: null, - branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '', - pr: null, - checks: null, - canMerge: false, - defaultBranch: null, - resolvedRemoteName: null, - }); - } - console.error('Failed to load GitHub PR status:', error); - return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' }); - } - }); - - app.post('/api/github/pr/create', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; - const head = typeof req.body?.head === 'string' ? req.body.head.trim() : ''; - const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : ''; - const body = typeof req.body?.body === 'string' ? req.body.body : undefined; - const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined; - // remote = target repo (where PR is created, e.g., 'upstream' for forks) - const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin'; - // headRemote = source repo (where head branch lives, e.g., 'origin' for forks) - const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : ''; - if (!directory || !title || !head || !requestedBase) { - return res.status(400).json({ error: 'directory, title, head, base are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory, remote); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - const normalizeBranchRef = (value, remoteNames = new Set()) => { - if (!value) { - return value; - } - let normalized = value.trim(); - if (normalized.startsWith('refs/heads/')) { - normalized = normalized.substring('refs/heads/'.length); - } - if (normalized.startsWith('heads/')) { - normalized = normalized.substring('heads/'.length); - } - if (normalized.startsWith('remotes/')) { - normalized = normalized.substring('remotes/'.length); - } - - const slashIndex = normalized.indexOf('/'); - if (slashIndex > 0) { - const maybeRemote = normalized.slice(0, slashIndex); - if (remoteNames.has(maybeRemote)) { - const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim(); - if (withoutRemotePrefix) { - normalized = withoutRemotePrefix; - } - } - } - - return normalized; - }; - - // Determine the source remote for the head branch - // Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin - let sourceRemote = headRemote; - const { getStatus, getRemotes } = await import('./lib/git/index.js'); - - // If no explicit headRemote, check the branch's tracking info - if (!sourceRemote) { - const status = await getStatus(directory).catch(() => null); - if (status?.tracking) { - // tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main" - const trackingRemote = status.tracking.split('/')[0]; - if (trackingRemote) { - sourceRemote = trackingRemote; - } - } - } - - // Fallback: if targeting non-origin and no tracking info, try 'origin' - if (!sourceRemote && remote !== 'origin') { - sourceRemote = 'origin'; - } - - const remoteNames = new Set([remote]); - const remotes = await getRemotes(directory).catch(() => []); - for (const item of remotes) { - if (item?.name) { - remoteNames.add(item.name); - } - } - if (sourceRemote) { - remoteNames.add(sourceRemote); - } - - const base = normalizeBranchRef(requestedBase, remoteNames); - if (!base) { - return res.status(400).json({ error: 'Invalid base branch name' }); - } - - // For fork workflows: we need to determine the correct head reference - let headRef = head; - - if (sourceRemote && sourceRemote !== remote) { - // The branch is on a different remote than the target - this is a cross-repo PR - const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote); - if (headRepo) { - // Always use owner:branch format for cross-repo PRs - // GitHub API requires this when head is from a different repo/fork - if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) { - headRef = `${headRepo.owner}:${head}`; - } - } - } - - // For cross-repo PRs, verify the branch exists on the head repo first - if (headRef.includes(':')) { - const [headOwner] = headRef.split(':'); - const headRepoName = sourceRemote - ? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo - : repo.repo; - - if (headRepoName) { - try { - await octokit.rest.repos.getBranch({ - owner: headOwner, - repo: headRepoName, - branch: head, - }); - } catch (branchError) { - if (branchError?.status === 404) { - return res.status(400).json({ - error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`, - }); - } - // For other errors, continue - let the PR create attempt handle it - } - } - } - - const created = await octokit.rest.pulls.create({ - owner: repo.owner, - repo: repo.repo, - title, - head: headRef, - base, - ...(typeof body === 'string' ? { body } : {}), - ...(typeof draft === 'boolean' ? { draft } : {}), - }); - - const pr = created?.data; - if (!pr) { - return res.status(500).json({ error: 'Failed to create PR' }); - } - - return res.json({ - number: pr.number, - title: pr.title, - body: pr.body || '', - url: pr.html_url, - state: pr.state === 'closed' ? 'closed' : 'open', - draft: Boolean(pr.draft), - base: pr.base?.ref, - head: pr.head?.ref, - headSha: pr.head?.sha, - mergeable: pr.mergeable, - mergeableState: pr.mergeable_state, - }); - } catch (error) { - console.error('Failed to create GitHub PR:', error); - - // Check for head validation error (common with fork PRs) - const errorMessage = error.message || ''; - const isHeadValidationError = - errorMessage.includes('Validation Failed') && - errorMessage.includes('"field":"head"') && - errorMessage.includes('"code":"invalid"'); - - if (isHeadValidationError) { - return res.status(400).json({ - error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.' - }); - } - - return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' }); - } - }); - - app.post('/api/github/pr/update', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const number = typeof req.body?.number === 'number' ? req.body.number : null; - const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; - const body = typeof req.body?.body === 'string' ? req.body.body : undefined; - if (!directory || !number || !title) { - return res.status(400).json({ error: 'directory, number, title are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - let updated; - try { - updated = await octokit.rest.pulls.update({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - title, - ...(typeof body === 'string' ? { body } : {}), - }); - } catch (error) { - if (error?.status === 401) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - if (error?.status === 403) { - return res.status(403).json({ error: 'Not authorized to edit this PR' }); - } - if (error?.status === 404) { - return res.status(404).json({ error: 'PR not found in this repository' }); - } - if (error?.status === 422) { - const apiMessage = error?.response?.data?.message; - const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0 - ? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code) - : null; - const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload'; - return res.status(422).json({ error: message }); - } - throw error; - } - - const pr = updated?.data; - if (!pr) { - return res.status(500).json({ error: 'Failed to update PR' }); - } - - return res.json({ - number: pr.number, - title: pr.title, - body: pr.body || '', - url: pr.html_url, - state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'), - draft: Boolean(pr.draft), - base: pr.base?.ref, - head: pr.head?.ref, - headSha: pr.head?.sha, - mergeable: pr.mergeable, - mergeableState: pr.mergeable_state, - }); - } catch (error) { - console.error('Failed to update GitHub PR:', error); - return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' }); - } - }); - - app.post('/api/github/pr/merge', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const number = typeof req.body?.number === 'number' ? req.body.number : null; - const method = typeof req.body?.method === 'string' ? req.body.method : 'merge'; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - try { - const result = await octokit.rest.pulls.merge({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - merge_method: method, - }); - return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message }); - } catch (error) { - if (error?.status === 403) { - return res.status(403).json({ error: 'Not authorized to merge this PR' }); - } - if (error?.status === 405 || error?.status === 409) { - return res.json({ merged: false, message: error?.message || 'PR not mergeable' }); - } - throw error; - } - } catch (error) { - console.error('Failed to merge GitHub PR:', error); - return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' }); - } - }); - - app.post('/api/github/pr/ready', async (req, res) => { - try { - const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; - const number = typeof req.body?.number === 'number' ? req.body.number : null; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.status(401).json({ error: 'GitHub not connected' }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); - } - - const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); - const nodeId = pr?.data?.node_id; - if (!nodeId) { - return res.status(500).json({ error: 'Failed to resolve PR node id' }); - } - - if (pr?.data?.draft === false) { - return res.json({ ready: true }); - } - - try { - await octokit.graphql( - `mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`, - { pullRequestId: nodeId } - ); - } catch (error) { - if (error?.status === 403) { - return res.status(403).json({ error: 'Not authorized to mark PR ready' }); - } - throw error; - } - - return res.json({ ready: true }); - } catch (error) { - console.error('Failed to mark PR ready:', error); - return res.status(500).json({ error: error.message || 'Failed to mark PR ready' }); - } - }); - - // ================= GitHub Issue APIs ================= - - app.get('/api/github/issues/list', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; - if (!directory) { - return res.status(400).json({ error: 'directory is required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, issues: [] }); - } - - const list = await octokit.rest.issues.listForRepo({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - per_page: 50, - page: Number.isFinite(page) && page > 0 ? page : 1, - }); - const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; - const hasMore = /rel="next"/.test(link); - const issues = (Array.isArray(list?.data) ? list.data : []) - .filter((item) => !item?.pull_request) - .map((item) => ({ - number: item.number, - title: item.title, - url: item.html_url, - state: item.state === 'closed' ? 'closed' : 'open', - author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null, - labels: Array.isArray(item.labels) - ? item.labels - .map((label) => { - if (typeof label === 'string') return null; - const name = typeof label?.name === 'string' ? label.name : ''; - if (!name) return null; - return { name, color: typeof label?.color === 'string' ? label.color : undefined }; - }) - .filter(Boolean) - : [], - })); - - return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); - } catch (error) { - console.error('Failed to list GitHub issues:', error); - return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' }); - } - }); - - app.get('/api/github/issues/get', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, issue: null }); - } - - const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number }); - const issue = result?.data; - if (!issue || issue.pull_request) { - return res.status(400).json({ error: 'Not a GitHub issue' }); - } - - return res.json({ - connected: true, - repo, - issue: { - number: issue.number, - title: issue.title, - url: issue.html_url, - state: issue.state === 'closed' ? 'closed' : 'open', - body: issue.body || '', - createdAt: issue.created_at, - updatedAt: issue.updated_at, - author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null, - assignees: Array.isArray(issue.assignees) - ? issue.assignees - .map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null)) - .filter(Boolean) - : [], - labels: Array.isArray(issue.labels) - ? issue.labels - .map((label) => { - if (typeof label === 'string') return null; - const name = typeof label?.name === 'string' ? label.name : ''; - if (!name) return null; - return { name, color: typeof label?.color === 'string' ? label.color : undefined }; - }) - .filter(Boolean) - : [], - }, - }); - } catch (error) { - console.error('Failed to fetch GitHub issue:', error); - return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' }); - } - }); - - app.get('/api/github/issues/comments', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, comments: [] }); - } - - const result = await octokit.rest.issues.listComments({ - owner: repo.owner, - repo: repo.repo, - issue_number: number, - per_page: 100, - }); - const comments = (Array.isArray(result?.data) ? result.data : []) - .map((comment) => ({ - id: comment.id, - url: comment.html_url, - body: comment.body || '', - createdAt: comment.created_at, - updatedAt: comment.updated_at, - author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, - })); - - return res.json({ connected: true, repo, comments }); - } catch (error) { - console.error('Failed to fetch GitHub issue comments:', error); - return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' }); - } - }); - - // ================= GitHub Pull Request Context APIs ================= - - app.get('/api/github/pulls/list', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; - if (!directory) { - return res.status(400).json({ error: 'directory is required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, prs: [] }); - } - - const list = await octokit.rest.pulls.list({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - per_page: 50, - page: Number.isFinite(page) && page > 0 ? page : 1, - }); - - const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; - const hasMore = /rel="next"/.test(link); - - const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => { - const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'); - const headRepo = pr.head?.repo - ? { - owner: pr.head.repo.owner?.login, - repo: pr.head.repo.name, - url: pr.head.repo.html_url, - cloneUrl: pr.head.repo.clone_url, - sshUrl: pr.head.repo.ssh_url, - } - : null; - return { - number: pr.number, - title: pr.title, - url: pr.html_url, - state: mergedState, - draft: Boolean(pr.draft), - base: pr.base?.ref, - head: pr.head?.ref, - headSha: pr.head?.sha, - mergeable: pr.mergeable, - mergeableState: pr.mergeable_state, - author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null, - headLabel: pr.head?.label, - headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url - ? headRepo - : null, - }; - }); - - return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); - } catch (error) { - if (error?.status === 401) { - const { clearGitHubAuth } = await getGitHubLibraries(); - clearGitHubAuth(); - return res.json({ connected: false }); - } - console.error('Failed to list GitHub PRs:', error); - return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' }); - } - }); - - app.get('/api/github/pulls/context', async (req, res) => { - try { - const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; - const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; - const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true'; - const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true'; - if (!directory || !number) { - return res.status(400).json({ error: 'directory and number are required' }); - } - - const { getOctokitOrNull } = await getGitHubLibraries(); - const octokit = getOctokitOrNull(); - if (!octokit) { - return res.json({ connected: false }); - } - - const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); - if (!repo) { - return res.json({ connected: true, repo: null, pr: null }); - } - - const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); - const prData = prResp?.data; - if (!prData) { - return res.status(404).json({ error: 'PR not found' }); - } - - const headRepo = prData.head?.repo - ? { - owner: prData.head.repo.owner?.login, - repo: prData.head.repo.name, - url: prData.head.repo.html_url, - cloneUrl: prData.head.repo.clone_url, - sshUrl: prData.head.repo.ssh_url, - } - : null; - - const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); - const pr = { - number: prData.number, - title: prData.title, - url: prData.html_url, - state: mergedState, - draft: Boolean(prData.draft), - base: prData.base?.ref, - head: prData.head?.ref, - headSha: prData.head?.sha, - mergeable: prData.mergeable, - mergeableState: prData.mergeable_state, - author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null, - headLabel: prData.head?.label, - headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null, - body: prData.body || '', - createdAt: prData.created_at, - updatedAt: prData.updated_at, - }; - - const issueCommentsResp = await octokit.rest.issues.listComments({ - owner: repo.owner, - repo: repo.repo, - issue_number: number, - per_page: 100, - }); - const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({ - id: comment.id, - url: comment.html_url, - body: comment.body || '', - createdAt: comment.created_at, - updatedAt: comment.updated_at, - author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, - })); - - const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - per_page: 100, - }); - const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({ - id: comment.id, - url: comment.html_url, - body: comment.body || '', - createdAt: comment.created_at, - updatedAt: comment.updated_at, - path: comment.path, - line: typeof comment.line === 'number' ? comment.line : null, - position: typeof comment.position === 'number' ? comment.position : null, - author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, - })); - - const filesResp = await octokit.rest.pulls.listFiles({ - owner: repo.owner, - repo: repo.repo, - pull_number: number, - per_page: 100, - }); - const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({ - filename: f.filename, - status: f.status, - additions: f.additions, - deletions: f.deletions, - changes: f.changes, - patch: f.patch, - })); - - // checks summary (same logic as status endpoint) - let checks = null; - let checkRunsOut = undefined; - const sha = prData.head?.sha; - if (sha) { - try { - const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 }); - const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; - if (checkRuns.length > 0) { - const parsedJobs = new Map(); - const parsedAnnotations = new Map(); - if (includeCheckDetails) { - // Prefetch actions jobs per runId. - const runIds = new Set(); - const jobIds = new Map(); - for (const run of checkRuns) { - const details = typeof run.details_url === 'string' ? run.details_url : ''; - const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); - if (match) { - const runId = Number(match[1]); - const jobId = match[2] ? Number(match[2]) : null; - if (Number.isFinite(runId) && runId > 0) { - runIds.add(runId); - if (jobId && Number.isFinite(jobId) && jobId > 0) { - jobIds.set(details, { runId, jobId }); - } else { - jobIds.set(details, { runId, jobId: null }); - } - } - } - } - - for (const runId of runIds) { - try { - const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({ - owner: repo.owner, - repo: repo.repo, - run_id: runId, - per_page: 100, - }); - const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : []; - parsedJobs.set(runId, jobs); - } catch { - parsedJobs.set(runId, []); - } - } - - for (const run of checkRuns) { - const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : ''; - const shouldLoadAnnotations = Boolean( - run?.id - && runConclusion - && !['success', 'neutral', 'skipped'].includes(runConclusion) - ); - if (!shouldLoadAnnotations) { - continue; - } - - const checkRunId = Number(run.id); - if (!Number.isFinite(checkRunId) || checkRunId <= 0) { - continue; - } - - const annotations = []; - for (let page = 1; page <= 3; page += 1) { - try { - const annotationsResp = await octokit.rest.checks.listAnnotations({ - owner: repo.owner, - repo: repo.repo, - check_run_id: checkRunId, - per_page: 50, - page, - }); - const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : []; - annotations.push(...chunk); - if (chunk.length < 50) { - break; - } - } catch { - break; - } - } - - if (annotations.length > 0) { - parsedAnnotations.set(checkRunId, annotations); - } - } - } - - checkRunsOut = checkRuns.map((run) => { - const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined; - let job = undefined; - if (includeCheckDetails && detailsUrl) { - const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); - const runId = match ? Number(match[1]) : null; - const jobId = match && match[2] ? Number(match[2]) : null; - if (runId && Number.isFinite(runId)) { - const jobs = parsedJobs.get(runId) || []; - const matched = jobId - ? jobs.find((j) => j.id === jobId) - : null; - const picked = matched || jobs.find((j) => j.name === run.name) || null; - if (picked) { - job = { - runId, - jobId: picked.id, - url: picked.html_url, - name: picked.name, - conclusion: picked.conclusion, - steps: Array.isArray(picked.steps) - ? picked.steps.map((s) => ({ - name: s.name, - status: s.status, - conclusion: s.conclusion, - number: s.number, - startedAt: s.started_at || undefined, - completedAt: s.completed_at || undefined, - })) - : undefined, - }; - } else { - job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl }; - } - } - } - - return { - id: run.id, - name: run.name, - app: run.app - ? { - name: run.app.name || undefined, - slug: run.app.slug || undefined, - } - : undefined, - status: run.status, - conclusion: run.conclusion, - detailsUrl, - output: run.output - ? { - title: run.output.title || undefined, - summary: run.output.summary || undefined, - text: run.output.text || undefined, - } - : undefined, - ...(job ? { job } : {}), - ...(run.id && parsedAnnotations.has(run.id) - ? { - annotations: parsedAnnotations.get(run.id).map((a) => ({ - path: a.path || undefined, - startLine: typeof a.start_line === 'number' ? a.start_line : undefined, - endLine: typeof a.end_line === 'number' ? a.end_line : undefined, - level: a.annotation_level || undefined, - message: a.message || '', - title: a.title || undefined, - rawDetails: a.raw_details || undefined, - })).filter((a) => a.message), - } - : {}), - }; - }); - const counts = { success: 0, failure: 0, pending: 0 }; - for (const run of checkRuns) { - const status = run?.status; - const conclusion = run?.conclusion; - if (status === 'queued' || status === 'in_progress') { - counts.pending += 1; - continue; - } - if (!conclusion) { - counts.pending += 1; - continue; - } - if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { - counts.success += 1; - } else { - counts.failure += 1; - } - } - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } - } catch { - // ignore and fall back - } - if (!checks) { - try { - const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha }); - const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; - const counts = { success: 0, failure: 0, pending: 0 }; - statuses.forEach((s) => { - if (s.state === 'success') counts.success += 1; - else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; - else if (s.state === 'pending') counts.pending += 1; - }); - const total = counts.success + counts.failure + counts.pending; - const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); - checks = { state, total, ...counts }; - } catch { - checks = null; - } - } - } - - let diff = undefined; - if (includeDiff) { - const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { - owner: repo.owner, - repo: repo.repo, - pull_number: number, - headers: { accept: 'application/vnd.github.v3.diff' }, - }); - diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined; - } - - return res.json({ - connected: true, - repo, - pr, - issueComments, - reviewComments, - files, - ...(diff ? { diff } : {}), - checks, - ...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}), - }); - } catch (error) { - if (error?.status === 401) { - const { clearGitHubAuth } = await getGitHubLibraries(); - clearGitHubAuth(); - return res.json({ connected: false }); - } - console.error('Failed to load GitHub PR context:', error); - return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' }); - } - }); - - app.get('/api/provider/:providerId/source', async (req, res) => { - try { - const { providerId } = req.params; - if (!providerId) { - return res.status(400).json({ error: 'Provider ID is required' }); - } - - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requestedDirectory = headerDirectory || queryDirectory || null; - - let directory = null; - const resolved = await resolveProjectDirectory(req); - if (resolved.directory) { - directory = resolved.directory; - } else if (requestedDirectory) { - return res.status(400).json({ error: resolved.error }); - } - - const sources = getProviderSources(providerId, directory); - const { getProviderAuth } = await getAuthLibrary(); - const auth = getProviderAuth(providerId); - sources.sources.auth.exists = Boolean(auth); - - res.json({ - providerId, - sources: sources.sources, - }); - } catch (error) { - console.error('Failed to get provider sources:', error); - res.status(500).json({ error: error.message || 'Failed to get provider sources' }); - } - }); - - app.get('/api/quota/providers', async (_req, res) => { - try { - const { listConfiguredQuotaProviders } = await getQuotaProviders(); - const providers = listConfiguredQuotaProviders(); - res.json({ providers }); - } catch (error) { - console.error('Failed to list quota providers:', error); - res.status(500).json({ error: error.message || 'Failed to list quota providers' }); - } - }); - - app.get('/api/quota/:providerId', async (req, res) => { - try { - const { providerId } = req.params; - if (!providerId) { - return res.status(400).json({ error: 'Provider ID is required' }); - } - const { fetchQuotaForProvider } = await getQuotaProviders(); - const result = await fetchQuotaForProvider(providerId); - res.json(result); - } catch (error) { - console.error('Failed to fetch quota:', error); - res.status(500).json({ error: error.message || 'Failed to fetch quota' }); - } - }); - - app.delete('/api/provider/:providerId/auth', async (req, res) => { - try { - const { providerId } = req.params; - if (!providerId) { - return res.status(400).json({ error: 'Provider ID is required' }); - } - - const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth'; - const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; - const queryDirectory = Array.isArray(req.query?.directory) - ? req.query.directory[0] - : req.query?.directory; - const requestedDirectory = headerDirectory || queryDirectory || null; - let directory = null; - - if (scope === 'project' || requestedDirectory) { - const resolved = await resolveProjectDirectory(req); - if (!resolved.directory) { - return res.status(400).json({ error: resolved.error }); - } - directory = resolved.directory; - } else { - const resolved = await resolveProjectDirectory(req); - if (resolved.directory) { - directory = resolved.directory; - } - } - - let removed = false; - if (scope === 'auth') { - const { removeProviderAuth } = await getAuthLibrary(); - removed = removeProviderAuth(providerId); - } else if (scope === 'user' || scope === 'project' || scope === 'custom') { - removed = removeProviderConfig(providerId, directory, scope); - } else if (scope === 'all') { - const { removeProviderAuth } = await getAuthLibrary(); - const authRemoved = removeProviderAuth(providerId); - const userRemoved = removeProviderConfig(providerId, directory, 'user'); - const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false; - const customRemoved = removeProviderConfig(providerId, directory, 'custom'); - removed = authRemoved || userRemoved || projectRemoved || customRemoved; - } else { - return res.status(400).json({ error: 'Invalid scope' }); - } - - if (removed) { - await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`); - } - - res.json({ - success: true, - removed, - requiresReload: removed, - message: removed ? 'Provider disconnected successfully' : 'Provider was not connected', - reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined, - }); - } catch (error) { - console.error('Failed to disconnect provider:', error); - res.status(500).json({ error: error.message || 'Failed to disconnect provider' }); - } - }); - - let gitLibraries = null; - const getGitLibraries = async () => { - if (!gitLibraries) { - gitLibraries = await import('./lib/git/index.js'); - } - return gitLibraries; - }; - - app.get('/api/git/identities', async (req, res) => { - const { getProfiles } = await getGitLibraries(); - try { - const profiles = getProfiles(); - res.json(profiles); - } catch (error) { - console.error('Failed to list git identity profiles:', error); - res.status(500).json({ error: 'Failed to list git identity profiles' }); - } - }); - - app.post('/api/git/identities', async (req, res) => { - const { createProfile } = await getGitLibraries(); - try { - const profile = createProfile(req.body); - console.log(`Created git identity profile: ${profile.name} (${profile.id})`); - res.json(profile); - } catch (error) { - console.error('Failed to create git identity profile:', error); - res.status(400).json({ error: error.message || 'Failed to create git identity profile' }); - } - }); - - app.put('/api/git/identities/:id', async (req, res) => { - const { updateProfile } = await getGitLibraries(); - try { - const profile = updateProfile(req.params.id, req.body); - console.log(`Updated git identity profile: ${profile.name} (${profile.id})`); - res.json(profile); - } catch (error) { - console.error('Failed to update git identity profile:', error); - res.status(400).json({ error: error.message || 'Failed to update git identity profile' }); - } - }); - - app.delete('/api/git/identities/:id', async (req, res) => { - const { deleteProfile } = await getGitLibraries(); - try { - deleteProfile(req.params.id); - console.log(`Deleted git identity profile: ${req.params.id}`); - res.json({ success: true }); - } catch (error) { - console.error('Failed to delete git identity profile:', error); - res.status(400).json({ error: error.message || 'Failed to delete git identity profile' }); - } - }); - - app.get('/api/git/global-identity', async (req, res) => { - const { getGlobalIdentity } = await getGitLibraries(); - try { - const identity = await getGlobalIdentity(); - res.json(identity); - } catch (error) { - console.error('Failed to get global git identity:', error); - res.status(500).json({ error: 'Failed to get global git identity' }); - } - }); - - app.get('/api/git/discover-credentials', async (req, res) => { - try { - const { discoverGitCredentials } = await import('./lib/git/index.js'); - const credentials = discoverGitCredentials(); - res.json(credentials); - } catch (error) { - console.error('Failed to discover git credentials:', error); - res.status(500).json({ error: 'Failed to discover git credentials' }); - } - }); - - app.get('/api/git/check', async (req, res) => { - const { isGitRepository } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const isRepo = await isGitRepository(directory); - res.json({ isGitRepository: isRepo }); - } catch (error) { - console.error('Failed to check git repository:', error); - res.status(500).json({ error: 'Failed to check git repository' }); - } - }); - - app.get('/api/git/remote-url', async (req, res) => { - const { getRemoteUrl } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - const remote = req.query.remote || 'origin'; - - const url = await getRemoteUrl(directory, remote); - res.json({ url }); - } catch (error) { - console.error('Failed to get remote url:', error); - res.status(500).json({ error: 'Failed to get remote url' }); - } - }); - - app.get('/api/git/current-identity', async (req, res) => { - const { getCurrentIdentity } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const identity = await getCurrentIdentity(directory); - res.json(identity); - } catch (error) { - console.error('Failed to get current git identity:', error); - res.status(500).json({ error: 'Failed to get current git identity' }); - } - }); - - app.get('/api/git/has-local-identity', async (req, res) => { - const { hasLocalIdentity } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const hasLocal = await hasLocalIdentity(directory); - res.json({ hasLocalIdentity: hasLocal }); - } catch (error) { - console.error('Failed to check local git identity:', error); - res.status(500).json({ error: 'Failed to check local git identity' }); - } - }); - - app.post('/api/git/set-identity', async (req, res) => { - const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { profileId } = req.body; - if (!profileId) { - return res.status(400).json({ error: 'profileId is required' }); - } - - let profile = null; - - if (profileId === 'global') { - const globalIdentity = await getGlobalIdentity(); - if (!globalIdentity?.userName || !globalIdentity?.userEmail) { - return res.status(404).json({ error: 'Global identity is not configured' }); - } - profile = { - id: 'global', - name: 'Global Identity', - userName: globalIdentity.userName, - userEmail: globalIdentity.userEmail, - sshKey: globalIdentity.sshCommand - ? globalIdentity.sshCommand.replace('ssh -i ', '') - : null, - }; - } else { - profile = getProfile(profileId); - if (!profile) { - return res.status(404).json({ error: 'Profile not found' }); - } - } - - await setLocalIdentity(directory, profile); - res.json({ success: true, profile }); - } catch (error) { - console.error('Failed to set git identity:', error); - res.status(500).json({ error: error.message || 'Failed to set git identity' }); - } - }); - - app.get('/api/git/status', async (req, res) => { - const { getStatus, isGitRepository } = await getGitLibraries(); - - const extractGitErrorText = (error) => { - const message = typeof error?.message === 'string' ? error.message : ''; - const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; - const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; - return [message, stderr, stdout] - .map((value) => String(value || '').trim()) - .filter(Boolean) - .join('\n'); - }; - - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const isRepo = await isGitRepository(directory); - if (!isRepo) { - return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); - } - - const status = await getStatus(directory); - res.json(status); - } catch (error) { - const errorText = extractGitErrorText(error); - if (/not a git repository/i.test(errorText)) { - return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); - } - console.error('Failed to get git status:', error); - res.status(500).json({ error: error.message || 'Failed to get git status' }); - } - }); - - app.get('/api/git/diff', async (req, res) => { - const { getDiff } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const path = req.query.path; - if (!path || typeof path !== 'string') { - return res.status(400).json({ error: 'path parameter is required' }); - } - - const staged = req.query.staged === 'true'; - const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; - - const diff = await getDiff(directory, { - path, - staged, - contextLines: Number.isFinite(context) ? context : 3, - }); - - res.json({ diff }); - } catch (error) { - console.error('Failed to get git diff:', error); - res.status(500).json({ error: error.message || 'Failed to get git diff' }); - } - }); - - app.get('/api/git/file-diff', async (req, res) => { - const { getFileDiff } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const pathParam = req.query.path; - if (!pathParam || typeof pathParam !== 'string') { - return res.status(400).json({ error: 'path parameter is required' }); - } - - const staged = req.query.staged === 'true'; - - const result = await getFileDiff(directory, { - path: pathParam, - staged, - }); - - res.json({ - original: result.original, - modified: result.modified, - path: result.path, - isBinary: Boolean(result.isBinary), - }); - } catch (error) { - console.error('Failed to get git file diff:', error); - res.status(500).json({ error: error.message || 'Failed to get git file diff' }); - } - }); - - app.post('/api/git/revert', async (req, res) => { - const { revertFile } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { path } = req.body || {}; - if (!path || typeof path !== 'string') { - return res.status(400).json({ error: 'path parameter is required' }); - } - - await revertFile(directory, path); - res.json({ success: true }); - } catch (error) { - console.error('Failed to revert git file:', error); - res.status(500).json({ error: error.message || 'Failed to revert git file' }); - } - }); - - app.post('/api/git/pull', async (req, res) => { - const { pull } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await pull(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to pull:', error); - res.status(500).json({ error: error.message || 'Failed to pull from remote' }); - } - }); - - app.post('/api/git/push', async (req, res) => { - const { push } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await push(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to push:', error); - res.status(500).json({ error: error.message || 'Failed to push to remote' }); - } - }); - - app.post('/api/git/fetch', async (req, res) => { - const { fetch: gitFetch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await gitFetch(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to fetch:', error); - res.status(500).json({ error: error.message || 'Failed to fetch from remote' }); - } - }); - - app.get('/api/git/remotes', async (req, res) => { - const { getRemotes } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const remotes = await getRemotes(directory); - res.json(remotes); - } catch (error) { - console.error('Failed to get remotes:', error); - res.status(500).json({ error: error.message || 'Failed to get remotes' }); - } - }); - - app.delete('/api/git/remotes', async (req, res) => { - const { removeRemote } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const remote = String(req.body?.remote || '').trim(); - if (!remote) { - return res.status(400).json({ error: 'remote is required' }); - } - - const result = await removeRemote(directory, { remote }); - res.json(result); - } catch (error) { - console.error('Failed to remove remote:', error); - res.status(500).json({ error: error.message || 'Failed to remove remote' }); - } - }); - - app.post('/api/git/rebase', async (req, res) => { - const { rebase } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await rebase(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to rebase:', error); - res.status(500).json({ error: error.message || 'Failed to rebase' }); - } - }); - - app.post('/api/git/rebase/abort', async (req, res) => { - const { abortRebase } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await abortRebase(directory); - res.json(result); - } catch (error) { - console.error('Failed to abort rebase:', error); - res.status(500).json({ error: error.message || 'Failed to abort rebase' }); - } - }); - - app.post('/api/git/merge', async (req, res) => { - const { merge } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await merge(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to merge:', error); - res.status(500).json({ error: error.message || 'Failed to merge' }); - } - }); - - app.post('/api/git/merge/abort', async (req, res) => { - const { abortMerge } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await abortMerge(directory); - res.json(result); - } catch (error) { - console.error('Failed to abort merge:', error); - res.status(500).json({ error: error.message || 'Failed to abort merge' }); - } - }); - - app.post('/api/git/rebase/continue', async (req, res) => { - const { continueRebase } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await continueRebase(directory); - res.json(result); - } catch (error) { - console.error('Failed to continue rebase:', error); - res.status(500).json({ error: error.message || 'Failed to continue rebase' }); - } - }); - - app.post('/api/git/merge/continue', async (req, res) => { - const { continueMerge } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await continueMerge(directory); - res.json(result); - } catch (error) { - console.error('Failed to continue merge:', error); - res.status(500).json({ error: error.message || 'Failed to continue merge' }); - } - }); - - app.get('/api/git/conflict-details', async (req, res) => { - const { getConflictDetails } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await getConflictDetails(directory); - res.json(result); - } catch (error) { - console.error('Failed to get conflict details:', error); - res.status(500).json({ error: error.message || 'Failed to get conflict details' }); - } - }); - - app.post('/api/git/stash', async (req, res) => { - const { stash } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await stash(directory, req.body); - res.json(result); - } catch (error) { - console.error('Failed to stash:', error); - res.status(500).json({ error: error.message || 'Failed to stash' }); - } - }); - - app.post('/api/git/stash/pop', async (req, res) => { - const { stashPop } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await stashPop(directory); - res.json(result); - } catch (error) { - console.error('Failed to pop stash:', error); - res.status(500).json({ error: error.message || 'Failed to pop stash' }); - } - }); - - app.post('/api/git/commit', async (req, res) => { - const { commit } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { message, addAll, files } = req.body; - if (!message) { - return res.status(400).json({ error: 'message is required' }); - } - - const result = await commit(directory, message, { - addAll, - files, - }); - res.json(result); - } catch (error) { - console.error('Failed to commit:', error); - res.status(500).json({ error: error.message || 'Failed to create commit' }); - } - }); - - app.get('/api/git/branches', async (req, res) => { - const { getBranches } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const branches = await getBranches(directory); - res.json(branches); - } catch (error) { - console.error('Failed to get branches:', error); - res.status(500).json({ error: error.message || 'Failed to get branches' }); - } - }); - - app.post('/api/git/branches', async (req, res) => { - const { createBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { name, startPoint } = req.body; - if (!name) { - return res.status(400).json({ error: 'name is required' }); - } - - const result = await createBranch(directory, name, { startPoint }); - res.json(result); - } catch (error) { - console.error('Failed to create branch:', error); - res.status(500).json({ error: error.message || 'Failed to create branch' }); - } - }); - - app.delete('/api/git/branches', async (req, res) => { - const { deleteBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { branch, force } = req.body; - if (!branch) { - return res.status(400).json({ error: 'branch is required' }); - } - - const result = await deleteBranch(directory, branch, { force }); - res.json(result); - } catch (error) { - console.error('Failed to delete branch:', error); - res.status(500).json({ error: error.message || 'Failed to delete branch' }); - } - }); - - - app.put('/api/git/branches/rename', async (req, res) => { - const { renameBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { oldName, newName } = req.body; - if (!oldName) { - return res.status(400).json({ error: 'oldName is required' }); - } - if (!newName) { - return res.status(400).json({ error: 'newName is required' }); - } - - const result = await renameBranch(directory, oldName, newName); - res.json(result); - } catch (error) { - console.error('Failed to rename branch:', error); - res.status(500).json({ error: error.message || 'Failed to rename branch' }); - } - }); - app.delete('/api/git/remote-branches', async (req, res) => { - const { deleteRemoteBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { branch, remote } = req.body; - if (!branch) { - return res.status(400).json({ error: 'branch is required' }); - } - - const result = await deleteRemoteBranch(directory, { branch, remote }); - res.json(result); - } catch (error) { - console.error('Failed to delete remote branch:', error); - res.status(500).json({ error: error.message || 'Failed to delete remote branch' }); - } - }); - - app.post('/api/git/checkout', async (req, res) => { - const { checkoutBranch } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { branch } = req.body; - if (!branch) { - return res.status(400).json({ error: 'branch is required' }); - } - - const result = await checkoutBranch(directory, branch); - res.json(result); - } catch (error) { - console.error('Failed to checkout branch:', error); - res.status(500).json({ error: error.message || 'Failed to checkout branch' }); - } - }); - - app.get('/api/git/worktrees', async (req, res) => { - const { getWorktrees } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const worktrees = await getWorktrees(directory); - res.json(worktrees); - } catch (error) { - // Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries) - // when the directory isn't a git repo or uses shell shorthand like "~/". - console.warn('Failed to get worktrees, returning empty list:', error?.message || error); - res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable'); - res.json([]); - } - }); - - app.post('/api/git/worktrees/validate', async (req, res) => { - const { validateWorktreeCreate } = await getGitLibraries(); - if (typeof validateWorktreeCreate !== 'function') { - return res.status(501).json({ error: 'Worktree validation is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const result = await validateWorktreeCreate(directory, req.body || {}); - res.json(result); - } catch (error) { - console.error('Failed to validate worktree creation:', error); - res.status(500).json({ error: error.message || 'Failed to validate worktree creation' }); - } - }); - - app.post('/api/git/worktrees', async (req, res) => { - const { createWorktree } = await getGitLibraries(); - if (typeof createWorktree !== 'function') { - return res.status(501).json({ error: 'Worktree creation is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const created = await createWorktree(directory, req.body || {}); - res.json(created); - } catch (error) { - console.error('Failed to create worktree:', error); - res.status(500).json({ error: error.message || 'Failed to create worktree' }); - } - }); - - app.post('/api/git/worktrees/preview', async (req, res) => { - const { previewWorktreeCreate } = await getGitLibraries(); - if (typeof previewWorktreeCreate !== 'function') { - return res.status(501).json({ error: 'Worktree preview is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const preview = await previewWorktreeCreate(directory, req.body || {}); - res.json(preview); - } catch (error) { - console.error('Failed to preview worktree:', error); - res.status(500).json({ error: error.message || 'Failed to preview worktree' }); - } - }); - - app.get('/api/git/worktrees/bootstrap-status', async (req, res) => { - const { getWorktreeBootstrapStatus } = await getGitLibraries(); - if (typeof getWorktreeBootstrapStatus !== 'function') { - return res.status(501).json({ error: 'Worktree bootstrap status is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const status = await getWorktreeBootstrapStatus(directory); - res.json(status); - } catch (error) { - console.error('Failed to get worktree bootstrap status:', error); - res.status(500).json({ error: error.message || 'Failed to get worktree bootstrap status' }); - } - }); - - app.delete('/api/git/worktrees', async (req, res) => { - const { removeWorktree } = await getGitLibraries(); - if (typeof removeWorktree !== 'function') { - return res.status(501).json({ error: 'Worktree removal is not available' }); - } - - try { - const directory = req.query.directory; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : ''; - if (!worktreeDirectory) { - return res.status(400).json({ error: 'worktree directory is required' }); - } - - const result = await removeWorktree(directory, { - directory: worktreeDirectory, - deleteLocalBranch: req.body?.deleteLocalBranch === true, - }); - res.json({ success: Boolean(result) }); - } catch (error) { - console.error('Failed to remove worktree:', error); - res.status(500).json({ error: error.message || 'Failed to remove worktree' }); - } - }); - - app.get('/api/git/worktree-type', async (req, res) => { - const { isLinkedWorktree } = await getGitLibraries(); - try { - const { directory } = req.query; - if (!directory || typeof directory !== 'string') { - return res.status(400).json({ error: 'directory parameter is required' }); - } - const linked = await isLinkedWorktree(directory); - res.json({ linked }); - } catch (error) { - console.error('Failed to determine worktree type:', error); - res.status(500).json({ error: error.message || 'Failed to determine worktree type' }); - } - }); - - app.get('/api/git/log', async (req, res) => { - const { getLog } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { maxCount, from, to, file } = req.query; - const log = await getLog(directory, { - maxCount: maxCount ? parseInt(maxCount) : undefined, - from, - to, - file - }); - res.json(log); - } catch (error) { - console.error('Failed to get log:', error); - res.status(500).json({ error: error.message || 'Failed to get commit log' }); - } - }); - - app.get('/api/git/commit-files', async (req, res) => { - const { getCommitFiles } = await getGitLibraries(); - try { - const { directory, hash } = req.query; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - if (!hash) { - return res.status(400).json({ error: 'hash parameter is required' }); - } - - const result = await getCommitFiles(directory, hash); - res.json(result); - } catch (error) { - console.error('Failed to get commit files:', error); - res.status(500).json({ error: error.message || 'Failed to get commit files' }); - } - }); - - app.get('/api/fs/home', (req, res) => { - try { - const home = os.homedir(); - if (!home || typeof home !== 'string' || home.length === 0) { - return res.status(500).json({ error: 'Failed to resolve home directory' }); - } - res.json({ home }); - } catch (error) { - console.error('Failed to resolve home directory:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' }); - } - }); - - app.post('/api/fs/mkdir', async (req, res) => { - try { - const { path: dirPath, allowOutsideWorkspace } = req.body ?? {}; - - if (typeof dirPath !== 'string' || !dirPath.trim()) { - return res.status(400).json({ error: 'Path is required' }); - } - - let resolvedPath = ''; - - if (allowOutsideWorkspace) { - resolvedPath = path.resolve(normalizeDirectoryPath(dirPath)); - } else { - const resolved = await resolveWorkspacePathFromContext(req, dirPath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - resolvedPath = resolved.resolved; - } - - await fsPromises.mkdir(resolvedPath, { recursive: true }); - - res.json({ success: true, path: resolvedPath }); - } catch (error) { - console.error('Failed to create directory:', error); - res.status(500).json({ error: error.message || 'Failed to create directory' }); - } - }); - - // Read file contents - app.get('/api/fs/read', async (req, res) => { - const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; - if (!filePath) { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, filePath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - const [canonicalPath, canonicalBase] = await Promise.all([ - fsPromises.realpath(resolved.resolved), - fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), - ]); - - if (!isPathWithinRoot(canonicalPath, canonicalBase)) { - return res.status(403).json({ error: 'Access to file denied' }); - } - - const stats = await fsPromises.stat(canonicalPath); - if (!stats.isFile()) { - return res.status(400).json({ error: 'Specified path is not a file' }); - } - - const content = await fsPromises.readFile(canonicalPath, 'utf8'); - res.type('text/plain').send(content); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'File not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to read file:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); - } - }); - - // Read file as raw bytes (images, etc.) - app.get('/api/fs/raw', async (req, res) => { - const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; - if (!filePath) { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, filePath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - const [canonicalPath, canonicalBase] = await Promise.all([ - fsPromises.realpath(resolved.resolved), - fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), - ]); - - if (!isPathWithinRoot(canonicalPath, canonicalBase)) { - return res.status(403).json({ error: 'Access to file denied' }); - } - - const stats = await fsPromises.stat(canonicalPath); - if (!stats.isFile()) { - return res.status(400).json({ error: 'Specified path is not a file' }); - } - - const ext = path.extname(canonicalPath).toLowerCase(); - const mimeMap = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.ico': 'image/x-icon', - '.bmp': 'image/bmp', - '.avif': 'image/avif', - }; - const mimeType = mimeMap[ext] || 'application/octet-stream'; - - const content = await fsPromises.readFile(canonicalPath); - res.setHeader('Cache-Control', 'no-store'); - res.type(mimeType).send(content); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'File not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access to file denied' }); - } - console.error('Failed to read raw file:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); - } - }); - - // Write file contents - app.post('/api/fs/write', async (req, res) => { - const { path: filePath, content } = req.body || {}; - if (!filePath || typeof filePath !== 'string') { - return res.status(400).json({ error: 'Path is required' }); - } - if (typeof content !== 'string') { - return res.status(400).json({ error: 'Content is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, filePath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - // Ensure parent directory exists - await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true }); - await fsPromises.writeFile(resolved.resolved, content, 'utf8'); - res.json({ success: true, path: resolved.resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access denied' }); - } - console.error('Failed to write file:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to write file' }); - } - }); - - // Delete file or directory - app.post('/api/fs/delete', async (req, res) => { - const { path: targetPath } = req.body || {}; - if (!targetPath || typeof targetPath !== 'string') { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = await resolveWorkspacePathFromContext(req, targetPath); - if (!resolved.ok) { - return res.status(400).json({ error: resolved.error }); - } - - await fsPromises.rm(resolved.resolved, { recursive: true, force: true }); - - res.json({ success: true, path: resolved.resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'File or directory not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access denied' }); - } - console.error('Failed to delete path:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to delete path' }); - } - }); - - // Rename/Move file or directory - app.post('/api/fs/rename', async (req, res) => { - const { oldPath, newPath } = req.body || {}; - if (!oldPath || typeof oldPath !== 'string') { - return res.status(400).json({ error: 'oldPath is required' }); - } - if (!newPath || typeof newPath !== 'string') { - return res.status(400).json({ error: 'newPath is required' }); - } - - try { - const resolvedOld = await resolveWorkspacePathFromContext(req, oldPath); - if (!resolvedOld.ok) { - return res.status(400).json({ error: resolvedOld.error }); - } - const resolvedNew = await resolveWorkspacePathFromContext(req, newPath); - if (!resolvedNew.ok) { - return res.status(400).json({ error: resolvedNew.error }); - } - - if (resolvedOld.base !== resolvedNew.base) { - return res.status(400).json({ error: 'Source and destination must share the same workspace root' }); - } - - await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved); - - res.json({ success: true, path: resolvedNew.resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'Source path not found' }); - } - if (err && typeof err === 'object' && err.code === 'EACCES') { - return res.status(403).json({ error: 'Access denied' }); - } - console.error('Failed to rename path:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to rename path' }); - } - }); - - // Reveal a file or folder in the system file manager (Finder on macOS, Explorer on Windows, etc.) - app.post('/api/fs/reveal', async (req, res) => { - const { path: targetPath } = req.body || {}; - if (!targetPath || typeof targetPath !== 'string') { - return res.status(400).json({ error: 'Path is required' }); - } - - try { - const resolved = path.resolve(targetPath.trim()); - - // Verify path exists - await fsPromises.access(resolved); - - const platform = process.platform; - if (platform === 'darwin') { - // macOS: open -R selects the file in Finder; open opens a folder - const stat = await fsPromises.stat(resolved); - if (stat.isDirectory()) { - spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } else { - spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } - } else if (platform === 'win32') { - // Windows: explorer /select, highlights the file - spawn('explorer', ['/select,', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } else { - // Linux: xdg-open opens the parent directory - const stat = await fsPromises.stat(resolved); - const dir = stat.isDirectory() ? resolved : path.dirname(resolved); - spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); - } - - res.json({ success: true, path: resolved }); - } catch (error) { - const err = error; - if (err && typeof err === 'object' && err.code === 'ENOENT') { - return res.status(404).json({ error: 'Path not found' }); - } - console.error('Failed to reveal path:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' }); - } - }); - - // Execute shell commands in a directory (for worktree setup) - // NOTE: This route supports background execution to avoid tying up browser connections. - const execJobs = new Map(); - const EXEC_JOB_TTL_MS = 30 * 60 * 1000; - const COMMAND_TIMEOUT_MS = (() => { - const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS); - if (Number.isFinite(raw) && raw > 0) return raw; - // `bun install` (common worktree setup cmd) often takes >60s. - return 5 * 60 * 1000; - })(); - - const pruneExecJobs = () => { - const now = Date.now(); - for (const [jobId, job] of execJobs.entries()) { - if (!job || typeof job !== 'object') { - execJobs.delete(jobId); - continue; - } - const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0; - if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) { - execJobs.delete(jobId); - } - } - }; - - const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => { - return new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - let timedOut = false; - - const envPath = buildAugmentedPath(); - const execEnv = { ...process.env, PATH: envPath }; - - const child = spawn(shell, [shellFlag, command], { - cwd: resolvedCwd, - env: execEnv, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const timeout = setTimeout(() => { - timedOut = true; - try { - child.kill('SIGKILL'); - } catch { - // ignore - } - }, COMMAND_TIMEOUT_MS); - - child.stdout?.on('data', (chunk) => { - stdout += chunk.toString(); - }); - - child.stderr?.on('data', (chunk) => { - stderr += chunk.toString(); - }); - - child.on('error', (error) => { - clearTimeout(timeout); - resolve({ - command, - success: false, - exitCode: undefined, - stdout: stdout.trim(), - stderr: stderr.trim(), - error: (error && error.message) || 'Command execution failed', - }); - }); - - child.on('close', (code, signal) => { - clearTimeout(timeout); - const exitCode = typeof code === 'number' ? code : undefined; - const base = { - command, - success: exitCode === 0 && !timedOut, - exitCode, - stdout: stdout.trim(), - stderr: stderr.trim(), - }; - - if (timedOut) { - resolve({ - ...base, - success: false, - error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''), - }); - return; - } - - resolve(base); - }); - }); - }; - - const runExecJob = async (job) => { - job.status = 'running'; - job.updatedAt = Date.now(); - - const results = []; - - for (const command of job.commands) { - if (typeof command !== 'string' || !command.trim()) { - results.push({ command, success: false, error: 'Invalid command' }); - continue; - } - - try { - const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd); - results.push(result); - } catch (error) { - results.push({ - command, - success: false, - error: (error && error.message) || 'Command execution failed', - }); - } - - job.results = results; - job.updatedAt = Date.now(); - } - - job.results = results; - job.success = results.every((r) => r.success); - job.status = 'done'; - job.finishedAt = Date.now(); - job.updatedAt = Date.now(); - }; - - app.post('/api/fs/exec', async (req, res) => { - const { commands, cwd, background } = req.body || {}; - if (!Array.isArray(commands) || commands.length === 0) { - return res.status(400).json({ error: 'Commands array is required' }); - } - if (!cwd || typeof cwd !== 'string') { - return res.status(400).json({ error: 'Working directory (cwd) is required' }); - } - - pruneExecJobs(); - - try { - const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd)); - const stats = await fsPromises.stat(resolvedCwd); - if (!stats.isDirectory()) { - return res.status(400).json({ error: 'Specified cwd is not a directory' }); - } - - const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); - const shellFlag = process.platform === 'win32' ? '/c' : '-c'; - - const jobId = crypto.randomUUID(); - const job = { - jobId, - status: 'queued', - success: null, - commands, - resolvedCwd, - shell, - shellFlag, - results: [], - startedAt: Date.now(), - finishedAt: null, - updatedAt: Date.now(), - }; - - execJobs.set(jobId, job); - - const isBackground = background === true; - if (isBackground) { - void runExecJob(job).catch((error) => { - job.status = 'done'; - job.success = false; - job.results = Array.isArray(job.results) ? job.results : []; - job.results.push({ - command: '', - success: false, - error: (error && error.message) || 'Command execution failed', - }); - job.finishedAt = Date.now(); - job.updatedAt = Date.now(); - }); - - return res.status(202).json({ - jobId, - status: 'running', - }); - } - - await runExecJob(job); - res.json({ - jobId, - status: job.status, - success: job.success === true, - results: job.results, - }); - } catch (error) { - console.error('Failed to execute commands:', error); - res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' }); - } - }); - - app.get('/api/fs/exec/:jobId', (req, res) => { - const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : ''; - if (!jobId) { - return res.status(400).json({ error: 'Job id is required' }); - } - - pruneExecJobs(); - - const job = execJobs.get(jobId); - if (!job) { - return res.status(404).json({ error: 'Job not found' }); - } - - job.updatedAt = Date.now(); - - return res.json({ - jobId: job.jobId, - status: job.status, - success: job.success === true, - results: Array.isArray(job.results) ? job.results : [], - }); - }); - - app.post('/api/opencode/directory', async (req, res) => { - try { - const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : ''; - if (!requestedPath) { - return res.status(400).json({ error: 'Path is required' }); - } - - const validated = await validateDirectoryPath(requestedPath); - if (!validated.ok) { - return res.status(400).json({ error: validated.error }); - } - - const resolvedPath = validated.directory; - const currentSettings = await readSettingsFromDisk(); - const existingProjects = sanitizeProjects(currentSettings.projects) || []; - const existing = existingProjects.find((project) => project.path === resolvedPath) || null; - - const nextProjects = existing - ? existingProjects - : [ - ...existingProjects, - { - id: crypto.randomUUID(), - path: resolvedPath, - addedAt: Date.now(), - lastOpenedAt: Date.now(), - }, - ]; - - const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id; - - const updated = await persistSettings({ - projects: nextProjects, - activeProjectId, - lastDirectory: resolvedPath, - }); - - res.json({ - success: true, - restarted: false, - path: resolvedPath, - settings: updated, - }); - } catch (error) { - console.error('Failed to update OpenCode working directory:', error); - res.status(500).json({ error: error.message || 'Failed to update working directory' }); - } - }); - - app.get('/api/fs/list', async (req, res) => { - const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0 - ? req.query.path.trim() - : os.homedir(); - const respectGitignore = req.query.respectGitignore === 'true'; - let resolvedPath = ''; - - const isPlansDirectory = (value) => { - if (!value || typeof value !== 'string') return false; - const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); - return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans'); - }; - - try { - resolvedPath = path.resolve(normalizeDirectoryPath(rawPath)); - - const stats = await fsPromises.stat(resolvedPath); - if (!stats.isDirectory()) { - return res.status(400).json({ error: 'Specified path is not a directory' }); - } - - const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true }); - - // Get gitignored paths if requested - let ignoredPaths = new Set(); - if (respectGitignore) { - try { - // Get all entry paths to check (relative to resolvedPath for git check-ignore) - const pathsToCheck = dirents.map((d) => d.name); - - if (pathsToCheck.length > 0) { - try { - // Use git check-ignore with paths as arguments - // Pass paths directly as arguments (works for reasonable directory sizes) - const result = await new Promise((resolve) => { - const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { - cwd: resolvedPath, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - child.stdout.on('data', (data) => { stdout += data.toString(); }); - child.on('close', () => resolve(stdout)); - child.on('error', () => resolve('')); - }); - - result.split('\n').filter(Boolean).forEach((name) => { - const fullPath = path.join(resolvedPath, name.trim()); - ignoredPaths.add(fullPath); - }); - } catch { - // git check-ignore fails if not a git repo, continue without filtering - } - } - } catch { - // If git is not available, continue without gitignore filtering - } - } - - const entries = await Promise.all( - dirents.map(async (dirent) => { - const entryPath = path.join(resolvedPath, dirent.name); - - // Skip gitignored entries - if (respectGitignore && ignoredPaths.has(entryPath)) { - return null; - } - - let isDirectory = dirent.isDirectory(); - const isSymbolicLink = dirent.isSymbolicLink(); - - if (!isDirectory && isSymbolicLink) { - try { - const linkStats = await fsPromises.stat(entryPath); - isDirectory = linkStats.isDirectory(); - } catch { - isDirectory = false; - } - } - - return { - name: dirent.name, - path: entryPath, - isDirectory, - isFile: dirent.isFile(), - isSymbolicLink - }; - }) - ); - - res.json({ - path: resolvedPath, - entries: entries.filter(Boolean) - }); - } catch (error) { - const err = error; - const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined; - const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath)); - if (!isPlansPath) { - console.error('Failed to list directory:', error); - } - if (code === 'ENOENT') { - // Return empty result for plans directory (expected to not exist until first use) - if (isPlansPath) { - return res.json({ path: resolvedPath || rawPath, entries: [] }); - } - return res.status(404).json({ error: 'Directory not found' }); - } - if (code === 'EACCES') { - return res.status(403).json({ error: 'Access to directory denied' }); - } - res.status(500).json({ error: (error && error.message) || 'Failed to list directory' }); - } - }); - - let ptyProviderPromise = null; - const getPtyProvider = async () => { - if (ptyProviderPromise) { - return ptyProviderPromise; - } - - ptyProviderPromise = (async () => { - const isBunRuntime = typeof globalThis.Bun !== 'undefined'; - - if (isBunRuntime) { - try { - const bunPty = await import('bun-pty'); - console.log('Using bun-pty for terminal sessions'); - return { spawn: bunPty.spawn, backend: 'bun-pty' }; - } catch (error) { - console.warn('bun-pty unavailable, falling back to node-pty'); - } - } - - try { - const nodePty = await import('node-pty'); - console.log('Using node-pty for terminal sessions'); - return { spawn: nodePty.spawn, backend: 'node-pty' }; - } catch (error) { - console.error('Failed to load node-pty:', error && error.message ? error.message : error); - if (isBunRuntime) { - throw new Error('No PTY backend available. Install bun-pty or node-pty.'); - } - throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)'); - } - })(); - - return ptyProviderPromise; - }; - - const getTerminalShellCandidates = () => { - if (process.platform === 'win32') { - const windowsCandidates = [ - process.env.OPENCHAMBER_TERMINAL_SHELL, - process.env.SHELL, - process.env.ComSpec, - path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), - 'pwsh.exe', - 'powershell.exe', - 'cmd.exe', - ].filter(Boolean); - - const resolved = []; - const seen = new Set(); - for (const candidateRaw of windowsCandidates) { - const candidate = String(candidateRaw).trim(); - if (!candidate) continue; - - const lookedUp = candidate.includes('\\') || candidate.includes('/') - ? candidate - : searchPathFor(candidate); - const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); - if (!executable || seen.has(executable)) continue; - seen.add(executable); - resolved.push(executable); - } - return resolved; - } - - const unixCandidates = [ - process.env.OPENCHAMBER_TERMINAL_SHELL, - process.env.SHELL, - '/bin/zsh', - '/bin/bash', - '/bin/sh', - 'zsh', - 'bash', - 'sh', - ].filter(Boolean); - - const resolved = []; - const seen = new Set(); - for (const candidateRaw of unixCandidates) { - const candidate = String(candidateRaw).trim(); - if (!candidate) continue; - - const lookedUp = candidate.includes('/') ? candidate : searchPathFor(candidate); - const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); - if (!executable || seen.has(executable)) continue; - seen.add(executable); - resolved.push(executable); - } - - return resolved; - }; - - const spawnTerminalPtyWithFallback = (pty, { cols, rows, cwd, env }) => { - const shellCandidates = getTerminalShellCandidates(); - if (shellCandidates.length === 0) { - throw new Error('No executable shell found for terminal session'); - } - - let lastError = null; - for (const shell of shellCandidates) { - try { - const ptyProcess = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: cols || 80, - rows: rows || 24, - cwd, - env: { - ...env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - }, - }); - - return { ptyProcess, shell }; - } catch (error) { - lastError = error; - console.warn(`Failed to spawn PTY using shell ${shell}:`, error && error.message ? error.message : error); - } - } - - const baseMessage = lastError && lastError.message ? lastError.message : 'PTY spawn failed'; - throw new Error(`Failed to spawn terminal PTY with available shells (${shellCandidates.join(', ')}): ${baseMessage}`); - }; - - const terminalSessions = new Map(); - const MAX_TERMINAL_SESSIONS = 20; - const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000; - const sanitizeTerminalEnv = (env) => { - const next = { ...env }; - delete next.BASH_XTRACEFD; - delete next.BASH_ENV; - delete next.ENV; - return next; - }; - const terminalInputCapabilities = { - input: { - preferred: 'ws', - transports: ['http', 'ws'], - ws: { - path: TERMINAL_INPUT_WS_PATH, - v: 1, - enc: 'text+json-bin-control', - }, - }, - }; - - const sendTerminalInputWsControl = (socket, payload) => { - if (!socket || socket.readyState !== 1) { - return; - } - - try { - socket.send(createTerminalInputWsControlFrame(payload), { binary: true }); - } catch { - } - }; - - terminalInputWsServer = new WebSocketServer({ - noServer: true, - maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, - }); - - terminalInputWsServer.on('connection', (socket) => { - const connectionState = { - boundSessionId: null, - invalidFrames: 0, - rebindTimestamps: [], - lastActivityAt: Date.now(), - }; - - sendTerminalInputWsControl(socket, { t: 'ok', v: 1 }); - - const heartbeatInterval = setInterval(() => { - if (socket.readyState !== 1) { - return; - } - - try { - socket.ping(); - } catch { - } - }, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS); - - socket.on('pong', () => { - connectionState.lastActivityAt = Date.now(); - }); - - socket.on('message', (message, isBinary) => { - connectionState.lastActivityAt = Date.now(); - - if (isBinary) { - const controlMessage = readTerminalInputWsControlFrame(message); - if (!controlMessage || typeof controlMessage.t !== 'string') { - connectionState.invalidFrames += 1; - sendTerminalInputWsControl(socket, { - t: 'e', - c: 'BAD_FRAME', - f: connectionState.invalidFrames >= 10, - }); - if (connectionState.invalidFrames >= 10) { - socket.close(1008, 'protocol violation'); - } - return; - } - - if (controlMessage.t === 'p') { - sendTerminalInputWsControl(socket, { t: 'po', v: 1 }); - return; - } - - if (controlMessage.t !== 'b' || typeof controlMessage.s !== 'string') { - connectionState.invalidFrames += 1; - sendTerminalInputWsControl(socket, { - t: 'e', - c: 'BAD_FRAME', - f: connectionState.invalidFrames >= 10, - }); - if (connectionState.invalidFrames >= 10) { - socket.close(1008, 'protocol violation'); - } - return; - } - - const now = Date.now(); - connectionState.rebindTimestamps = pruneRebindTimestamps( - connectionState.rebindTimestamps, - now, - TERMINAL_INPUT_WS_REBIND_WINDOW_MS - ); - - if (isRebindRateLimited(connectionState.rebindTimestamps, TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW)) { - sendTerminalInputWsControl(socket, { t: 'e', c: 'RATE_LIMIT', f: false }); - return; - } - - const nextSessionId = controlMessage.s.trim(); - const targetSession = terminalSessions.get(nextSessionId); - if (!targetSession) { - connectionState.boundSessionId = null; - sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); - return; - } - - connectionState.rebindTimestamps.push(now); - connectionState.boundSessionId = nextSessionId; - sendTerminalInputWsControl(socket, { t: 'bok', v: 1 }); - return; - } - - const payload = normalizeTerminalInputWsMessageToText(message); - if (payload.length === 0) { - return; - } - - if (!connectionState.boundSessionId) { - sendTerminalInputWsControl(socket, { t: 'e', c: 'NOT_BOUND', f: false }); - return; - } - - const session = terminalSessions.get(connectionState.boundSessionId); - if (!session) { - connectionState.boundSessionId = null; - sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); - return; - } - - try { - session.ptyProcess.write(payload); - session.lastActivity = Date.now(); - } catch { - sendTerminalInputWsControl(socket, { t: 'e', c: 'WRITE_FAIL', f: false }); - } - }); - - socket.on('close', () => { - clearInterval(heartbeatInterval); - }); - - socket.on('error', (error) => { - void error; - }); - }); - - server.on('upgrade', (req, socket, head) => { - const pathname = parseRequestPathname(req.url); - if (pathname !== TERMINAL_INPUT_WS_PATH) { - return; - } - - const handleUpgrade = async () => { - try { - if (uiAuthController?.enabled) { - // Must be awaited: this call performs async token verification. - const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null); - if (!sessionToken) { - rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); - return; - } - - const originAllowed = await isRequestOriginAllowed(req); - if (!originAllowed) { - rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); - return; - } - } - - if (!terminalInputWsServer) { - rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); - return; - } - - terminalInputWsServer.handleUpgrade(req, socket, head, (ws) => { - terminalInputWsServer.emit('connection', ws, req); - }); - } catch { - rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); - } - }; - - void handleUpgrade(); - }); - - setInterval(() => { - const now = Date.now(); - for (const [sessionId, session] of terminalSessions.entries()) { - if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) { - console.log(`Cleaning up idle terminal session: ${sessionId}`); - try { - session.ptyProcess.kill(); - } catch (error) { - - } - terminalSessions.delete(sessionId); - } - } - }, 5 * 60 * 1000); - - app.post('/api/terminal/create', async (req, res) => { - try { - if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) { - return res.status(429).json({ error: 'Maximum terminal sessions reached' }); - } - - const { cwd, cols, rows } = req.body; - if (!cwd) { - return res.status(400).json({ error: 'cwd is required' }); - } - - try { - await fs.promises.access(cwd); - } catch { - return res.status(400).json({ error: 'Invalid working directory' }); - } - - const sessionId = Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); - - const envPath = buildAugmentedPath(); - const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); - - const pty = await getPtyProvider(); - const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { - cols, - rows, - cwd, - env: resolvedEnv, - }); - - const session = { - ptyProcess, - ptyBackend: pty.backend, - cwd, - lastActivity: Date.now(), - clients: new Set(), - }; - - terminalSessions.set(sessionId, session); - - ptyProcess.onExit(({ exitCode, signal }) => { - console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`); - terminalSessions.delete(sessionId); - }); - - console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`); - res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); - } catch (error) { - console.error('Failed to create terminal session:', error); - res.status(500).json({ error: error.message || 'Failed to create terminal session' }); - } - }); - - app.get('/api/terminal/:sessionId/stream', (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - const clientId = Math.random().toString(36).substring(7); - session.clients.add(clientId); - session.lastActivity = Date.now(); - - const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun'; - const ptyBackend = session.ptyBackend || 'unknown'; - res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`); - - const heartbeatInterval = setInterval(() => { - try { - - res.write(': heartbeat\n\n'); - } catch (error) { - console.error(`Heartbeat failed for client ${clientId}:`, error); - clearInterval(heartbeatInterval); - } - }, 15000); - - const dataHandler = (data) => { - try { - session.lastActivity = Date.now(); - const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`); - if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') { - session.ptyProcess.pause(); - res.once('drain', () => { - if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') { - session.ptyProcess.resume(); - } - }); - } - } catch (error) { - console.error(`Error sending data to client ${clientId}:`, error); - cleanup(); - } - }; - - const exitHandler = ({ exitCode, signal }) => { - try { - res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`); - res.end(); - } catch (error) { - - } - cleanup(); - }; - - const dataDisposable = session.ptyProcess.onData(dataHandler); - const exitDisposable = session.ptyProcess.onExit(exitHandler); - - const cleanup = () => { - clearInterval(heartbeatInterval); - session.clients.delete(clientId); - - if (dataDisposable && typeof dataDisposable.dispose === 'function') { - dataDisposable.dispose(); - } - if (exitDisposable && typeof exitDisposable.dispose === 'function') { - exitDisposable.dispose(); - } - - try { - res.end(); - } catch (error) { - - } - - console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`); - }; - - req.on('close', cleanup); - req.on('error', cleanup); - - console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`); - }); - - app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - const data = typeof req.body === 'string' ? req.body : ''; - - try { - session.ptyProcess.write(data); - session.lastActivity = Date.now(); - res.json({ success: true }); - } catch (error) { - console.error('Failed to write to terminal:', error); - res.status(500).json({ error: error.message || 'Failed to write to terminal' }); - } - }); - - app.post('/api/terminal/:sessionId/resize', (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - const { cols, rows } = req.body; - if (!cols || !rows) { - return res.status(400).json({ error: 'cols and rows are required' }); - } - - try { - session.ptyProcess.resize(cols, rows); - session.lastActivity = Date.now(); - res.json({ success: true, cols, rows }); - } catch (error) { - console.error('Failed to resize terminal:', error); - res.status(500).json({ error: error.message || 'Failed to resize terminal' }); - } - }); - - app.delete('/api/terminal/:sessionId', (req, res) => { - const { sessionId } = req.params; - const session = terminalSessions.get(sessionId); - - if (!session) { - return res.status(404).json({ error: 'Terminal session not found' }); - } - - try { - session.ptyProcess.kill(); - terminalSessions.delete(sessionId); - console.log(`Closed terminal session: ${sessionId}`); - res.json({ success: true }); - } catch (error) { - console.error('Failed to close terminal:', error); - res.status(500).json({ error: error.message || 'Failed to close terminal' }); - } - }); - - app.post('/api/terminal/:sessionId/restart', async (req, res) => { - const { sessionId } = req.params; - const { cwd, cols, rows } = req.body; - - if (!cwd) { - return res.status(400).json({ error: 'cwd is required' }); - } - - const existingSession = terminalSessions.get(sessionId); - if (existingSession) { - try { - existingSession.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(sessionId); - } - - try { - try { - const stats = await fs.promises.stat(cwd); - if (!stats.isDirectory()) { - return res.status(400).json({ error: 'Invalid working directory: not a directory' }); - } - } catch (error) { - return res.status(400).json({ error: 'Invalid working directory: not accessible' }); - } - - const newSessionId = Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15); - - const envPath = buildAugmentedPath(); - const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); - - const pty = await getPtyProvider(); - const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { - cols, - rows, - cwd, - env: resolvedEnv, - }); - - const session = { - ptyProcess, - ptyBackend: pty.backend, - cwd, - lastActivity: Date.now(), - clients: new Set(), - }; - - terminalSessions.set(newSessionId, session); - - ptyProcess.onExit(({ exitCode, signal }) => { - console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`); - terminalSessions.delete(newSessionId); - }); - - console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`); - res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); - } catch (error) { - console.error('Failed to restart terminal session:', error); - res.status(500).json({ error: error.message || 'Failed to restart terminal session' }); - } - }); - - app.post('/api/terminal/force-kill', (req, res) => { - const { sessionId, cwd } = req.body; - let killedCount = 0; - - if (sessionId) { - const session = terminalSessions.get(sessionId); - if (session) { - try { - session.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(sessionId); - killedCount++; - } - } else if (cwd) { - for (const [id, session] of terminalSessions) { - if (session.cwd === cwd) { - try { - session.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(id); - killedCount++; - } - } - } else { - for (const [id, session] of terminalSessions) { - try { - session.ptyProcess.kill(); - } catch (error) { - } - terminalSessions.delete(id); - killedCount++; - } - } - - console.log(`Force killed ${killedCount} terminal session(s)`); - res.json({ success: true, killedCount }); - }); - - setupProxy(app); - scheduleOpenCodeApiDetection(); - void bootstrapOpenCodeAtStartup(); - - const distPath = (() => { - const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : ''; - if (env) { - return path.resolve(env); - } - return path.join(__dirname, '..', 'dist'); - })(); - - if (fs.existsSync(distPath)) { - console.log(`Serving static files from ${distPath}`); - app.use(express.static(distPath, { - setHeaders(res, filePath) { - // Service workers should never be long-cached; iOS is especially sensitive. - if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) { - res.setHeader('Cache-Control', 'no-store'); - } - }, - })); - - const recentPwaSessionsCache = new Map(); - - const getRecentPwaSessionShortcuts = async (req) => { - const now = Date.now(); - - const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null })); - const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string' - ? resolvedDirectoryResult.directory - : null; - - const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global'; - const cached = recentPwaSessionsCache.get(cacheKey); - if (cached && now - cached.at < 5000) { - return cached.data; - } - - const normalizeShortcutTitle = (value, fallback) => { - const normalized = normalizePwaAppName(value, fallback); - return normalized.length > 48 ? normalized.slice(0, 48) : normalized; - }; - - const toFiniteNumber = (value) => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - if (typeof value === 'string' && value.trim().length > 0) { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return null; - }; - - const normalizeDirectory = (value) => { - if (typeof value !== 'string') { - return ''; - } - const trimmed = value.trim(); - if (!trimmed) { - return ''; - } - const normalized = trimmed.replace(/\\/g, '/'); - if (normalized === '/') { - return '/'; - } - return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; - }; - - const sessionUpdatedAt = (session) => { - const time = session && typeof session.time === 'object' ? session.time : null; - return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0; - }; - - const filterSessionsByDirectory = (sessions, directory) => { - const normalizedDirectory = normalizeDirectory(directory); - if (!normalizedDirectory) { - return sessions; - } - - const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`; - return sessions.filter((session) => { - const sessionDirectory = normalizeDirectory(session?.directory); - if (!sessionDirectory) { - return false; - } - return sessionDirectory === normalizedDirectory || (prefix !== '/' && sessionDirectory.startsWith(prefix)); - }); - }; - - const listSessions = async (directory) => { - const query = (() => { - if (typeof directory !== 'string' || directory.length === 0) { - return ''; - } - const preparedDirectory = process.platform === 'win32' - ? directory.replace(/\//g, '\\') - : directory; - return `?directory=${encodeURIComponent(preparedDirectory)}`; - })(); - - const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(2500), - }); - - if (!response.ok) { - return []; - } - - const payload = await response.json().catch(() => null); - return Array.isArray(payload) ? payload : []; - }; - - try { - let payload = []; - - if (preferredDirectory) { - const scopedPayload = await listSessions(preferredDirectory); - const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory); - - if (filteredScopedPayload.length > 0) { - payload = filteredScopedPayload; - } else { - const globalPayload = await listSessions(null); - const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory); - payload = filteredGlobalPayload.length > 0 ? filteredGlobalPayload : globalPayload; - } - } else { - payload = await listSessions(null); - } - - const seen = new Set(); - const rows = []; - - for (const item of payload) { - if (!item || typeof item !== 'object') { - continue; - } - - const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : ''; - if (!id || seen.has(id)) { - continue; - } - - seen.add(id); - const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`); - const updatedAt = sessionUpdatedAt(item); - - rows.push({ id, title, updatedAt }); - } - - rows.sort((a, b) => b.updatedAt - a.updatedAt); - - const shortcuts = rows.slice(0, 3).map((session) => ({ - name: session.title, - short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title, - description: 'Open recent session', - url: `/?session=${encodeURIComponent(session.id)}`, - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], - })); - - recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts }); - return shortcuts; - } catch { - recentPwaSessionsCache.set(cacheKey, { at: now, data: [] }); - return []; - } - }; - - app.get('/manifest.webmanifest', async (req, res) => { - const hasQueryOverride = - typeof req.query?.pwa_name === 'string' - || typeof req.query?.app_name === 'string' - || typeof req.query?.appName === 'string'; - - let queryValueRaw = ''; - if (typeof req.query?.pwa_name === 'string') { - queryValueRaw = req.query.pwa_name; - } else if (typeof req.query?.app_name === 'string') { - queryValueRaw = req.query.app_name; - } else if (typeof req.query?.appName === 'string') { - queryValueRaw = req.query.appName; - } - - const queryOverrideName = normalizePwaAppName(queryValueRaw, ''); - - let storedName = ''; - try { - const settings = await readSettingsFromDiskMigrated(); - storedName = normalizePwaAppName(settings?.pwaAppName, ''); - } catch { - storedName = ''; - } - - const appName = hasQueryOverride - ? (queryOverrideName || DEFAULT_PWA_APP_NAME) - : (storedName || DEFAULT_PWA_APP_NAME); - - const shortName = appName.length > 30 ? appName.slice(0, 30) : appName; - const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req); - - const manifest = { - name: appName, - short_name: shortName, - description: 'Web interface companion for OpenCode AI coding agent', - id: '/', - start_url: '/', - scope: '/', - display: 'standalone', - background_color: '#151313', - theme_color: '#edb449', - orientation: 'any', - icons: [ - { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, - { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, - { src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' }, - { src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, - { src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' }, - { src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' }, - { src: '/favicon-32.png', sizes: '32x32', type: 'image/png' }, - { src: '/favicon-16.png', sizes: '16x16', type: 'image/png' }, - ], - shortcuts: [ - { - name: 'Appearance Settings', - short_name: 'Settings', - description: 'Open appearance settings', - url: '/?settings=appearance', - icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], - }, - ...recentSessionShortcuts, - ], - categories: ['developer', 'tools', 'productivity'], - lang: 'en', - }; - - res.setHeader('Cache-Control', 'no-store, must-revalidate'); - res.type('application/manifest+json'); - res.send(JSON.stringify(manifest)); - }); - - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { - res.sendFile(path.join(distPath, 'index.html')); - }); - } else { - console.warn(`Warning: ${distPath} not found, static files will not be served`); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { - res.status(404).send('Static files not found. Please build the application first.'); - }); - } - - let activePort = port; - - const bindHost = host - || (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0 - ? process.env.OPENCHAMBER_HOST.trim() - : '127.0.0.1'); - - await new Promise((resolve, reject) => { - const onError = (error) => { - server.off('error', onError); - reject(error); - }; - server.once('error', onError); - const onListening = async () => { - server.off('error', onError); - const addressInfo = server.address(); - activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port; - - try { - process.send?.({ type: 'openchamber:ready', port: activePort }); - } catch { - // ignore - } - - const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]') - ? 'localhost' - : (bindHost.includes(':') ? `[${bindHost}]` : bindHost); - console.log(`OpenChamber server listening on ${bindHost}:${activePort}`); - console.log(`Health check: http://${displayHost}:${activePort}/health`); - console.log(`Web interface: http://${displayHost}:${activePort}`); - - if (startupTunnelRequest) { - const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK - ? 'Quick Tunnel' - : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL - ? 'Managed Local Tunnel' - : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel')); - console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`); - try { - const { publicUrl, mode } = await startTunnelWithNormalizedRequest({ - provider: startupTunnelRequest.provider, - mode: startupTunnelRequest.mode, - intent: startupTunnelRequest.intent, - hostname: startupTunnelRequest.hostname, - token: startupTunnelRequest.token, - configPath: startupTunnelRequest.configPath, - selectedPresetId: '', - selectedPresetName: '', - }); - if (publicUrl) { - tunnelAuthController.setActiveTunnel({ - tunnelId: crypto.randomUUID(), - publicUrl, - mode, - }); - const settings = await readSettingsFromDiskMigrated(); - const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null - ? null - : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); - const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); - const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; - if (onTunnelReady) { - onTunnelReady(publicUrl, connectUrl); - } else { - console.log(`\n🌐 Tunnel URL: ${connectUrl}`); - console.log('🔑 One-time connect link (expires after first use)\n'); - } - } else if (onTunnelReady) { - onTunnelReady(publicUrl, null); - } - } catch (error) { - console.error(`Failed to start tunnel: ${error.message}`); - console.log('Continuing without tunnel...'); - } - } - - resolve(); - }; - - server.listen(port, bindHost, onListening); - }); - - if (attachSignals && !signalsAttached) { - const handleSignal = async () => { - await gracefulShutdown(); - }; - process.on('SIGTERM', handleSignal); - process.on('SIGINT', handleSignal); - process.on('SIGQUIT', handleSignal); - signalsAttached = true; - syncToHmrState(); - } - - process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); - }); - - process.on('uncaughtException', (error) => { - console.error('Uncaught Exception:', error); - gracefulShutdown(); - }); + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + host, + port, + startupTunnelRequest, + onTunnelReady, + tunnelRuntimeContext, + attachSignals, + }); + terminalRuntime = startupPipelineResult.terminalRuntime; return { expressApp: app, httpServer: server, - getPort: () => activePort, + getPort: () => tunnelRuntimeContext.getActivePort(), getOpenCodePort: () => openCodePort, getTunnelUrl: () => tunnelService.getPublicUrl(), isReady: () => isOpenCodeReady, @@ -14387,27 +970,23 @@ async function main(options = {}) { }; } -const isCliExecution = process.argv[1] === __filename; +runCliEntryIfMain({ + process, + currentFilename: __filename, + parseServeCliOptions, + defaultPort: DEFAULT_PORT, + cloudflareProvider: TUNNEL_PROVIDER_CLOUDFLARE, + managedLocalMode: TUNNEL_MODE_MANAGED_LOCAL, + setExitOnShutdown: (value) => { + exitOnShutdown = value; + }, + startServer: main, +}); -if (isCliExecution) { - const cliOptions = parseArgs(); - exitOnShutdown = true; - main({ - port: cliOptions.port, - host: cliOptions.host, - tryCfTunnel: cliOptions.tryCfTunnel, - tunnelProvider: cliOptions.tunnelProvider, - tunnelMode: cliOptions.tunnelMode, - tunnelConfigPath: cliOptions.tunnelConfigPath, - tunnelToken: cliOptions.tunnelToken, - tunnelHostname: cliOptions.tunnelHostname, - attachSignals: true, - exitOnShutdown: true, - uiPassword: cliOptions.uiPassword - }).catch(error => { - console.error('Failed to start server:', error); - process.exit(1); - }); -} - -export { gracefulShutdown, setupProxy, restartOpenCode, main as startWebUiServer, parseArgs }; +export { + gracefulShutdown, + setupProxy, + restartOpenCode, + main as startWebUiServer, + parseServeCliOptions as parseArgs, +}; diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md new file mode 100644 index 00000000..dc87c8cd --- /dev/null +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -0,0 +1,36 @@ +# FS Module Documentation + +## Purpose +Own filesystem API behavior for the web server runtime, including workspace-bound file operations, directory listing, reveal, and background command execution jobs. + +## Entrypoints and structure +- `packages/web/server/lib/fs/routes.js`: route registration and runtime-owned state for `/api/fs/*` endpoints. +- `packages/web/server/lib/fs/search.js`: fuzzy filesystem search runtime used by non-FS routes (for example project icon discovery). + +## Public exports +- `registerFsRoutes(app, dependencies)` from `routes.js` + - Registers all filesystem routes: + - `GET /api/fs/home` + - `POST /api/fs/mkdir` + - `GET /api/fs/read` + - `GET /api/fs/raw` + - `POST /api/fs/write` + - `POST /api/fs/delete` + - `POST /api/fs/rename` + - `POST /api/fs/reveal` + - `POST /api/fs/exec` + - `GET /api/fs/exec/:jobId` + - `GET /api/fs/list` + - Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning. + - Enforces workspace boundary checks with active project + worktree fallback support. +- `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js` + - Returns `{ searchFilesystemFiles(rootPath, options) }`. + - Supports fuzzy matching, hidden-file handling, and optional `git check-ignore` filtering. + +## Composition contract with `index.js` +- `index.js` provides composition-time dependencies only (platform primitives + callbacks such as `resolveProjectDirectory`, `normalizeDirectoryPath`, and `buildAugmentedPath`). +- `index.js` no longer owns FS route handlers or FS exec job state. + +## Notes for contributors +- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root. +- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document. diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js new file mode 100644 index 00000000..0eaaeeed --- /dev/null +++ b/packages/web/server/lib/fs/routes.js @@ -0,0 +1,760 @@ +const EXEC_JOB_TTL_MS = 30 * 60 * 1000; + +const createCommandTimeoutMs = () => { + const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS); + if (Number.isFinite(raw) && raw > 0) return raw; + return 5 * 60 * 1000; +}; + +const isPathWithinRoot = (resolvedPath, rootPath, path, os) => { + const resolvedRoot = path.resolve(rootPath || os.homedir()); + const relative = path.relative(resolvedRoot, resolvedPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return false; + } + return true; +}; + +const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => { + const normalized = normalizeDirectoryPath(targetPath); + if (!normalized || typeof normalized !== 'string') { + return { ok: false, error: 'Path is required' }; + } + + const resolved = path.resolve(normalized); + const resolvedBase = path.resolve(baseDirectory || os.homedir()); + + if (isPathWithinRoot(resolved, resolvedBase, path, os)) { + return { ok: true, base: resolvedBase, resolved }; + } + + if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) { + return { ok: true, base: path.resolve(openchamberUserConfigRoot), resolved }; + } + + return { ok: false, error: 'Path is outside of active workspace' }; +}; + +const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath }) => { + const normalized = normalizeDirectoryPath(targetPath); + if (!normalized || typeof normalized !== 'string') { + return { ok: false, error: 'Path is required' }; + } + + const resolved = path.resolve(normalized); + const resolvedBase = path.resolve(baseDirectory || os.homedir()); + + try { + const { getWorktrees } = await import('../git/index.js'); + const worktrees = await getWorktrees(resolvedBase); + + for (const worktree of worktrees) { + const candidatePath = typeof worktree?.path === 'string' + ? worktree.path + : (typeof worktree?.worktree === 'string' ? worktree.worktree : ''); + const candidate = normalizeDirectoryPath(candidatePath); + if (!candidate) { + continue; + } + const candidateResolved = path.resolve(candidate); + if (isPathWithinRoot(resolved, candidateResolved, path, os)) { + return { ok: true, base: candidateResolved, resolved }; + } + } + } catch (error) { + console.warn('Failed to resolve worktree roots:', error); + } + + return { ok: false, error: 'Path is outside of active workspace' }; +}; + +const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => { + const resolvedProject = await resolveProjectDirectory(req); + if (!resolvedProject.directory) { + return { ok: false, error: resolvedProject.error || 'Active workspace is required' }; + } + + const resolved = resolveWorkspacePath({ + targetPath, + baseDirectory: resolvedProject.directory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (resolved.ok || resolved.error !== 'Path is outside of active workspace') { + return resolved; + } + + return resolveWorkspacePathFromWorktrees({ + targetPath, + baseDirectory: resolvedProject.directory, + path, + os, + normalizeDirectoryPath, + }); +}; + +const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => { + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const envPath = buildAugmentedPath(); + const execEnv = { ...process.env, PATH: envPath }; + + const child = spawn(shell, [shellFlag, command], { + cwd: resolvedCwd, + env: execEnv, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const timeout = setTimeout(() => { + timedOut = true; + try { + child.kill('SIGKILL'); + } catch { + } + }, commandTimeoutMs); + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + clearTimeout(timeout); + resolve({ + command, + success: false, + exitCode: undefined, + stdout: stdout.trim(), + stderr: stderr.trim(), + error: (error && error.message) || 'Command execution failed', + }); + }); + + child.on('close', (code, signal) => { + clearTimeout(timeout); + const exitCode = typeof code === 'number' ? code : undefined; + const base = { + command, + success: exitCode === 0 && !timedOut, + exitCode, + stdout: stdout.trim(), + stderr: stderr.trim(), + }; + + if (timedOut) { + resolve({ + ...base, + success: false, + error: `Command timed out after ${commandTimeoutMs}ms` + (signal ? ` (${signal})` : ''), + }); + return; + } + + resolve(base); + }); + }); +}; + +export const registerFsRoutes = (app, dependencies) => { + const { + os, + path, + fsPromises, + spawn, + crypto, + normalizeDirectoryPath, + resolveProjectDirectory, + buildAugmentedPath, + resolveGitBinaryForSpawn, + openchamberUserConfigRoot, + } = dependencies; + + const execJobs = new Map(); + const commandTimeoutMs = createCommandTimeoutMs(); + + const pruneExecJobs = () => { + const now = Date.now(); + for (const [jobId, job] of execJobs.entries()) { + if (!job || typeof job !== 'object') { + execJobs.delete(jobId); + continue; + } + const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0; + if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) { + execJobs.delete(jobId); + } + } + }; + + const runExecJob = async (job) => { + job.status = 'running'; + job.updatedAt = Date.now(); + + const results = []; + for (const command of job.commands) { + if (typeof command !== 'string' || !command.trim()) { + results.push({ command, success: false, error: 'Invalid command' }); + continue; + } + + try { + const result = await runCommandInDirectory({ + shell: job.shell, + shellFlag: job.shellFlag, + command, + resolvedCwd: job.resolvedCwd, + spawn, + buildAugmentedPath, + commandTimeoutMs, + }); + results.push(result); + } catch (error) { + results.push({ + command, + success: false, + error: (error && error.message) || 'Command execution failed', + }); + } + + job.results = results; + job.updatedAt = Date.now(); + } + + job.results = results; + job.success = results.every((r) => r.success); + job.status = 'done'; + job.finishedAt = Date.now(); + job.updatedAt = Date.now(); + }; + + app.get('/api/fs/home', (_req, res) => { + try { + const home = os.homedir(); + if (!home || typeof home !== 'string' || home.length === 0) { + return res.status(500).json({ error: 'Failed to resolve home directory' }); + } + return res.json({ home }); + } catch (error) { + console.error('Failed to resolve home directory:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' }); + } + }); + + app.post('/api/fs/mkdir', async (req, res) => { + try { + const { path: dirPath, allowOutsideWorkspace } = req.body ?? {}; + if (typeof dirPath !== 'string' || !dirPath.trim()) { + return res.status(400).json({ error: 'Path is required' }); + } + + let resolvedPath = ''; + if (allowOutsideWorkspace) { + resolvedPath = path.resolve(normalizeDirectoryPath(dirPath)); + } else { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: dirPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + resolvedPath = resolved.resolved; + } + + await fsPromises.mkdir(resolvedPath, { recursive: true }); + return res.json({ success: true, path: resolvedPath }); + } catch (error) { + console.error('Failed to create directory:', error); + return res.status(500).json({ error: error.message || 'Failed to create directory' }); + } + }); + + app.get('/api/fs/read', async (req, res) => { + const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; + if (!filePath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const [canonicalPath, canonicalBase] = await Promise.all([ + fsPromises.realpath(resolved.resolved), + fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), + ]); + + if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) { + return res.status(403).json({ error: 'Access to file denied' }); + } + + const stats = await fsPromises.stat(canonicalPath); + if (!stats.isFile()) { + return res.status(400).json({ error: 'Specified path is not a file' }); + } + + const content = await fsPromises.readFile(canonicalPath, 'utf8'); + return res.type('text/plain').send(content); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to read file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); + } + }); + + app.get('/api/fs/raw', async (req, res) => { + const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; + if (!filePath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const [canonicalPath, canonicalBase] = await Promise.all([ + fsPromises.realpath(resolved.resolved), + fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), + ]); + + if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) { + return res.status(403).json({ error: 'Access to file denied' }); + } + + const stats = await fsPromises.stat(canonicalPath); + if (!stats.isFile()) { + return res.status(400).json({ error: 'Specified path is not a file' }); + } + + const ext = path.extname(canonicalPath).toLowerCase(); + const mimeMap = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp', + '.avif': 'image/avif', + }; + const mimeType = mimeMap[ext] || 'application/octet-stream'; + + const content = await fsPromises.readFile(canonicalPath); + res.setHeader('Cache-Control', 'no-store'); + return res.type(mimeType).send(content); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to read raw file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to read file' }); + } + }); + + app.post('/api/fs/write', async (req, res) => { + const { path: filePath, content } = req.body || {}; + if (!filePath || typeof filePath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + if (typeof content !== 'string') { + return res.status(400).json({ error: 'Content is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true }); + await fsPromises.writeFile(resolved.resolved, content, 'utf8'); + return res.json({ success: true, path: resolved.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to write file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to write file' }); + } + }); + + app.post('/api/fs/delete', async (req, res) => { + const { path: targetPath } = req.body || {}; + if (!targetPath || typeof targetPath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + await fsPromises.rm(resolved.resolved, { recursive: true, force: true }); + return res.json({ success: true, path: resolved.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'File or directory not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to delete path:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to delete path' }); + } + }); + + app.post('/api/fs/rename', async (req, res) => { + const { oldPath, newPath } = req.body || {}; + if (!oldPath || typeof oldPath !== 'string') { + return res.status(400).json({ error: 'oldPath is required' }); + } + if (!newPath || typeof newPath !== 'string') { + return res.status(400).json({ error: 'newPath is required' }); + } + + try { + const resolvedOld = await resolveWorkspacePathFromContext({ + req, + targetPath: oldPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolvedOld.ok) { + return res.status(400).json({ error: resolvedOld.error }); + } + + const resolvedNew = await resolveWorkspacePathFromContext({ + req, + targetPath: newPath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolvedNew.ok) { + return res.status(400).json({ error: resolvedNew.error }); + } + + if (resolvedOld.base !== resolvedNew.base) { + return res.status(400).json({ error: 'Source and destination must share the same workspace root' }); + } + + await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved); + return res.json({ success: true, path: resolvedNew.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'Source path not found' }); + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return res.status(403).json({ error: 'Access denied' }); + } + console.error('Failed to rename path:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to rename path' }); + } + }); + + app.post('/api/fs/reveal', async (req, res) => { + const { path: targetPath } = req.body || {}; + if (!targetPath || typeof targetPath !== 'string') { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + const resolved = path.resolve(targetPath.trim()); + await fsPromises.access(resolved); + + const platform = process.platform; + if (platform === 'darwin') { + const stat = await fsPromises.stat(resolved); + if (stat.isDirectory()) { + spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } else { + spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } + } else if (platform === 'win32') { + spawn('explorer', ['/select,', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } else { + const stat = await fsPromises.stat(resolved); + const dir = stat.isDirectory() ? resolved : path.dirname(resolved); + spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref(); + } + + return res.json({ success: true, path: resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'Path not found' }); + } + console.error('Failed to reveal path:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' }); + } + }); + + app.post('/api/fs/exec', async (req, res) => { + const { commands, cwd, background } = req.body || {}; + if (!Array.isArray(commands) || commands.length === 0) { + return res.status(400).json({ error: 'Commands array is required' }); + } + if (!cwd || typeof cwd !== 'string') { + return res.status(400).json({ error: 'Working directory (cwd) is required' }); + } + + pruneExecJobs(); + + try { + const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd)); + const stats = await fsPromises.stat(resolvedCwd); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified cwd is not a directory' }); + } + + const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); + const shellFlag = process.platform === 'win32' ? '/c' : '-c'; + + const jobId = crypto.randomUUID(); + const job = { + jobId, + status: 'queued', + success: null, + commands, + resolvedCwd, + shell, + shellFlag, + results: [], + startedAt: Date.now(), + finishedAt: null, + updatedAt: Date.now(), + }; + + execJobs.set(jobId, job); + + const isBackground = background === true; + if (isBackground) { + void runExecJob(job).catch((error) => { + job.status = 'done'; + job.success = false; + job.results = Array.isArray(job.results) ? job.results : []; + job.results.push({ + command: '', + success: false, + error: (error && error.message) || 'Command execution failed', + }); + job.finishedAt = Date.now(); + job.updatedAt = Date.now(); + }); + + return res.status(202).json({ + jobId, + status: 'running', + }); + } + + await runExecJob(job); + return res.json({ + jobId, + status: job.status, + success: job.success === true, + results: job.results, + }); + } catch (error) { + console.error('Failed to execute commands:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' }); + } + }); + + app.get('/api/fs/exec/:jobId', (req, res) => { + const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : ''; + if (!jobId) { + return res.status(400).json({ error: 'Job id is required' }); + } + + pruneExecJobs(); + + const job = execJobs.get(jobId); + if (!job) { + return res.status(404).json({ error: 'Job not found' }); + } + + job.updatedAt = Date.now(); + return res.json({ + jobId: job.jobId, + status: job.status, + success: job.success === true, + results: Array.isArray(job.results) ? job.results : [], + }); + }); + + app.get('/api/fs/list', async (req, res) => { + const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0 + ? req.query.path.trim() + : os.homedir(); + const respectGitignore = req.query.respectGitignore === 'true'; + let resolvedPath = ''; + + const isPlansDirectory = (value) => { + if (!value || typeof value !== 'string') return false; + const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans'); + }; + + try { + resolvedPath = path.resolve(normalizeDirectoryPath(rawPath)); + + const stats = await fsPromises.stat(resolvedPath); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified path is not a directory' }); + } + + const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true }); + let ignoredPaths = new Set(); + if (respectGitignore) { + try { + const pathsToCheck = dirents.map((d) => d.name); + if (pathsToCheck.length > 0) { + try { + const result = await new Promise((resolve) => { + const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { + cwd: resolvedPath, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.on('close', () => resolve(stdout)); + child.on('error', () => resolve('')); + }); + + result.split('\n').filter(Boolean).forEach((name) => { + const fullPath = path.join(resolvedPath, name.trim()); + ignoredPaths.add(fullPath); + }); + } catch { + } + } + } catch { + } + } + + const entries = await Promise.all( + dirents.map(async (dirent) => { + const entryPath = path.join(resolvedPath, dirent.name); + if (respectGitignore && ignoredPaths.has(entryPath)) { + return null; + } + + let isDirectory = dirent.isDirectory(); + const isSymbolicLink = dirent.isSymbolicLink(); + + if (!isDirectory && isSymbolicLink) { + try { + const linkStats = await fsPromises.stat(entryPath); + isDirectory = linkStats.isDirectory(); + } catch { + isDirectory = false; + } + } + + return { + name: dirent.name, + path: entryPath, + isDirectory, + isFile: dirent.isFile(), + isSymbolicLink, + }; + }) + ); + + return res.json({ + path: resolvedPath, + entries: entries.filter(Boolean), + }); + } catch (error) { + const err = error; + const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined; + const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath)); + if (!isPlansPath) { + console.error('Failed to list directory:', error); + } + if (code === 'ENOENT') { + if (isPlansPath) { + return res.json({ path: resolvedPath || rawPath, entries: [] }); + } + return res.status(404).json({ error: 'Directory not found' }); + } + if (code === 'EACCES') { + return res.status(403).json({ error: 'Access to directory denied' }); + } + return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' }); + } + }); +}; diff --git a/packages/web/server/lib/fs/search.js b/packages/web/server/lib/fs/search.js new file mode 100644 index 00000000..c770fdc6 --- /dev/null +++ b/packages/web/server/lib/fs/search.js @@ -0,0 +1,238 @@ +const FILE_SEARCH_MAX_CONCURRENCY = 5; +const FILE_SEARCH_EXCLUDED_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', + 'coverage', + 'tmp', + 'logs', +]); + +const normalizeRelativeSearchPath = (rootPath, targetPath, path) => { + const relative = path.relative(rootPath, targetPath) || path.basename(targetPath); + return relative.split(path.sep).join('/') || targetPath; +}; + +const shouldSkipSearchDirectory = (name, includeHidden) => { + if (!name) { + return false; + } + if (!includeHidden && name.startsWith('.')) { + return true; + } + return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase()); +}; + +const listDirectoryEntries = async (dirPath, fsPromises) => { + try { + return await fsPromises.readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } +}; + +const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => { + if (!normalizedQuery) return 0; + + const q = normalizedQuery; + const c = candidate.toLowerCase(); + if (c.includes(q)) { + const idx = c.indexOf(q); + let bonus = 0; + if (idx === 0) { + bonus = 20; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + bonus = 15; + } + } + return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5); + } + + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i += 1) { + const ch = q[i]; + if (!ch || ch === ' ') continue; + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive += 1; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); + score -= Math.min(gap, 10); + + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; + lastIndex = idx; + } + + score += Math.max(0, 24 - Math.floor(c.length / 3)); + return score; +}; + +export const createFsSearchRuntime = ({ fsPromises, path, spawn, resolveGitBinaryForSpawn }) => { + const searchFilesystemFiles = async (rootPath, options) => { + const { limit, query, includeHidden, respectGitignore } = options; + const includeHiddenEntries = Boolean(includeHidden); + const normalizedQuery = query.trim().toLowerCase(); + const matchAll = normalizedQuery.length === 0; + const queue = [rootPath]; + const visited = new Set([rootPath]); + const shouldRespectGitignore = respectGitignore !== false; + const collectLimit = matchAll ? limit : Math.max(limit * 3, 200); + const candidates = []; + + while (queue.length > 0 && candidates.length < collectLimit) { + const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY); + + const dirResults = await Promise.all( + batch.map(async (dir) => { + if (!shouldRespectGitignore) { + return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() }; + } + + try { + const dirents = await listDirectoryEntries(dir, fsPromises); + const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean); + if (pathsToCheck.length === 0) { + return { dir, dirents, ignoredPaths: new Set() }; + } + + const result = await new Promise((resolve) => { + const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], { + cwd: dir, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.on('close', () => resolve(stdout)); + child.on('error', () => resolve('')); + }); + + const ignoredNames = new Set( + String(result) + .split('\n') + .map((name) => name.trim()) + .filter(Boolean) + ); + + return { dir, dirents, ignoredPaths: ignoredNames }; + } catch { + return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() }; + } + }) + ); + + for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) { + for (const dirent of dirents) { + const entryName = dirent.name; + if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) { + continue; + } + + if (shouldRespectGitignore && ignoredPaths.has(entryName)) { + continue; + } + + const entryPath = path.join(currentDir, entryName); + + if (dirent.isDirectory()) { + if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) { + continue; + } + if (!visited.has(entryPath)) { + visited.add(entryPath); + queue.push(entryPath); + } + continue; + } + + if (!dirent.isFile()) { + continue; + } + + const relativePath = normalizeRelativeSearchPath(rootPath, entryPath, path); + const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined; + + if (matchAll) { + candidates.push({ + name: entryName, + path: entryPath, + relativePath, + extension, + score: 0, + }); + } else { + const score = fuzzyMatchScoreNormalized(normalizedQuery, relativePath); + if (score !== null) { + candidates.push({ + name: entryName, + path: entryPath, + relativePath, + extension, + score, + }); + } + } + + if (candidates.length >= collectLimit) { + queue.length = 0; + break; + } + } + + if (candidates.length >= collectLimit) { + break; + } + } + } + + if (!matchAll) { + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.relativePath.length !== b.relativePath.length) { + return a.relativePath.length - b.relativePath.length; + } + return a.relativePath.localeCompare(b.relativePath); + }); + } + + return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({ + name, + path: filePath, + relativePath, + extension, + })); + }; + + return { + searchFilesystemFiles, + }; +}; diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index d708e8e9..5222288a 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -6,6 +6,7 @@ This module provides Git repository operations for the web server runtime, inclu ## Entrypoints and structure - `packages/web/server/lib/git/`: Git module directory containing all Git-related functionality. - `index.js`: Public API entry point imported by `packages/web/server/index.js`. + - `routes.js`: Express route registration for `/api/git/*` endpoints. - `service.js`: Core Git operations (repository, branch, worktree, commit, merge/rebase, status/diff, log). - `credentials.js`: Git credentials management. - `identity-storage.js`: Git identity (user.name, user.email) storage. diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js new file mode 100644 index 00000000..964e4789 --- /dev/null +++ b/packages/web/server/lib/git/routes.js @@ -0,0 +1,867 @@ +export function registerGitRoutes(app) { + let gitLibraries = null; + const getGitLibraries = async () => { + if (!gitLibraries) { + gitLibraries = await import('./index.js'); + } + return gitLibraries; + }; + + app.get('/api/git/identities', async (req, res) => { + const { getProfiles } = await getGitLibraries(); + try { + const profiles = getProfiles(); + res.json(profiles); + } catch (error) { + console.error('Failed to list git identity profiles:', error); + res.status(500).json({ error: 'Failed to list git identity profiles' }); + } + }); + + app.post('/api/git/identities', async (req, res) => { + const { createProfile } = await getGitLibraries(); + try { + const profile = createProfile(req.body); + console.log(`Created git identity profile: ${profile.name} (${profile.id})`); + res.json(profile); + } catch (error) { + console.error('Failed to create git identity profile:', error); + res.status(400).json({ error: error.message || 'Failed to create git identity profile' }); + } + }); + + app.put('/api/git/identities/:id', async (req, res) => { + const { updateProfile } = await getGitLibraries(); + try { + const profile = updateProfile(req.params.id, req.body); + console.log(`Updated git identity profile: ${profile.name} (${profile.id})`); + res.json(profile); + } catch (error) { + console.error('Failed to update git identity profile:', error); + res.status(400).json({ error: error.message || 'Failed to update git identity profile' }); + } + }); + + app.delete('/api/git/identities/:id', async (req, res) => { + const { deleteProfile } = await getGitLibraries(); + try { + deleteProfile(req.params.id); + console.log(`Deleted git identity profile: ${req.params.id}`); + res.json({ success: true }); + } catch (error) { + console.error('Failed to delete git identity profile:', error); + res.status(400).json({ error: error.message || 'Failed to delete git identity profile' }); + } + }); + + app.get('/api/git/global-identity', async (req, res) => { + const { getGlobalIdentity } = await getGitLibraries(); + try { + const identity = await getGlobalIdentity(); + res.json(identity); + } catch (error) { + console.error('Failed to get global git identity:', error); + res.status(500).json({ error: 'Failed to get global git identity' }); + } + }); + + app.get('/api/git/discover-credentials', async (req, res) => { + try { + const { discoverGitCredentials } = await import('./index.js'); + const credentials = discoverGitCredentials(); + res.json(credentials); + } catch (error) { + console.error('Failed to discover git credentials:', error); + res.status(500).json({ error: 'Failed to discover git credentials' }); + } + }); + + app.get('/api/git/check', async (req, res) => { + const { isGitRepository } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const isRepo = await isGitRepository(directory); + res.json({ isGitRepository: isRepo }); + } catch (error) { + console.error('Failed to check git repository:', error); + res.status(500).json({ error: 'Failed to check git repository' }); + } + }); + + app.get('/api/git/remote-url', async (req, res) => { + const { getRemoteUrl } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const remote = req.query.remote || 'origin'; + + const url = await getRemoteUrl(directory, remote); + res.json({ url }); + } catch (error) { + console.error('Failed to get remote url:', error); + res.status(500).json({ error: 'Failed to get remote url' }); + } + }); + + app.get('/api/git/current-identity', async (req, res) => { + const { getCurrentIdentity } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const identity = await getCurrentIdentity(directory); + res.json(identity); + } catch (error) { + console.error('Failed to get current git identity:', error); + res.status(500).json({ error: 'Failed to get current git identity' }); + } + }); + + app.get('/api/git/has-local-identity', async (req, res) => { + const { hasLocalIdentity } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const hasLocal = await hasLocalIdentity(directory); + res.json({ hasLocalIdentity: hasLocal }); + } catch (error) { + console.error('Failed to check local git identity:', error); + res.status(500).json({ error: 'Failed to check local git identity' }); + } + }); + + app.post('/api/git/set-identity', async (req, res) => { + const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { profileId } = req.body; + if (!profileId) { + return res.status(400).json({ error: 'profileId is required' }); + } + + let profile = null; + + if (profileId === 'global') { + const globalIdentity = await getGlobalIdentity(); + if (!globalIdentity?.userName || !globalIdentity?.userEmail) { + return res.status(404).json({ error: 'Global identity is not configured' }); + } + profile = { + id: 'global', + name: 'Global Identity', + userName: globalIdentity.userName, + userEmail: globalIdentity.userEmail, + sshKey: globalIdentity.sshCommand + ? globalIdentity.sshCommand.replace('ssh -i ', '') + : null, + }; + } else { + profile = getProfile(profileId); + if (!profile) { + return res.status(404).json({ error: 'Profile not found' }); + } + } + + await setLocalIdentity(directory, profile); + res.json({ success: true, profile }); + } catch (error) { + console.error('Failed to set git identity:', error); + res.status(500).json({ error: error.message || 'Failed to set git identity' }); + } + }); + + app.get('/api/git/status', async (req, res) => { + const { getStatus, isGitRepository } = await getGitLibraries(); + + const extractGitErrorText = (error) => { + const message = typeof error?.message === 'string' ? error.message : ''; + const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; + const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; + return [message, stderr, stdout] + .map((value) => String(value || '').trim()) + .filter(Boolean) + .join('\n'); + }; + + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const isRepo = await isGitRepository(directory); + if (!isRepo) { + return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); + } + + const mode = req.query.mode === 'light' ? 'light' : undefined; + const status = await getStatus(directory, { mode }); + res.json(status); + } catch (error) { + const errorText = extractGitErrorText(error); + if (/not a git repository/i.test(errorText)) { + return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); + } + console.error('Failed to get git status:', error); + res.status(500).json({ error: error.message || 'Failed to get git status' }); + } + }); + + app.get('/api/git/diff', async (req, res) => { + const { getDiff } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const path = req.query.path; + if (!path || typeof path !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + + const staged = req.query.staged === 'true'; + const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; + + const diff = await getDiff(directory, { + path, + staged, + contextLines: Number.isFinite(context) ? context : 3, + }); + + res.json({ diff }); + } catch (error) { + console.error('Failed to get git diff:', error); + res.status(500).json({ error: error.message || 'Failed to get git diff' }); + } + }); + + app.get('/api/git/file-diff', async (req, res) => { + const { getFileDiff } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const pathParam = req.query.path; + if (!pathParam || typeof pathParam !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + + const staged = req.query.staged === 'true'; + + const result = await getFileDiff(directory, { + path: pathParam, + staged, + }); + + res.json({ + original: result.original, + modified: result.modified, + path: result.path, + isBinary: Boolean(result.isBinary), + }); + } catch (error) { + console.error('Failed to get git file diff:', error); + res.status(500).json({ error: error.message || 'Failed to get git file diff' }); + } + }); + + app.post('/api/git/revert', async (req, res) => { + const { revertFile } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { path } = req.body || {}; + if (!path || typeof path !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + + await revertFile(directory, path); + res.json({ success: true }); + } catch (error) { + console.error('Failed to revert git file:', error); + res.status(500).json({ error: error.message || 'Failed to revert git file' }); + } + }); + + app.post('/api/git/pull', async (req, res) => { + const { pull } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await pull(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to pull:', error); + res.status(500).json({ error: error.message || 'Failed to pull from remote' }); + } + }); + + app.post('/api/git/push', async (req, res) => { + const { push } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await push(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to push:', error); + res.status(500).json({ error: error.message || 'Failed to push to remote' }); + } + }); + + app.post('/api/git/fetch', async (req, res) => { + const { fetch: gitFetch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await gitFetch(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to fetch:', error); + res.status(500).json({ error: error.message || 'Failed to fetch from remote' }); + } + }); + + app.get('/api/git/remotes', async (req, res) => { + const { getRemotes } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const remotes = await getRemotes(directory); + res.json(remotes); + } catch (error) { + console.error('Failed to get remotes:', error); + res.status(500).json({ error: error.message || 'Failed to get remotes' }); + } + }); + + app.delete('/api/git/remotes', async (req, res) => { + const { removeRemote } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const remote = String(req.body?.remote || '').trim(); + if (!remote) { + return res.status(400).json({ error: 'remote is required' }); + } + + const result = await removeRemote(directory, { remote }); + res.json(result); + } catch (error) { + console.error('Failed to remove remote:', error); + res.status(500).json({ error: error.message || 'Failed to remove remote' }); + } + }); + + app.post('/api/git/rebase', async (req, res) => { + const { rebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await rebase(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to rebase:', error); + res.status(500).json({ error: error.message || 'Failed to rebase' }); + } + }); + + app.post('/api/git/rebase/abort', async (req, res) => { + const { abortRebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await abortRebase(directory); + res.json(result); + } catch (error) { + console.error('Failed to abort rebase:', error); + res.status(500).json({ error: error.message || 'Failed to abort rebase' }); + } + }); + + app.post('/api/git/merge', async (req, res) => { + const { merge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await merge(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to merge:', error); + res.status(500).json({ error: error.message || 'Failed to merge' }); + } + }); + + app.post('/api/git/merge/abort', async (req, res) => { + const { abortMerge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await abortMerge(directory); + res.json(result); + } catch (error) { + console.error('Failed to abort merge:', error); + res.status(500).json({ error: error.message || 'Failed to abort merge' }); + } + }); + + app.post('/api/git/rebase/continue', async (req, res) => { + const { continueRebase } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await continueRebase(directory); + res.json(result); + } catch (error) { + console.error('Failed to continue rebase:', error); + res.status(500).json({ error: error.message || 'Failed to continue rebase' }); + } + }); + + app.post('/api/git/merge/continue', async (req, res) => { + const { continueMerge } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await continueMerge(directory); + res.json(result); + } catch (error) { + console.error('Failed to continue merge:', error); + res.status(500).json({ error: error.message || 'Failed to continue merge' }); + } + }); + + app.get('/api/git/conflict-details', async (req, res) => { + const { getConflictDetails } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await getConflictDetails(directory); + res.json(result); + } catch (error) { + console.error('Failed to get conflict details:', error); + res.status(500).json({ error: error.message || 'Failed to get conflict details' }); + } + }); + + app.post('/api/git/stash', async (req, res) => { + const { stash } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await stash(directory, req.body); + res.json(result); + } catch (error) { + console.error('Failed to stash:', error); + res.status(500).json({ error: error.message || 'Failed to stash' }); + } + }); + + app.post('/api/git/stash/pop', async (req, res) => { + const { stashPop } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await stashPop(directory); + res.json(result); + } catch (error) { + console.error('Failed to pop stash:', error); + res.status(500).json({ error: error.message || 'Failed to pop stash' }); + } + }); + + app.post('/api/git/commit', async (req, res) => { + const { commit } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { message, addAll, files } = req.body; + if (!message) { + return res.status(400).json({ error: 'message is required' }); + } + + const result = await commit(directory, message, { + addAll, + files, + }); + res.json(result); + } catch (error) { + console.error('Failed to commit:', error); + res.status(500).json({ error: error.message || 'Failed to create commit' }); + } + }); + + app.get('/api/git/branches', async (req, res) => { + const { getBranches } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const branches = await getBranches(directory); + res.json(branches); + } catch (error) { + console.error('Failed to get branches:', error); + res.status(500).json({ error: error.message || 'Failed to get branches' }); + } + }); + + app.post('/api/git/branches', async (req, res) => { + const { createBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { name, startPoint } = req.body; + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const result = await createBranch(directory, name, { startPoint }); + res.json(result); + } catch (error) { + console.error('Failed to create branch:', error); + res.status(500).json({ error: error.message || 'Failed to create branch' }); + } + }); + + app.delete('/api/git/branches', async (req, res) => { + const { deleteBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { branch, force } = req.body; + if (!branch) { + return res.status(400).json({ error: 'branch is required' }); + } + + const result = await deleteBranch(directory, branch, { force }); + res.json(result); + } catch (error) { + console.error('Failed to delete branch:', error); + res.status(500).json({ error: error.message || 'Failed to delete branch' }); + } + }); + + + app.put('/api/git/branches/rename', async (req, res) => { + const { renameBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { oldName, newName } = req.body; + if (!oldName) { + return res.status(400).json({ error: 'oldName is required' }); + } + if (!newName) { + return res.status(400).json({ error: 'newName is required' }); + } + + const result = await renameBranch(directory, oldName, newName); + res.json(result); + } catch (error) { + console.error('Failed to rename branch:', error); + res.status(500).json({ error: error.message || 'Failed to rename branch' }); + } + }); + app.delete('/api/git/remote-branches', async (req, res) => { + const { deleteRemoteBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { branch, remote } = req.body; + if (!branch) { + return res.status(400).json({ error: 'branch is required' }); + } + + const result = await deleteRemoteBranch(directory, { branch, remote }); + res.json(result); + } catch (error) { + console.error('Failed to delete remote branch:', error); + res.status(500).json({ error: error.message || 'Failed to delete remote branch' }); + } + }); + + app.post('/api/git/checkout', async (req, res) => { + const { checkoutBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { branch } = req.body; + if (!branch) { + return res.status(400).json({ error: 'branch is required' }); + } + + const result = await checkoutBranch(directory, branch); + res.json(result); + } catch (error) { + console.error('Failed to checkout branch:', error); + res.status(500).json({ error: error.message || 'Failed to checkout branch' }); + } + }); + + app.get('/api/git/worktrees', async (req, res) => { + const { getWorktrees } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const worktrees = await getWorktrees(directory); + res.json(worktrees); + } catch (error) { + // Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries) + // when the directory isn't a git repo or uses shell shorthand like "~/". + console.warn('Failed to get worktrees, returning empty list:', error?.message || error); + res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable'); + res.json([]); + } + }); + + app.post('/api/git/worktrees/validate', async (req, res) => { + const { validateWorktreeCreate } = await getGitLibraries(); + if (typeof validateWorktreeCreate !== 'function') { + return res.status(501).json({ error: 'Worktree validation is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await validateWorktreeCreate(directory, req.body || {}); + res.json(result); + } catch (error) { + console.error('Failed to validate worktree creation:', error); + res.status(500).json({ error: error.message || 'Failed to validate worktree creation' }); + } + }); + + app.post('/api/git/worktrees', async (req, res) => { + const { createWorktree } = await getGitLibraries(); + if (typeof createWorktree !== 'function') { + return res.status(501).json({ error: 'Worktree creation is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const created = await createWorktree(directory, req.body || {}); + res.json(created); + } catch (error) { + console.error('Failed to create worktree:', error); + res.status(500).json({ error: error.message || 'Failed to create worktree' }); + } + }); + + app.post('/api/git/worktrees/preview', async (req, res) => { + const { previewWorktreeCreate } = await getGitLibraries(); + if (typeof previewWorktreeCreate !== 'function') { + return res.status(501).json({ error: 'Worktree preview is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const preview = await previewWorktreeCreate(directory, req.body || {}); + res.json(preview); + } catch (error) { + console.error('Failed to preview worktree:', error); + res.status(500).json({ error: error.message || 'Failed to preview worktree' }); + } + }); + + app.get('/api/git/worktrees/bootstrap-status', async (req, res) => { + const { getWorktreeBootstrapStatus } = await getGitLibraries(); + if (typeof getWorktreeBootstrapStatus !== 'function') { + return res.status(501).json({ error: 'Worktree bootstrap status is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const status = await getWorktreeBootstrapStatus(directory); + res.json(status); + } catch (error) { + console.error('Failed to get worktree bootstrap status:', error); + res.status(500).json({ error: error.message || 'Failed to get worktree bootstrap status' }); + } + }); + + app.delete('/api/git/worktrees', async (req, res) => { + const { removeWorktree } = await getGitLibraries(); + if (typeof removeWorktree !== 'function') { + return res.status(501).json({ error: 'Worktree removal is not available' }); + } + + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : ''; + if (!worktreeDirectory) { + return res.status(400).json({ error: 'worktree directory is required' }); + } + + const result = await removeWorktree(directory, { + directory: worktreeDirectory, + deleteLocalBranch: req.body?.deleteLocalBranch === true, + }); + res.json({ success: Boolean(result) }); + } catch (error) { + console.error('Failed to remove worktree:', error); + res.status(500).json({ error: error.message || 'Failed to remove worktree' }); + } + }); + + app.get('/api/git/worktree-type', async (req, res) => { + const { isLinkedWorktree } = await getGitLibraries(); + try { + const { directory } = req.query; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + const linked = await isLinkedWorktree(directory); + res.json({ linked }); + } catch (error) { + console.error('Failed to determine worktree type:', error); + res.status(500).json({ error: error.message || 'Failed to determine worktree type' }); + } + }); + + app.get('/api/git/log', async (req, res) => { + const { getLog } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { maxCount, from, to, file } = req.query; + const log = await getLog(directory, { + maxCount: maxCount ? parseInt(maxCount) : undefined, + from, + to, + file + }); + res.json(log); + } catch (error) { + console.error('Failed to get log:', error); + res.status(500).json({ error: error.message || 'Failed to get commit log' }); + } + }); + + app.get('/api/git/commit-files', async (req, res) => { + const { getCommitFiles } = await getGitLibraries(); + try { + const { directory, hash } = req.query; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + if (!hash) { + return res.status(400).json({ error: 'hash parameter is required' }); + } + + const result = await getCommitFiles(directory, hash); + res.json(result); + } catch (error) { + console.error('Failed to get commit files:', error); + res.status(500).json({ error: error.message || 'Failed to get commit files' }); + } + }); + +} diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index fa3e753d..0496a465 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -820,6 +820,25 @@ const getProjectStoragePath = (projectID) => { return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`); }; +const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => { + try { + const { Database } = require('bun:sqlite'); + const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db'); + if (!fs.existsSync(dbPath)) return; + const db = new Database(dbPath); + try { + const row = db.query('SELECT sandboxes FROM project WHERE id = ?').get(projectID); + if (!row) return; + const json = JSON.stringify(sandboxes); + db.query('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID); + } finally { + db.close(); + } + } catch (error) { + console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error)); + } +}; + const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => { const storagePath = getProjectStoragePath(projectID); await fsp.mkdir(path.dirname(storagePath), { recursive: true }); @@ -859,6 +878,9 @@ const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => { )]; await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); + + // Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK + syncSandboxesToOpenCodeDb(projectID, current.sandboxes); }; const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => { @@ -1187,18 +1209,22 @@ export async function setLocalIdentity(directory, profile) { } } -export async function getStatus(directory) { +export async function getStatus(directory, options = {}) { const directoryPath = normalizeDirectoryPath(directory); const git = await createGit(directoryPath); + const lightMode = options.mode === 'light'; try { // Use -uall to show all untracked files individually, not just directories const status = await git.status(['-uall']); - const [stagedStatsRaw, workingStatsRaw] = await Promise.all([ - git.raw(['diff', '--cached', '--numstat']).catch(() => ''), - git.raw(['diff', '--numstat']).catch(() => ''), - ]); + // Light mode: skip numstat + new-file line counting for faster response + const [stagedStatsRaw, workingStatsRaw] = lightMode + ? ['', ''] + : await Promise.all([ + git.raw(['diff', '--cached', '--numstat']).catch(() => ''), + git.raw(['diff', '--numstat']).catch(() => ''), + ]); const diffStatsMap = new Map(); @@ -1234,7 +1260,7 @@ export async function getStatus(directory) { const diffStats = Object.fromEntries(diffStatsMap.entries()); - const newFileStats = await Promise.all( + const newFileStats = lightMode ? [] : await Promise.all( status.files.map(async (file) => { const working = (file.working_dir || '').trim(); const indexStatus = (file.index || '').trim(); @@ -1333,7 +1359,8 @@ export async function getStatus(directory) { // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind. // We still want to show the number of unpublished commits to the user. - if (!tracking && status.current) { + // Light mode skips this — the basic ahead/behind from git status is sufficient for polling. + if (!lightMode && !tracking && status.current) { const baseRef = await selectBaseRefForUnpublished(); if (baseRef) { const countRaw = await git @@ -1411,7 +1438,7 @@ export async function getStatus(directory) { working_dir: f.working_dir, })), isClean: status.isClean(), - diffStats, + diffStats: lightMode ? undefined : diffStats, mergeInProgress, rebaseInProgress, }; diff --git a/packages/web/server/lib/github/DOCUMENTATION.md b/packages/web/server/lib/github/DOCUMENTATION.md index 0248d410..461f52c2 100644 --- a/packages/web/server/lib/github/DOCUMENTATION.md +++ b/packages/web/server/lib/github/DOCUMENTATION.md @@ -8,6 +8,7 @@ ## Entrypoints and structure - `packages/web/server/lib/github/index.js`: public server entrypoint. +- `packages/web/server/lib/github/routes.js`: Express route registration for `/api/github/*` endpoints. - `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config. - `packages/web/server/lib/github/device-flow.js`: OAuth device flow. - `packages/web/server/lib/github/octokit.js`: Octokit factory for the current auth. diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 7af68a32..041058e3 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -296,7 +296,20 @@ const parseRepoFromApiUrl = (value) => { } }; +// Track repos where the GitHub Search API returned 403 (token lacks scope for that org) +const _searchApiDisabledRepos = new Map(); +const SEARCH_API_RETRY_MS = 5 * 60 * 1000; // retry after 5 minutes + const searchFallbackPr = async ({ octokit, branch, repoNames }) => { + // Build a repo key to check/store 403 status per-repo + const repoKey = [...repoNames].sort().join(',').toLowerCase(); + + // Skip if this repo set returned 403 recently + const disabledAt = _searchApiDisabledRepos.get(repoKey); + if (disabledAt && Date.now() - disabledAt < SEARCH_API_RETRY_MS) { + return null; + } + const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean)); for (const state of ['open', 'closed']) { @@ -306,8 +319,14 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => { q: `is:pr state:${state} head:${branch}`, per_page: 20, }); + // If we get here, search API works for this repo — clear the disabled flag + _searchApiDisabledRepos.delete(repoKey); } catch (error) { - if (error?.status === 403 || error?.status === 404) { + if (error?.status === 403) { + _searchApiDisabledRepos.set(repoKey, Date.now()); + return null; + } + if (error?.status === 404) { continue; } throw error; diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js new file mode 100644 index 00000000..45579f9c --- /dev/null +++ b/packages/web/server/lib/github/routes.js @@ -0,0 +1,1349 @@ +export function registerGitHubRoutes(app) { + let githubLibraries = null; + const getGitHubLibraries = async () => { + if (!githubLibraries) { + githubLibraries = await import('./index.js'); + } + return githubLibraries; + }; + + const getGitHubUserSummary = async (octokit) => { + const me = await octokit.rest.users.getAuthenticated(); + + let email = typeof me.data.email === 'string' ? me.data.email : null; + if (!email) { + try { + const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 }); + const list = Array.isArray(emails?.data) ? emails.data : []; + const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string'); + const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string'); + email = primaryVerified?.email || anyVerified?.email || null; + } catch { + // ignore (scope might be missing) + } + } + + return { + login: me.data.login, + id: me.data.id, + avatarUrl: me.data.avatar_url, + name: typeof me.data.name === 'string' ? me.data.name : null, + email, + }; + }; + + const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403; + const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404; + + app.get('/api/github/auth/status', async (_req, res) => { + try { + const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const auth = getGitHubAuth(); + const accounts = getGitHubAuthAccounts(); + if (!auth?.accessToken) { + return res.json({ connected: false, accounts }); + } + + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false, accounts }); + } + + let user = null; + try { + user = await getGitHubUserSummary(octokit); + } catch (error) { + if (isGitHubAuthInvalid(error)) { + clearGitHubAuth(); + return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); + } + } + + const fallback = auth.user; + const mergedUser = user || fallback; + + return res.json({ + connected: true, + user: mergedUser, + scope: auth.scope, + accounts, + }); + } catch (error) { + console.error('Failed to get GitHub auth status:', error); + return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' }); + } + }); + + app.post('/api/github/auth/start', async (_req, res) => { + try { + const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries(); + const clientId = getGitHubClientId(); + if (!clientId) { + return res.status(400).json({ + error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', + }); + } + + const scope = getGitHubScopes(); + + const payload = await startDeviceFlow({ + clientId, + scope, + }); + + return res.json({ + deviceCode: payload.device_code, + userCode: payload.user_code, + verificationUri: payload.verification_uri, + verificationUriComplete: payload.verification_uri_complete, + expiresIn: payload.expires_in, + interval: payload.interval, + scope, + }); + } catch (error) { + console.error('Failed to start GitHub device flow:', error); + return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' }); + } + }); + + app.post('/api/github/auth/complete', async (req, res) => { + try { + const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const clientId = getGitHubClientId(); + if (!clientId) { + return res.status(400).json({ + error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.', + }); + } + + const deviceCode = typeof req.body?.deviceCode === 'string' + ? req.body.deviceCode + : (typeof req.body?.device_code === 'string' ? req.body.device_code : ''); + + if (!deviceCode) { + return res.status(400).json({ error: 'deviceCode is required' }); + } + + const payload = await exchangeDeviceCode({ clientId, deviceCode }); + + if (payload?.error) { + return res.json({ + connected: false, + status: payload.error, + error: payload.error_description || payload.error, + }); + } + + const accessToken = payload?.access_token; + if (!accessToken) { + return res.status(500).json({ error: 'Missing access_token from GitHub' }); + } + + const { Octokit } = await import('@octokit/rest'); + const octokit = new Octokit({ auth: accessToken }); + const user = await getGitHubUserSummary(octokit); + + setGitHubAuth({ + accessToken, + scope: typeof payload.scope === 'string' ? payload.scope : '', + tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer', + user, + }); + + return res.json({ + connected: true, + user, + scope: typeof payload.scope === 'string' ? payload.scope : '', + accounts: getGitHubAuthAccounts(), + }); + } catch (error) { + console.error('Failed to complete GitHub device flow:', error); + return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' }); + } + }); + + app.post('/api/github/auth/activate', async (req, res) => { + try { + const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : ''; + if (!accountId) { + return res.status(400).json({ error: 'accountId is required' }); + } + const activated = activateGitHubAuth(accountId); + if (!activated) { + return res.status(404).json({ error: 'GitHub account not found' }); + } + + const auth = getGitHubAuth(); + const accounts = getGitHubAuthAccounts(); + if (!auth?.accessToken) { + return res.json({ connected: false, accounts }); + } + + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false, accounts }); + } + + let user = auth.user || null; + try { + user = await getGitHubUserSummary(octokit); + } catch (error) { + if (isGitHubAuthInvalid(error)) { + clearGitHubAuth(); + return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); + } + } + + return res.json({ + connected: true, + user, + scope: auth.scope, + accounts, + }); + } catch (error) { + console.error('Failed to activate GitHub account:', error); + return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' }); + } + }); + + app.delete('/api/github/auth', async (_req, res) => { + try { + const { clearGitHubAuth } = await getGitHubLibraries(); + const removed = clearGitHubAuth(); + return res.json({ success: true, removed }); + } catch (error) { + console.error('Failed to disconnect GitHub:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' }); + } + }); + + app.get('/api/github/me', async (_req, res) => { + try { + const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + let user; + try { + user = await getGitHubUserSummary(octokit); + } catch (error) { + if (isGitHubAuthInvalid(error)) { + clearGitHubAuth(); + return res.status(401).json({ error: 'GitHub token expired or revoked' }); + } + throw error; + } + return res.json(user); + } catch (error) { + console.error('Failed to fetch GitHub user:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' }); + } + }); + + // ================= GitHub PR APIs ================= + + app.get('/api/github/pr/status', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : ''; + const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin'; + if (!directory || !branch) { + return res.status(400).json({ error: 'directory and branch are required' }); + } + + const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubPrStatus } = await import('./pr-status.js'); + const resolvedStatus = await resolveGitHubPrStatus({ + octokit, + directory, + branch, + remoteName: remote, + }); + const searchRepo = resolvedStatus.repo; + const first = resolvedStatus.pr; + if (!searchRepo) { + return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null }); + } + if (!first) { + return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null }); + } + + // Enrich with mergeability fields + const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number }); + const prData = prFull?.data; + if (!prData) { + return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false }); + } + + // Checks summary: prefer check-runs (Actions), fallback to classic statuses. + let checks = null; + const sha = prData.head?.sha; + if (sha) { + try { + const runs = await octokit.rest.checks.listForRef({ + owner: searchRepo.owner, + repo: searchRepo.repo, + ref: sha, + per_page: 100, + }); + const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; + if (checkRuns.length > 0) { + const counts = { success: 0, failure: 0, pending: 0 }; + for (const run of checkRuns) { + const status = run?.status; + const conclusion = run?.conclusion; + if (status === 'queued' || status === 'in_progress') { + counts.pending += 1; + continue; + } + if (!conclusion) { + counts.pending += 1; + continue; + } + if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + counts.success += 1; + } else { + counts.failure += 1; + } + } + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 + ? 'failure' + : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } + } catch { + // ignore and fall back + } + + if (!checks) { + try { + const combined = await octokit.rest.repos.getCombinedStatusForRef({ + owner: searchRepo.owner, + repo: searchRepo.repo, + ref: sha, + }); + const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; + const counts = { success: 0, failure: 0, pending: 0 }; + statuses.forEach((s) => { + if (s.state === 'success') counts.success += 1; + else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; + else if (s.state === 'pending') counts.pending += 1; + }); + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 + ? 'failure' + : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } catch { + checks = null; + } + } + } + + // Permission check (best-effort) + let canMerge = false; + try { + const auth = getGitHubAuth(); + const username = auth?.user?.login; + if (username) { + const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({ + owner: searchRepo.owner, + repo: searchRepo.repo, + username, + }); + const level = perm?.data?.permission; + canMerge = level === 'admin' || level === 'maintain' || level === 'write'; + } + } catch { + canMerge = false; + } + + const isMerged = Boolean(prData.merged || prData.merged_at); + const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); + + return res.json({ + connected: true, + repo: searchRepo, + branch, + pr: { + number: prData.number, + title: prData.title, + body: prData.body || '', + url: prData.html_url, + state: mergedState, + draft: Boolean(prData.draft), + base: prData.base?.ref, + head: prData.head?.ref, + headSha: prData.head?.sha, + mergeable: prData.mergeable, + mergeableState: prData.mergeable_state, + }, + checks, + canMerge, + defaultBranch: resolvedStatus.defaultBranch ?? null, + resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null, + }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + if (isGitHubResourceUnavailable(error)) { + return res.json({ + connected: true, + repo: null, + branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '', + pr: null, + checks: null, + canMerge: false, + defaultBranch: null, + resolvedRemoteName: null, + }); + } + console.error('Failed to load GitHub PR status:', error); + return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' }); + } + }); + + app.post('/api/github/pr/create', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; + const head = typeof req.body?.head === 'string' ? req.body.head.trim() : ''; + const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : ''; + const body = typeof req.body?.body === 'string' ? req.body.body : undefined; + const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined; + // remote = target repo (where PR is created, e.g., 'upstream' for forks) + const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin'; + // headRemote = source repo (where head branch lives, e.g., 'origin' for forks) + const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : ''; + if (!directory || !title || !head || !requestedBase) { + return res.status(400).json({ error: 'directory, title, head, base are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory, remote); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + const normalizeBranchRef = (value, remoteNames = new Set()) => { + if (!value) { + return value; + } + let normalized = value.trim(); + if (normalized.startsWith('refs/heads/')) { + normalized = normalized.substring('refs/heads/'.length); + } + if (normalized.startsWith('heads/')) { + normalized = normalized.substring('heads/'.length); + } + if (normalized.startsWith('remotes/')) { + normalized = normalized.substring('remotes/'.length); + } + + const slashIndex = normalized.indexOf('/'); + if (slashIndex > 0) { + const maybeRemote = normalized.slice(0, slashIndex); + if (remoteNames.has(maybeRemote)) { + const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim(); + if (withoutRemotePrefix) { + normalized = withoutRemotePrefix; + } + } + } + + return normalized; + }; + + // Determine the source remote for the head branch + // Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin + let sourceRemote = headRemote; + const { getStatus, getRemotes } = await import('../git/index.js'); + + // If no explicit headRemote, check the branch's tracking info + if (!sourceRemote) { + const status = await getStatus(directory).catch(() => null); + if (status?.tracking) { + // tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main" + const trackingRemote = status.tracking.split('/')[0]; + if (trackingRemote) { + sourceRemote = trackingRemote; + } + } + } + + // Fallback: if targeting non-origin and no tracking info, try 'origin' + if (!sourceRemote && remote !== 'origin') { + sourceRemote = 'origin'; + } + + const remoteNames = new Set([remote]); + const remotes = await getRemotes(directory).catch(() => []); + for (const item of remotes) { + if (item?.name) { + remoteNames.add(item.name); + } + } + if (sourceRemote) { + remoteNames.add(sourceRemote); + } + + const base = normalizeBranchRef(requestedBase, remoteNames); + if (!base) { + return res.status(400).json({ error: 'Invalid base branch name' }); + } + + // For fork workflows: we need to determine the correct head reference + let headRef = head; + + if (sourceRemote && sourceRemote !== remote) { + // The branch is on a different remote than the target - this is a cross-repo PR + const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote); + if (headRepo) { + // Always use owner:branch format for cross-repo PRs + // GitHub API requires this when head is from a different repo/fork + if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) { + headRef = `${headRepo.owner}:${head}`; + } + } + } + + // For cross-repo PRs, verify the branch exists on the head repo first + if (headRef.includes(':')) { + const [headOwner] = headRef.split(':'); + const headRepoName = sourceRemote + ? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo + : repo.repo; + + if (headRepoName) { + try { + await octokit.rest.repos.getBranch({ + owner: headOwner, + repo: headRepoName, + branch: head, + }); + } catch (branchError) { + if (branchError?.status === 404) { + return res.status(400).json({ + error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`, + }); + } + // For other errors, continue - let the PR create attempt handle it + } + } + } + + const created = await octokit.rest.pulls.create({ + owner: repo.owner, + repo: repo.repo, + title, + head: headRef, + base, + ...(typeof body === 'string' ? { body } : {}), + ...(typeof draft === 'boolean' ? { draft } : {}), + }); + + const pr = created?.data; + if (!pr) { + return res.status(500).json({ error: 'Failed to create PR' }); + } + + return res.json({ + number: pr.number, + title: pr.title, + body: pr.body || '', + url: pr.html_url, + state: pr.state === 'closed' ? 'closed' : 'open', + draft: Boolean(pr.draft), + base: pr.base?.ref, + head: pr.head?.ref, + headSha: pr.head?.sha, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state, + }); + } catch (error) { + console.error('Failed to create GitHub PR:', error); + + // Check for head validation error (common with fork PRs) + const errorMessage = error.message || ''; + const isHeadValidationError = + errorMessage.includes('Validation Failed') && + errorMessage.includes('"field":"head"') && + errorMessage.includes('"code":"invalid"'); + + if (isHeadValidationError) { + return res.status(400).json({ + error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.' + }); + } + + return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' }); + } + }); + + app.post('/api/github/pr/update', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const number = typeof req.body?.number === 'number' ? req.body.number : null; + const title = typeof req.body?.title === 'string' ? req.body.title.trim() : ''; + const body = typeof req.body?.body === 'string' ? req.body.body : undefined; + if (!directory || !number || !title) { + return res.status(400).json({ error: 'directory, number, title are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + let updated; + try { + updated = await octokit.rest.pulls.update({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + title, + ...(typeof body === 'string' ? { body } : {}), + }); + } catch (error) { + if (error?.status === 401) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + if (error?.status === 403) { + return res.status(403).json({ error: 'Not authorized to edit this PR' }); + } + if (error?.status === 404) { + return res.status(404).json({ error: 'PR not found in this repository' }); + } + if (error?.status === 422) { + const apiMessage = error?.response?.data?.message; + const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0 + ? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code) + : null; + const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload'; + return res.status(422).json({ error: message }); + } + throw error; + } + + const pr = updated?.data; + if (!pr) { + return res.status(500).json({ error: 'Failed to update PR' }); + } + + return res.json({ + number: pr.number, + title: pr.title, + body: pr.body || '', + url: pr.html_url, + state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'), + draft: Boolean(pr.draft), + base: pr.base?.ref, + head: pr.head?.ref, + headSha: pr.head?.sha, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state, + }); + } catch (error) { + console.error('Failed to update GitHub PR:', error); + return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' }); + } + }); + + app.post('/api/github/pr/merge', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const number = typeof req.body?.number === 'number' ? req.body.number : null; + const method = typeof req.body?.method === 'string' ? req.body.method : 'merge'; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + try { + const result = await octokit.rest.pulls.merge({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + merge_method: method, + }); + return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message }); + } catch (error) { + if (error?.status === 403) { + return res.status(403).json({ error: 'Not authorized to merge this PR' }); + } + if (error?.status === 405 || error?.status === 409) { + return res.json({ merged: false, message: error?.message || 'PR not mergeable' }); + } + throw error; + } + } catch (error) { + console.error('Failed to merge GitHub PR:', error); + return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' }); + } + }); + + app.post('/api/github/pr/ready', async (req, res) => { + try { + const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : ''; + const number = typeof req.body?.number === 'number' ? req.body.number : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.status(401).json({ error: 'GitHub not connected' }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); + } + + const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); + const nodeId = pr?.data?.node_id; + if (!nodeId) { + return res.status(500).json({ error: 'Failed to resolve PR node id' }); + } + + if (pr?.data?.draft === false) { + return res.json({ ready: true }); + } + + try { + await octokit.graphql( + `mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`, + { pullRequestId: nodeId } + ); + } catch (error) { + if (error?.status === 403) { + return res.status(403).json({ error: 'Not authorized to mark PR ready' }); + } + throw error; + } + + return res.json({ ready: true }); + } catch (error) { + console.error('Failed to mark PR ready:', error); + return res.status(500).json({ error: error.message || 'Failed to mark PR ready' }); + } + }); + + // ================= GitHub Issue APIs ================= + + app.get('/api/github/issues/list', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; + if (!directory) { + return res.status(400).json({ error: 'directory is required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, issues: [] }); + } + + const list = await octokit.rest.issues.listForRepo({ + owner: repo.owner, + repo: repo.repo, + state: 'open', + per_page: 50, + page: Number.isFinite(page) && page > 0 ? page : 1, + }); + const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; + const hasMore = /rel="next"/.test(link); + const issues = (Array.isArray(list?.data) ? list.data : []) + .filter((item) => !item?.pull_request) + .map((item) => ({ + number: item.number, + title: item.title, + url: item.html_url, + state: item.state === 'closed' ? 'closed' : 'open', + author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null, + labels: Array.isArray(item.labels) + ? item.labels + .map((label) => { + if (typeof label === 'string') return null; + const name = typeof label?.name === 'string' ? label.name : ''; + if (!name) return null; + return { name, color: typeof label?.color === 'string' ? label.color : undefined }; + }) + .filter(Boolean) + : [], + })); + + return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); + } catch (error) { + console.error('Failed to list GitHub issues:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' }); + } + }); + + app.get('/api/github/issues/get', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, issue: null }); + } + + const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number }); + const issue = result?.data; + if (!issue || issue.pull_request) { + return res.status(400).json({ error: 'Not a GitHub issue' }); + } + + return res.json({ + connected: true, + repo, + issue: { + number: issue.number, + title: issue.title, + url: issue.html_url, + state: issue.state === 'closed' ? 'closed' : 'open', + body: issue.body || '', + createdAt: issue.created_at, + updatedAt: issue.updated_at, + author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null, + assignees: Array.isArray(issue.assignees) + ? issue.assignees + .map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null)) + .filter(Boolean) + : [], + labels: Array.isArray(issue.labels) + ? issue.labels + .map((label) => { + if (typeof label === 'string') return null; + const name = typeof label?.name === 'string' ? label.name : ''; + if (!name) return null; + return { name, color: typeof label?.color === 'string' ? label.color : undefined }; + }) + .filter(Boolean) + : [], + }, + }); + } catch (error) { + console.error('Failed to fetch GitHub issue:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' }); + } + }); + + app.get('/api/github/issues/comments', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, comments: [] }); + } + + const result = await octokit.rest.issues.listComments({ + owner: repo.owner, + repo: repo.repo, + issue_number: number, + per_page: 100, + }); + const comments = (Array.isArray(result?.data) ? result.data : []) + .map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + return res.json({ connected: true, repo, comments }); + } catch (error) { + console.error('Failed to fetch GitHub issue comments:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' }); + } + }); + + // ================= GitHub Pull Request Context APIs ================= + + app.get('/api/github/pulls/list', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; + if (!directory) { + return res.status(400).json({ error: 'directory is required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, prs: [] }); + } + + const list = await octokit.rest.pulls.list({ + owner: repo.owner, + repo: repo.repo, + state: 'open', + per_page: 50, + page: Number.isFinite(page) && page > 0 ? page : 1, + }); + + const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; + const hasMore = /rel="next"/.test(link); + + const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => { + const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'); + const headRepo = pr.head?.repo + ? { + owner: pr.head.repo.owner?.login, + repo: pr.head.repo.name, + url: pr.head.repo.html_url, + cloneUrl: pr.head.repo.clone_url, + sshUrl: pr.head.repo.ssh_url, + } + : null; + return { + number: pr.number, + title: pr.title, + url: pr.html_url, + state: mergedState, + draft: Boolean(pr.draft), + base: pr.base?.ref, + head: pr.head?.ref, + headSha: pr.head?.sha, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state, + author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null, + headLabel: pr.head?.label, + headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url + ? headRepo + : null, + }; + }); + + return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + console.error('Failed to list GitHub PRs:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' }); + } + }); + + app.get('/api/github/pulls/context', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; + const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true'; + const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true'; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, pr: null }); + } + + const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); + const prData = prResp?.data; + if (!prData) { + return res.status(404).json({ error: 'PR not found' }); + } + + const headRepo = prData.head?.repo + ? { + owner: prData.head.repo.owner?.login, + repo: prData.head.repo.name, + url: prData.head.repo.html_url, + cloneUrl: prData.head.repo.clone_url, + sshUrl: prData.head.repo.ssh_url, + } + : null; + + const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); + const pr = { + number: prData.number, + title: prData.title, + url: prData.html_url, + state: mergedState, + draft: Boolean(prData.draft), + base: prData.base?.ref, + head: prData.head?.ref, + headSha: prData.head?.sha, + mergeable: prData.mergeable, + mergeableState: prData.mergeable_state, + author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null, + headLabel: prData.head?.label, + headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null, + body: prData.body || '', + createdAt: prData.created_at, + updatedAt: prData.updated_at, + }; + + const issueCommentsResp = await octokit.rest.issues.listComments({ + owner: repo.owner, + repo: repo.repo, + issue_number: number, + per_page: 100, + }); + const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + per_page: 100, + }); + const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + path: comment.path, + line: typeof comment.line === 'number' ? comment.line : null, + position: typeof comment.position === 'number' ? comment.position : null, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + const filesResp = await octokit.rest.pulls.listFiles({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + per_page: 100, + }); + const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + patch: f.patch, + })); + + // checks summary (same logic as status endpoint) + let checks = null; + let checkRunsOut = undefined; + const sha = prData.head?.sha; + if (sha) { + try { + const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 }); + const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; + if (checkRuns.length > 0) { + const parsedJobs = new Map(); + const parsedAnnotations = new Map(); + if (includeCheckDetails) { + // Prefetch actions jobs per runId. + const runIds = new Set(); + const jobIds = new Map(); + for (const run of checkRuns) { + const details = typeof run.details_url === 'string' ? run.details_url : ''; + const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + if (match) { + const runId = Number(match[1]); + const jobId = match[2] ? Number(match[2]) : null; + if (Number.isFinite(runId) && runId > 0) { + runIds.add(runId); + if (jobId && Number.isFinite(jobId) && jobId > 0) { + jobIds.set(details, { runId, jobId }); + } else { + jobIds.set(details, { runId, jobId: null }); + } + } + } + } + + for (const runId of runIds) { + try { + const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({ + owner: repo.owner, + repo: repo.repo, + run_id: runId, + per_page: 100, + }); + const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : []; + parsedJobs.set(runId, jobs); + } catch { + parsedJobs.set(runId, []); + } + } + + for (const run of checkRuns) { + const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : ''; + const shouldLoadAnnotations = Boolean( + run?.id + && runConclusion + && !['success', 'neutral', 'skipped'].includes(runConclusion) + ); + if (!shouldLoadAnnotations) { + continue; + } + + const checkRunId = Number(run.id); + if (!Number.isFinite(checkRunId) || checkRunId <= 0) { + continue; + } + + const annotations = []; + for (let page = 1; page <= 3; page += 1) { + try { + const annotationsResp = await octokit.rest.checks.listAnnotations({ + owner: repo.owner, + repo: repo.repo, + check_run_id: checkRunId, + per_page: 50, + page, + }); + const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : []; + annotations.push(...chunk); + if (chunk.length < 50) { + break; + } + } catch { + break; + } + } + + if (annotations.length > 0) { + parsedAnnotations.set(checkRunId, annotations); + } + } + } + + checkRunsOut = checkRuns.map((run) => { + const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined; + let job = undefined; + if (includeCheckDetails && detailsUrl) { + const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + const runId = match ? Number(match[1]) : null; + const jobId = match && match[2] ? Number(match[2]) : null; + if (runId && Number.isFinite(runId)) { + const jobs = parsedJobs.get(runId) || []; + const matched = jobId + ? jobs.find((j) => j.id === jobId) + : null; + const picked = matched || jobs.find((j) => j.name === run.name) || null; + if (picked) { + job = { + runId, + jobId: picked.id, + url: picked.html_url, + name: picked.name, + conclusion: picked.conclusion, + steps: Array.isArray(picked.steps) + ? picked.steps.map((s) => ({ + name: s.name, + status: s.status, + conclusion: s.conclusion, + number: s.number, + startedAt: s.started_at || undefined, + completedAt: s.completed_at || undefined, + })) + : undefined, + }; + } else { + job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl }; + } + } + } + + return { + id: run.id, + name: run.name, + app: run.app + ? { + name: run.app.name || undefined, + slug: run.app.slug || undefined, + } + : undefined, + status: run.status, + conclusion: run.conclusion, + detailsUrl, + output: run.output + ? { + title: run.output.title || undefined, + summary: run.output.summary || undefined, + text: run.output.text || undefined, + } + : undefined, + ...(job ? { job } : {}), + ...(run.id && parsedAnnotations.has(run.id) + ? { + annotations: parsedAnnotations.get(run.id).map((a) => ({ + path: a.path || undefined, + startLine: typeof a.start_line === 'number' ? a.start_line : undefined, + endLine: typeof a.end_line === 'number' ? a.end_line : undefined, + level: a.annotation_level || undefined, + message: a.message || '', + title: a.title || undefined, + rawDetails: a.raw_details || undefined, + })).filter((a) => a.message), + } + : {}), + }; + }); + const counts = { success: 0, failure: 0, pending: 0 }; + for (const run of checkRuns) { + const status = run?.status; + const conclusion = run?.conclusion; + if (status === 'queued' || status === 'in_progress') { + counts.pending += 1; + continue; + } + if (!conclusion) { + counts.pending += 1; + continue; + } + if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + counts.success += 1; + } else { + counts.failure += 1; + } + } + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } + } catch { + // ignore and fall back + } + if (!checks) { + try { + const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha }); + const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; + const counts = { success: 0, failure: 0, pending: 0 }; + statuses.forEach((s) => { + if (s.state === 'success') counts.success += 1; + else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; + else if (s.state === 'pending') counts.pending += 1; + }); + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } catch { + checks = null; + } + } + } + + let diff = undefined; + if (includeDiff) { + const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner: repo.owner, + repo: repo.repo, + pull_number: number, + headers: { accept: 'application/vnd.github.v3.diff' }, + }); + diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined; + } + + return res.json({ + connected: true, + repo, + pr, + issueComments, + reviewComments, + files, + ...(diff ? { diff } : {}), + checks, + ...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}), + }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + console.error('Failed to load GitHub PR context:', error); + return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' }); + } + }); +} diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index 0d0873d7..27ea3a96 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -5,6 +5,11 @@ This module provides notification message preparation utilities for the web serv ## Entrypoints and structure - `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`. +- `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints. +- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime. +- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime. +- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout. +- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables, zen-model helpers, and session text/title enrichment runtime. - `packages/web/server/lib/notifications/message.js`: helper implementation module. - `packages/web/server/lib/notifications/message.test.js`: unit tests for notification message helpers. @@ -14,6 +19,68 @@ This module provides notification message preparation utilities for the web serv - `truncateNotificationText(text, maxLength)`: Truncates text to specified max length, appending `...` if truncated. - `prepareNotificationLastMessage({ message, settings, summarize })`: Prepares the last message for notification display, with optional summarization support. +### Route registration API (routes.js) +- `registerNotificationRoutes(app, dependencies)`: Registers notification-owned endpoints: + - `GET /api/push/vapid-public-key` + - `POST /api/push/subscribe` + - `DELETE /api/push/subscribe` + - `POST /api/push/visibility` + - `GET /api/push/visibility` + - `GET /api/session-activity` + - `GET /api/sessions/snapshot` + - `GET /api/sessions/status` + - `GET /api/sessions/:id/status` + - `GET /api/sessions/attention` + - `GET /api/sessions/:id/attention` + - `POST /api/sessions/:id/view` + - `POST /api/sessions/:id/unview` + - `POST /api/sessions/:id/message-sent` + +### Trigger runtime API (runtime.js) +- `createNotificationTriggerRuntime(dependencies)`: creates runtime-owned debounced trigger handling for OpenCode events. +- Returned API: + - `maybeSendPushForTrigger(payload)` +- Owns: + - completion/error/question/permission trigger routing + - session parent cache for subtask suppression + - template resolution and fallback behavior + - native notification fanout and web push payload fanout + +### Push runtime API (push-runtime.js) +- `createPushRuntime(dependencies)`: creates runtime for web push and UI visibility state. +- Returned API: + - `getOrCreateVapidKeys()` + - `ensurePushInitialized()` + - `setPushInitialized(value)` + - `addOrUpdatePushSubscription(uiSessionToken, subscription, userAgent)` + - `removePushSubscription(uiSessionToken, endpoint)` + - `sendPushToAllUiSessions(payload, options?)` + - `updateUiVisibility(token, visible)` + - `isAnyUiVisible()` + - `isUiVisible(token)` + +### Emitter runtime API (emitter-runtime.js) +- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels. +- Returned API: + - `writeSseEvent(res, payload)` + - `emitDesktopNotification(payload)` + - `broadcastUiNotification(payload)` + +### Template runtime API (template-runtime.js) +- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template + zen helper runtime. +- Returned API: + - `resolveNotificationTemplate(template, variables)` + - `shouldApplyResolvedTemplateMessage(template, resolved, variables)` + - `fetchFreeZenModels()` + - `resolveZenModel(override)` + - `validateZenModelAtStartup()` + - `summarizeText(text, targetLength, zenModel)` + - `extractLastMessageText(payload, maxLength?)` + - `fetchLastAssistantMessageText(sessionId, messageId, maxLength?)` + - `maybeCacheSessionInfoFromEvent(payload)` + - `buildTemplateVariables(payload, sessionId)` + - `getCachedZenModels()` + ## Constants ### Default values diff --git a/packages/web/server/lib/notifications/emitter-runtime.js b/packages/web/server/lib/notifications/emitter-runtime.js new file mode 100644 index 00000000..2ffc9cfe --- /dev/null +++ b/packages/web/server/lib/notifications/emitter-runtime.js @@ -0,0 +1,65 @@ +export const createNotificationEmitterRuntime = (dependencies) => { + const { + process, + getDesktopNotifyEnabled, + desktopNotifyPrefix, + getUiNotificationClients, + } = dependencies; + + const writeSseEvent = (res, payload) => { + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + const emitDesktopNotification = (payload) => { + const desktopNotifyEnabled = getDesktopNotifyEnabled(); + if (!desktopNotifyEnabled) { + return; + } + + if (!payload || typeof payload !== 'object') { + return; + } + + try { + // One-line protocol consumed by the Tauri shell. + process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`); + } catch { + // ignore + } + }; + + const broadcastUiNotification = (payload) => { + const desktopNotifyEnabled = getDesktopNotifyEnabled(); + if (!payload || typeof payload !== 'object') { + return; + } + + const clients = getUiNotificationClients(); + if (clients.size === 0) { + return; + } + + for (const res of clients) { + try { + writeSseEvent(res, { + type: 'openchamber:notification', + properties: { + ...payload, + // Tell the UI whether the sidecar stdout notification channel is active. + // When true, the desktop UI should skip this SSE notification to avoid duplicates. + // When false (e.g. tauri dev), the UI must handle this SSE notification itself. + desktopStdoutActive: desktopNotifyEnabled, + }, + }); + } catch { + // ignore + } + } + }; + + return { + writeSseEvent, + emitDesktopNotification, + broadcastUiNotification, + }; +}; diff --git a/packages/web/server/lib/notifications/index.js b/packages/web/server/lib/notifications/index.js index fb5cecd4..49a8c25d 100644 --- a/packages/web/server/lib/notifications/index.js +++ b/packages/web/server/lib/notifications/index.js @@ -1 +1,4 @@ export { truncateNotificationText, prepareNotificationLastMessage } from './message.js'; +export { createNotificationTriggerRuntime } from './runtime.js'; +export { createPushRuntime } from './push-runtime.js'; +export { createNotificationTemplateRuntime } from './template-runtime.js'; diff --git a/packages/web/server/lib/notifications/push-runtime.js b/packages/web/server/lib/notifications/push-runtime.js new file mode 100644 index 00000000..2cdf1930 --- /dev/null +++ b/packages/web/server/lib/notifications/push-runtime.js @@ -0,0 +1,294 @@ +const PUSH_SUBSCRIPTIONS_VERSION = 1; + +export const createPushRuntime = (deps) => { + const { + fsPromises, + path, + webPush, + PUSH_SUBSCRIPTIONS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + } = deps; + + let persistPushSubscriptionsLock = Promise.resolve(); + let pushInitialized = false; + + const uiVisibilityByToken = new Map(); + let globalVisibilityState = false; + + const readPushSubscriptionsFromDisk = async () => { + try { + const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) { + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + + const subscriptionsBySession = + parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object' + ? parsed.subscriptionsBySession + : {}; + + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession }; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + console.warn('Failed to read push subscriptions file:', error); + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} }; + } + }; + + const writePushSubscriptionsToDisk = async (data) => { + await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8'); + }; + + const persistPushSubscriptionUpdate = async (mutate) => { + persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => { + await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true }); + const current = await readPushSubscriptionsFromDisk(); + const next = mutate({ + version: PUSH_SUBSCRIPTIONS_VERSION, + subscriptionsBySession: current.subscriptionsBySession || {}, + }); + await writePushSubscriptionsToDisk(next); + return next; + }); + + return persistPushSubscriptionsLock; + }; + + const getOrCreateVapidKeys = async () => { + const settings = await readSettingsFromDiskMigrated(); + const existing = settings?.vapidKeys; + if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') { + return { publicKey: existing.publicKey, privateKey: existing.privateKey }; + } + + const generated = webPush.generateVAPIDKeys(); + const next = { + ...settings, + vapidKeys: { + publicKey: generated.publicKey, + privateKey: generated.privateKey, + }, + }; + + await writeSettingsToDisk(next); + return { publicKey: generated.publicKey, privateKey: generated.privateKey }; + }; + + const normalizePushSubscriptions = (record) => { + if (!Array.isArray(record)) return []; + return record + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const endpoint = entry.endpoint; + const p256dh = entry.p256dh; + const auth = entry.auth; + if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') { + return null; + } + return { + endpoint, + p256dh, + auth, + createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, + }; + }) + .filter(Boolean); + }; + + const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => { + if (!uiSessionToken) { + return; + } + + await ensurePushInitialized(); + + const now = Date.now(); + + await persistPushSubscriptionUpdate((current) => { + const subsBySession = { ...(current.subscriptionsBySession || {}) }; + const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; + + const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint); + + filtered.unshift({ + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + createdAt: now, + lastSeenAt: now, + userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, + }); + + subsBySession[uiSessionToken] = filtered.slice(0, 10); + + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; + }); + }; + + const removePushSubscription = async (uiSessionToken, endpoint) => { + if (!uiSessionToken || !endpoint) return; + + await ensurePushInitialized(); + + await persistPushSubscriptionUpdate((current) => { + const subsBySession = { ...(current.subscriptionsBySession || {}) }; + const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : []; + const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); + if (filtered.length === 0) { + delete subsBySession[uiSessionToken]; + } else { + subsBySession[uiSessionToken] = filtered; + } + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; + }); + }; + + const removePushSubscriptionFromAllSessions = async (endpoint) => { + if (!endpoint) return; + + await ensurePushInitialized(); + + await persistPushSubscriptionUpdate((current) => { + const subsBySession = { ...(current.subscriptionsBySession || {}) }; + for (const [token, entries] of Object.entries(subsBySession)) { + if (!Array.isArray(entries)) continue; + const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint); + if (filtered.length === 0) { + delete subsBySession[token]; + } else { + subsBySession[token] = filtered; + } + } + return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession }; + }); + }; + + const sendPushToSubscription = async (sub, payload) => { + await ensurePushInitialized(); + const body = JSON.stringify(payload); + + const pushSubscription = { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + }; + + try { + await webPush.sendNotification(pushSubscription, body); + } catch (error) { + const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null; + if (statusCode === 410 || statusCode === 404) { + await removePushSubscriptionFromAllSessions(sub.endpoint); + return; + } + console.warn('[Push] Failed to send notification:', error); + } + }; + + const sendPushToAllUiSessions = async (payload, options = {}) => { + const requireNoSse = options.requireNoSse === true; + const store = await readPushSubscriptionsFromDisk(); + const sessions = store.subscriptionsBySession || {}; + const subscriptionsByEndpoint = new Map(); + + for (const record of Object.values(sessions)) { + const subscriptions = normalizePushSubscriptions(record); + if (subscriptions.length === 0) continue; + + for (const sub of subscriptions) { + if (!subscriptionsByEndpoint.has(sub.endpoint)) { + subscriptionsByEndpoint.set(sub.endpoint, sub); + } + } + } + + await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => { + if (requireNoSse && isAnyUiVisible()) { + return; + } + await sendPushToSubscription(sub, payload); + })); + }; + + const updateUiVisibility = (token, visible) => { + if (!token) return; + const now = Date.now(); + const nextVisible = Boolean(visible); + uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now }); + globalVisibilityState = nextVisible; + }; + + const isAnyUiVisible = () => globalVisibilityState === true; + + const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true; + + const resolveVapidSubject = async () => { + const configured = process.env.OPENCHAMBER_VAPID_SUBJECT; + if (typeof configured === 'string' && configured.trim().length > 0) { + return configured.trim(); + } + + const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN; + if (typeof originEnv === 'string' && originEnv.trim().length > 0) { + const trimmed = originEnv.trim(); + if (trimmed.startsWith('http://localhost')) { + return 'mailto:openchamber@localhost'; + } + return trimmed; + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const stored = settings?.publicOrigin; + if (typeof stored === 'string' && stored.trim().length > 0) { + const trimmed = stored.trim(); + if (trimmed.startsWith('http://localhost')) { + return 'mailto:openchamber@localhost'; + } + return trimmed; + } + } catch { + } + + return 'mailto:openchamber@localhost'; + }; + + const ensurePushInitialized = async () => { + if (pushInitialized) return; + const keys = await getOrCreateVapidKeys(); + const subject = await resolveVapidSubject(); + + if (subject === 'mailto:openchamber@localhost') { + console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.'); + } + + webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey); + pushInitialized = true; + }; + + const setPushInitialized = (value) => { + pushInitialized = value === true; + }; + + return { + getOrCreateVapidKeys, + addOrUpdatePushSubscription, + removePushSubscription, + sendPushToAllUiSessions, + updateUiVisibility, + isAnyUiVisible, + isUiVisible, + ensurePushInitialized, + setPushInitialized, + }; +}; diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js new file mode 100644 index 00000000..7be9883c --- /dev/null +++ b/packages/web/server/lib/notifications/routes.js @@ -0,0 +1,247 @@ +const parsePushSubscribeBody = (body) => { + if (!body || typeof body !== 'object') return null; + const endpoint = body.endpoint; + const keys = body.keys; + const p256dh = keys?.p256dh; + const auth = keys?.auth; + + if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; + if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null; + if (typeof auth !== 'string' || auth.trim().length === 0) return null; + + return { + endpoint: endpoint.trim(), + keys: { p256dh: p256dh.trim(), auth: auth.trim() }, + }; +}; + +const parsePushUnsubscribeBody = (body) => { + if (!body || typeof body !== 'object') return null; + const endpoint = body.endpoint; + if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null; + return { endpoint: endpoint.trim() }; +}; + +export const registerNotificationRoutes = (app, dependencies) => { + const { + uiAuthController, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + getSessionActivitySnapshot, + getSessionStateSnapshot, + getSessionAttentionSnapshot, + getSessionState, + getSessionAttentionState, + markSessionViewed, + markSessionUnviewed, + markUserMessageSent, + setPushInitialized, + } = dependencies; + + app.get('/api/push/vapid-public-key', async (_req, res) => { + try { + await ensurePushInitialized(); + const keys = await getOrCreateVapidKeys(); + res.json({ publicKey: keys.publicKey }); + } catch (error) { + console.warn('[Push] Failed to load VAPID key:', error); + res.status(500).json({ error: 'Failed to load push key' }); + } + }); + + app.post('/api/push/subscribe', async (req, res) => { + await ensurePushInitialized(); + + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const parsed = parsePushSubscribeBody(req.body); + if (!parsed) { + return res.status(400).json({ error: 'Invalid body' }); + } + + const { endpoint, keys } = parsed; + + const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : ''; + if (origin.startsWith('http://') || origin.startsWith('https://')) { + try { + const settings = await readSettingsFromDiskMigrated(); + if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) { + await writeSettingsToDisk({ + ...settings, + publicOrigin: origin, + }); + setPushInitialized(false); + } + } catch { + } + } + + await addOrUpdatePushSubscription( + uiToken, + { + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + }, + req.headers['user-agent'] + ); + + return res.json({ ok: true }); + }); + + app.delete('/api/push/subscribe', async (req, res) => { + await ensurePushInitialized(); + + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const parsed = parsePushUnsubscribeBody(req.body); + if (!parsed) { + return res.status(400).json({ error: 'Invalid body' }); + } + + await removePushSubscription(uiToken, parsed.endpoint); + return res.json({ ok: true }); + }); + + app.post('/api/push/visibility', async (req, res) => { + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const visible = req.body && typeof req.body === 'object' ? req.body.visible : null; + updateUiVisibility(uiToken, visible === true); + return res.json({ ok: true }); + }); + + app.get('/api/push/visibility', (req, res) => { + const uiToken = getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + return res.json({ + ok: true, + visible: isUiVisible(uiToken), + }); + }); + + app.get('/api/session-activity', (_req, res) => { + res.json(getSessionActivitySnapshot()); + }); + + app.get('/api/sessions/snapshot', (_req, res) => { + res.json({ + statusSessions: getSessionStateSnapshot(), + attentionSessions: getSessionAttentionSnapshot(), + serverTime: Date.now(), + }); + }); + + app.get('/api/sessions/status', (_req, res) => { + const snapshot = getSessionStateSnapshot(); + res.json({ + sessions: snapshot, + serverTime: Date.now(), + }); + }); + + app.get('/api/sessions/:id/status', (req, res) => { + const sessionId = req.params.id; + const state = getSessionState(sessionId); + + if (!state) { + return res.status(404).json({ + error: 'Session not found or no state available', + sessionId, + }); + } + + return res.json({ + sessionId, + ...state, + }); + }); + + app.get('/api/sessions/attention', (_req, res) => { + const snapshot = getSessionAttentionSnapshot(); + res.json({ + sessions: snapshot, + serverTime: Date.now(), + }); + }); + + app.get('/api/sessions/:id/attention', (req, res) => { + const sessionId = req.params.id; + const state = getSessionAttentionState(sessionId); + + if (!state) { + return res.status(404).json({ + error: 'Session not found or no attention state available', + sessionId, + }); + } + + return res.json({ + sessionId, + ...state, + }); + }); + + app.post('/api/sessions/:id/view', (req, res) => { + const sessionId = req.params.id; + const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; + + markSessionViewed(sessionId, clientId); + + return res.json({ + success: true, + sessionId, + viewed: true, + }); + }); + + app.post('/api/sessions/:id/unview', (req, res) => { + const sessionId = req.params.id; + const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; + + markSessionUnviewed(sessionId, clientId); + + return res.json({ + success: true, + sessionId, + viewed: false, + }); + }); + + app.post('/api/sessions/:id/message-sent', (req, res) => { + const sessionId = req.params.id; + + markUserMessageSent(sessionId); + + return res.json({ + success: true, + sessionId, + messageSent: true, + }); + }); +}; diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js new file mode 100644 index 00000000..8bd276d6 --- /dev/null +++ b/packages/web/server/lib/notifications/runtime.js @@ -0,0 +1,471 @@ +export const createNotificationTriggerRuntime = (deps) => { + const { + readSettingsFromDisk, + prepareNotificationLastMessage, + summarizeText, + resolveZenModel, + buildTemplateVariables, + extractLastMessageText, + fetchLastAssistantMessageText, + resolveNotificationTemplate, + shouldApplyResolvedTemplateMessage, + emitDesktopNotification, + broadcastUiNotification, + sendPushToAllUiSessions, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + } = deps; + + const PUSH_READY_COOLDOWN_MS = 5000; + const PUSH_QUESTION_DEBOUNCE_MS = 500; + const PUSH_PERMISSION_DEBOUNCE_MS = 500; + const pushQuestionDebounceTimers = new Map(); + const pushPermissionDebounceTimers = new Map(); + const notifiedPermissionRequests = new Set(); + const lastReadyNotificationAt = new Map(); + + const sessionParentIdCache = new Map(); + const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000; + + const buildSessionDeepLinkUrl = (sessionId) => { + if (!sessionId || typeof sessionId !== 'string') { + return '/'; + } + return `/?session=${encodeURIComponent(sessionId)}`; + }; + + const getCachedSessionParentId = (sessionId) => { + const entry = sessionParentIdCache.get(sessionId); + if (!entry) return undefined; + if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) { + sessionParentIdCache.delete(sessionId); + return undefined; + } + return entry.parentID; + }; + + const setCachedSessionParentId = (sessionId, parentID) => { + sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() }); + }; + + const fetchSessionParentId = async (sessionId) => { + if (!sessionId) return undefined; + + const cached = getCachedSessionParentId(sessionId); + if (cached !== undefined) return cached; + + try { + const response = await fetch(buildOpenCodeUrl('/session', ''), { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + return undefined; + } + const data = await response.json().catch(() => null); + if (!Array.isArray(data)) { + return undefined; + } + + const match = data.find((session) => session && typeof session === 'object' && session.id === sessionId); + const parentID = match?.parentID ? match.parentID : null; + setCachedSessionParentId(sessionId, parentID); + return parentID; + } catch { + return undefined; + } + }; + + const extractSessionIdFromPayload = (payload) => { + if (!payload || typeof payload !== 'object') return null; + const props = payload.properties; + const info = props?.info; + const sessionId = + info?.sessionID ?? + info?.sessionId ?? + props?.sessionID ?? + props?.sessionId ?? + props?.session ?? + null; + return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null; + }; + + const formatMode = (raw) => { + const value = typeof raw === 'string' ? raw.trim() : ''; + const normalized = value.length > 0 ? value : 'agent'; + return normalized + .split(/[-_\s]+/) + .filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(' '); + }; + + const formatModelId = (raw) => { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) { + return 'Assistant'; + } + + const tokens = value.split(/[-_]+/).filter(Boolean); + const result = []; + for (let i = 0; i < tokens.length; i += 1) { + const current = tokens[i]; + const next = tokens[i + 1]; + if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) { + result.push(`${current}.${next}`); + i += 1; + continue; + } + result.push(current); + } + + return result + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + }; + + const maybeSendPushForTrigger = async (payload) => { + if (!payload || typeof payload !== 'object') { + return; + } + + const sessionId = extractSessionIdFromPayload(payload); + + if (payload.type === 'message.updated') { + const info = payload.properties?.info; + if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) { + const settings = await readSettingsFromDisk(); + + if (settings.notifyOnSubtasks === false) { + const sessionInfo = payload.properties?.session; + const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID; + const parentID = parentIDFromPayload + ? parentIDFromPayload + : await fetchSessionParentId(sessionId); + + if (parentID) { + return; + } + } + + if (settings.notifyOnCompletion === false) { + return; + } + + const now = Date.now(); + const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0; + if (now - lastAt < PUSH_READY_COOLDOWN_MS) { + return; + } + lastReadyNotificationAt.set(sessionId, now); + + let title = `${formatMode(info?.mode)} agent is ready`; + let body = `${formatModelId(info?.modelID)} completed the task`; + + try { + const templates = settings.notificationTemplates || {}; + const isSubtask = await fetchSessionParentId(sessionId); + const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false + ? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }) + : (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }); + + const variables = await buildTemplateVariables(payload, sessionId); + + const messageId = info?.id; + let lastMessage = extractLastMessageText(payload); + if (!lastMessage) { + lastMessage = await fetchLastAssistantMessageText(sessionId, messageId); + } + + const notifZenModel = await resolveZenModel(settings?.zenModel); + variables.last_message = await prepareNotificationLastMessage({ + message: lastMessage, + settings, + summarize: (text, len) => summarizeText(text, len, notifZenModel), + }); + + const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + const notificationPayload = { + title, + body, + tag: `ready-${sessionId}`, + kind: 'ready', + sessionId, + requireHidden: settings.notificationMode !== 'always', + }; + emitDesktopNotification(notificationPayload); + broadcastUiNotification(notificationPayload); + } + + await sendPushToAllUiSessions( + { + title, + body, + tag: `ready-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'ready', + }, + }, + { requireNoSse: true }, + ); + } + + if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) { + const settings = await readSettingsFromDisk(); + if (settings.notifyOnError === false) return; + + let title = 'Tool error'; + let body = 'An error occurred'; + + try { + const variables = await buildTemplateVariables(payload, sessionId); + const errorMessageId = info?.id; + let lastMessage = extractLastMessageText(payload); + if (!lastMessage) { + lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId); + } + + const errZenModel = await resolveZenModel(settings?.zenModel); + variables.last_message = await prepareNotificationLastMessage({ + message: lastMessage, + settings, + summarize: (text, len) => summarizeText(text, len, errZenModel), + }); + + const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' }; + const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Error template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + const notificationPayload = { + title, + body, + tag: `error-${sessionId}`, + kind: 'error', + sessionId, + requireHidden: settings.notificationMode !== 'always', + }; + emitDesktopNotification(notificationPayload); + broadcastUiNotification(notificationPayload); + } + + await sendPushToAllUiSessions( + { + title, + body, + tag: `error-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'error', + }, + }, + { requireNoSse: true }, + ); + } + + return; + } + + if (payload.type === 'question.asked' && sessionId) { + const existingTimer = pushQuestionDebounceTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(async () => { + pushQuestionDebounceTimers.delete(sessionId); + + const settings = await readSettingsFromDisk(); + if (settings.notifyOnQuestion === false) { + return; + } + + const firstQuestion = payload.properties?.questions?.[0]; + const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : ''; + const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : ''; + + let title = /plan\s*mode/i.test(header) + ? 'Switch to plan mode' + : /build\s*agent/i.test(header) + ? 'Switch to build mode' + : header || 'Input needed'; + let body = questionText || 'Agent is waiting for your response'; + + try { + const variables = await buildTemplateVariables(payload, sessionId); + variables.last_message = questionText || header || ''; + + const templates = settings.notificationTemplates || {}; + const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' }; + + const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Question template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + emitDesktopNotification({ + kind: 'question', + title, + body, + tag: `question-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + + broadcastUiNotification({ + kind: 'question', + title, + body, + tag: `question-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + } + + void sendPushToAllUiSessions( + { + title, + body, + tag: `question-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'question', + }, + }, + { requireNoSse: true }, + ); + }, PUSH_QUESTION_DEBOUNCE_MS); + + pushQuestionDebounceTimers.set(sessionId, timer); + return; + } + + if (payload.type === 'permission.replied' && sessionId) { + const requestId = payload.properties?.requestID; + const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; + const pendingNotification = pushPermissionDebounceTimers.get(sessionId); + if (requestKey && pendingNotification?.requestKey === requestKey) { + clearTimeout(pendingNotification.timer); + pushPermissionDebounceTimers.delete(sessionId); + } + return; + } + + if (payload.type === 'permission.asked' && sessionId) { + const requestId = payload.properties?.id; + const permission = payload.properties?.permission; + const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null; + if (requestKey && notifiedPermissionRequests.has(requestKey)) { + return; + } + + const existingTimer = pushPermissionDebounceTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer.timer); + } + + const timer = setTimeout(async () => { + pushPermissionDebounceTimers.delete(sessionId); + + const settings = await readSettingsFromDisk(); + + if (settings.notifyOnQuestion === false) { + return; + } + + const sessionTitle = payload.properties?.sessionTitle; + const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : ''; + const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0 + ? sessionTitle.trim() + : permissionText || 'Agent is waiting for your approval'; + + let title = 'Permission required'; + let body = fallbackMessage; + + try { + const variables = await buildTemplateVariables(payload, sessionId); + variables.last_message = fallbackMessage; + + const templates = settings.notificationTemplates || {}; + const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' }; + + const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables); + const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables); + if (resolvedTitle) title = resolvedTitle; + if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody; + } catch (error) { + console.warn('[Notification] Permission template resolution failed, using defaults:', error?.message || error); + } + + if (settings.nativeNotificationsEnabled) { + emitDesktopNotification({ + kind: 'permission', + title, + body, + tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + + broadcastUiNotification({ + kind: 'permission', + title, + body, + tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`, + sessionId, + requireHidden: settings.notificationMode !== 'always', + }); + } + + if (requestKey) { + notifiedPermissionRequests.add(requestKey); + } + + void sendPushToAllUiSessions( + { + title, + body, + tag: `permission-${sessionId}`, + data: { + url: buildSessionDeepLinkUrl(sessionId), + sessionId, + type: 'permission', + }, + }, + { requireNoSse: true }, + ); + }, PUSH_PERMISSION_DEBOUNCE_MS); + + pushPermissionDebounceTimers.set(sessionId, { timer, requestKey }); + } + }; + + return { + maybeSendPushForTrigger, + }; +}; diff --git a/packages/web/server/lib/notifications/template-runtime.js b/packages/web/server/lib/notifications/template-runtime.js new file mode 100644 index 00000000..f0641174 --- /dev/null +++ b/packages/web/server/lib/notifications/template-runtime.js @@ -0,0 +1,436 @@ +export const createNotificationTemplateRuntime = (deps) => { + const { + readSettingsFromDisk, + persistSettings, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + resolveGitBinaryForSpawn, + } = deps; + + const NOTIFICATION_BODY_MAX_CHARS = 1000; + const ZEN_DEFAULT_MODEL = 'gpt-5-nano'; + const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; + const SESSION_INFO_CACHE_TTL_MS = 60 * 1000; + + let validatedZenFallback = null; + let cachedZenModels = null; + let cachedZenModelsTimestamp = 0; + + const sessionTitleCache = new Map(); + const sessionInfoCache = new Map(); + + const createTimeoutSignal = (timeoutMs) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + cleanup: () => clearTimeout(timer), + }; + }; + + const formatProjectLabel = (label) => { + if (!label || typeof label !== 'string') return ''; + return label + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); + }; + + const resolveNotificationTemplate = (template, variables) => { + if (!template || typeof template !== 'string') return ''; + return template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = variables[key]; + if (value === undefined || value === null) return ''; + return String(value); + }); + }; + + const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => { + if (!resolved) { + return false; + } + + if (typeof template !== 'string') { + return true; + } + + if (template.includes('{last_message}')) { + return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0; + } + + return true; + }; + + const fetchFreeZenModels = async () => { + const now = Date.now(); + if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) { + return cachedZenModels.models; + } + + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; + try { + const response = await fetch('https://opencode.ai/zen/v1/models', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`zen/v1/models responded with status ${response.status}`); + } + const data = await response.json(); + const allModels = Array.isArray(data?.data) ? data.data : []; + const freeModels = allModels + .filter((model) => typeof model?.id === 'string' && model.id.endsWith('-free')) + .map((model) => ({ id: model.id, owned_by: model.owned_by })); + + cachedZenModels = { models: freeModels }; + cachedZenModelsTimestamp = Date.now(); + return freeModels; + } finally { + if (timeout) clearTimeout(timeout); + } + }; + + const resolveZenModel = async (override) => { + if (typeof override === 'string' && override.trim().length > 0) { + return override.trim(); + } + try { + const settings = await readSettingsFromDisk(); + if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) { + return settings.zenModel.trim(); + } + } catch { + } + return validatedZenFallback || ZEN_DEFAULT_MODEL; + }; + + const validateZenModelAtStartup = async () => { + try { + const freeModels = await fetchFreeZenModels(); + const freeModelIds = freeModels.map((model) => model.id); + + if (freeModelIds.length > 0) { + validatedZenFallback = freeModelIds[0]; + + const settings = await readSettingsFromDisk(); + const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; + + if (!storedModel || !freeModelIds.includes(storedModel)) { + const fallback = freeModelIds[0]; + console.log( + storedModel + ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` + : `[zen] No model configured, setting default to "${fallback}"` + ); + await persistSettings({ zenModel: fallback }); + } else { + console.log(`[zen] Stored model "${storedModel}" verified as available`); + } + } else { + console.warn('[zen] No free models returned from API, skipping validation'); + } + } catch (error) { + console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); + } + }; + + const summarizeText = async (text, targetLength, zenModel) => { + if (!text || typeof text !== 'string' || text.trim().length === 0) return text; + + try { + const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output ONLY the summary text, nothing else.\n\nText:\n${text}`; + + const completionTimeout = createTimeoutSignal(15000); + let response; + try { + response = await fetch('https://opencode.ai/zen/v1/responses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: zenModel || ZEN_DEFAULT_MODEL, + input: [{ role: 'user', content: prompt }], + max_output_tokens: 1000, + stream: false, + reasoning: { effort: 'low' }, + }), + signal: completionTimeout.signal, + }); + } finally { + completionTimeout.cleanup(); + } + + if (!response.ok) return text; + + const data = await response.json(); + const summary = data?.output?.find((item) => item?.type === 'message') + ?.content?.find((item) => item?.type === 'output_text')?.text?.trim(); + + return summary || text; + } catch { + return text; + } + }; + + const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { + if (!Array.isArray(parts) || parts.length === 0) return ''; + + const textParts = parts + .filter((part) => part && (part.type === 'text' || typeof part.text === 'string' || typeof part.content === 'string')) + .map((part) => part.text || part.content || '') + .filter(Boolean); + + let text = textParts.length > 0 ? textParts.join('\n').trim() : ''; + + if (maxLength > 0 && text.length > maxLength) { + text = text.slice(0, maxLength); + } + + return text; + }; + + const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { + const info = payload?.properties?.info; + if (!info) return ''; + + const parts = info.parts || payload?.properties?.parts; + const text = extractTextFromParts(parts, maxLength); + if (text) return text; + + const content = info.content; + if (Array.isArray(content)) { + const textContent = content + .filter((entry) => entry && (entry.type === 'text' || typeof entry.text === 'string')) + .map((entry) => entry.text || '') + .filter(Boolean); + if (textContent.length > 0) { + let result = textContent.join('\n').trim(); + if (maxLength > 0 && result.length > maxLength) { + result = result.slice(0, maxLength); + } + return result; + } + } + + return ''; + }; + + const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { + if (!sessionId) return ''; + + try { + const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); + const response = await fetch(`${url}?limit=5`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(3000), + }); + + if (!response.ok) return ''; + + const messages = await response.json().catch(() => null); + if (!Array.isArray(messages)) return ''; + + let target = null; + if (messageId) { + target = messages.find((message) => message?.info?.id === messageId && message?.info?.role === 'assistant'); + } + if (!target) { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.info?.role === 'assistant' && message?.info?.finish === 'stop') { + target = message; + break; + } + } + } + + if (!target || !Array.isArray(target.parts)) return ''; + + return extractTextFromParts(target.parts, maxLength); + } catch { + return ''; + } + }; + + const cacheSessionTitle = (sessionId, title) => { + if (typeof sessionId === 'string' && sessionId.length > 0 && typeof title === 'string' && title.length > 0) { + sessionTitleCache.set(sessionId, title); + } + }; + + const getCachedSessionTitle = (sessionId) => { + return sessionTitleCache.get(sessionId) ?? null; + }; + + const maybeCacheSessionInfoFromEvent = (payload) => { + if (!payload || typeof payload !== 'object') return; + const type = payload.type; + if (type !== 'session.updated' && type !== 'session.created') return; + const info = payload.properties?.info; + if (!info || typeof info !== 'object') return; + cacheSessionTitle(info.id, info.title); + }; + + const fetchSessionInfo = async (sessionId) => { + if (!sessionId) return null; + + const cached = sessionInfoCache.get(sessionId); + if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) { + return cached.data; + } + + try { + const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, ''); + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`); + return null; + } + const data = await response.json().catch(() => null); + if (data && typeof data === 'object') { + sessionInfoCache.set(sessionId, { data, at: Date.now() }); + return data; + } + return null; + } catch (error) { + console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, error?.message || error); + return null; + } + }; + + const buildTemplateVariables = async (payload, sessionId) => { + const info = payload?.properties?.info || {}; + + let sessionTitle = payload?.properties?.sessionTitle || payload?.properties?.session?.title || (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') || ''; + + if (!sessionTitle && sessionId) { + const cached = getCachedSessionTitle(sessionId); + if (cached) { + sessionTitle = cached; + } + } + + let sessionInfo = null; + if (!sessionTitle && sessionId) { + sessionInfo = await fetchSessionInfo(sessionId); + if (sessionInfo && typeof sessionInfo.title === 'string') { + sessionTitle = sessionInfo.title; + cacheSessionTitle(sessionId, sessionTitle); + } + } + + const agentName = (() => { + const mode = typeof info.agent === 'string' && info.agent.trim().length > 0 + ? info.agent.trim() + : (typeof info.mode === 'string' ? info.mode.trim() : ''); + if (!mode) return 'Agent'; + return mode.split(/[-_\s]+/).filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)).join(' '); + })(); + + const modelName = (() => { + const raw = typeof info.modelID === 'string' ? info.modelID.trim() + : (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : ''); + if (!raw) return 'Assistant'; + return raw.split(/[-_]+/).filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); + })(); + + let projectName = ''; + let branch = ''; + let worktreeDir = ''; + + const infoPath = info.path; + if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) { + worktreeDir = infoPath.root; + } else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) { + worktreeDir = infoPath.cwd; + } + + try { + const settings = await readSettingsFromDisk(); + const projects = Array.isArray(settings.projects) ? settings.projects : []; + + if (worktreeDir) { + const normalizedDir = worktreeDir.replace(/\/+$/, ''); + const matchedProject = projects.find((project) => { + if (!project || typeof project.path !== 'string') return false; + return project.path.replace(/\/+$/, '') === normalizedDir; + }); + if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) { + projectName = matchedProject.label.trim(); + } else { + projectName = normalizedDir.split('/').filter(Boolean).pop() || ''; + } + } else { + const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; + const activeProject = activeId ? projects.find((project) => project && project.id === activeId) : projects[0]; + if (activeProject) { + projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0 + ? activeProject.label.trim() + : typeof activeProject.path === 'string' + ? activeProject.path.split('/').pop() || '' + : ''; + worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : ''; + } + } + } catch { + if (worktreeDir && !projectName) { + projectName = worktreeDir.split('/').filter(Boolean).pop() || ''; + } + } + + if (worktreeDir) { + try { + const { simpleGit } = await import('simple-git'); + const git = simpleGit({ + baseDir: worktreeDir, + spawnOptions: { windowsHide: true }, + binary: resolveGitBinaryForSpawn(), + }); + branch = await Promise.race([ + git.revparse(['--abbrev-ref', 'HEAD']), + new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)), + ]).catch(() => ''); + } catch { + } + } + + return { + project_name: formatProjectLabel(projectName), + worktree: worktreeDir, + branch: typeof branch === 'string' ? branch.trim() : '', + session_name: sessionTitle, + agent_name: agentName, + model_name: modelName, + last_message: '', + session_id: sessionId || '', + }; + }; + + const getCachedZenModels = () => cachedZenModels; + + return { + createTimeoutSignal, + formatProjectLabel, + resolveNotificationTemplate, + shouldApplyResolvedTemplateMessage, + fetchFreeZenModels, + resolveZenModel, + validateZenModelAtStartup, + summarizeText, + extractTextFromParts, + extractLastMessageText, + fetchLastAssistantMessageText, + maybeCacheSessionInfoFromEvent, + buildTemplateVariables, + getCachedZenModels, + }; +}; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 843d6f93..85e87ff5 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -6,6 +6,39 @@ This module provides OpenCode server integration utilities for the web server ru ## Entrypoints and structure - `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder). - `packages/web/server/lib/opencode/auth.js`: provider authentication file operations. +- `packages/web/server/lib/opencode/auth-state-runtime.js`: managed OpenCode server auth password/header runtime. +- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments. +- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap. +- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration. +- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). +- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime. +- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname). +- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers. +- `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring. +- `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime. +- `packages/web/server/lib/opencode/project-directory-runtime.js`: request-scoped and settings-backed project directory resolution/validation runtime. +- `packages/web/server/lib/opencode/config-entity-routes.js`: route registration for agent/command/MCP config orchestration and reload semantics. +- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments. +- `packages/web/server/lib/opencode/core-routes.js`: server status/system routes, auth/access guard routes, and settings utility route registration. +- `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration runtime for watcher/session/terminal/process/server teardown. +- `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen/startup tunnel flow and process/signal handler orchestration runtime. +- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring. +- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration. +- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics. +- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup. +- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow. +- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers. +- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration. +- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching. +- `packages/web/server/lib/opencode/project-icon-routes.js`: project icon upload/read/discovery route registration and icon storage orchestration. +- `packages/web/server/lib/opencode/skill-routes.js`: route registration for skill config CRUD, supporting files, and skills catalog scan/install flows. +- `packages/web/server/lib/opencode/settings-runtime.js`: Settings persistence runtime (disk IO, migrations, normalization, project validation, and persisted update serialization). +- `packages/web/server/lib/opencode/settings-helpers.js`: Settings payload sanitization/format helpers runtime for response shaping and persisted merge prep. +- `packages/web/server/lib/opencode/settings-normalization-runtime.js`: path/settings/tunnel normalization and sanitization helpers runtime used by settings/routes/config wiring. +- `packages/web/server/lib/opencode/theme-runtime.js`: custom theme JSON validation and theme directory loading runtime for settings utility routes. +- `packages/web/server/lib/opencode/proxy.js`: OpenCode API/SSE forwarding and readiness-gate route registration. +- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events. +- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout. - `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers. - `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting. @@ -43,6 +76,269 @@ This module provides OpenCode server integration utilities for the web server ru - `ensureSessionToken(req, res)`: Returns or creates session token. - `dispose()`: Cleans up timers and state. +## Public exports (routes.js) +- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime: + - `GET /api/config/settings` + - `PUT /api/config/settings` + - `GET /api/config/opencode-resolution` + - `POST /api/opencode/directory` + - `GET /api/provider/:providerId/source` + - `DELETE /api/provider/:providerId/auth` +- Owns lazy auth library loading for provider auth checks/removal. +- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only. + +## Public exports (session-runtime.js) +- `createSessionRuntime({ writeSseEvent, getNotificationClients })`: creates runtime-owned state machine and APIs for session status. +- Returned API: + - `processOpenCodeSsePayload(payload)` + - `getSessionActivitySnapshot()` + - `getSessionStateSnapshot()` + - `getSessionAttentionSnapshot()` + - `getSessionState(sessionId)` + - `getSessionAttentionState(sessionId)` + - `markSessionViewed(sessionId, clientId)` + - `markSessionUnviewed(sessionId, clientId)` + - `markUserMessageSent(sessionId)` + - `resetAllSessionActivityToIdle()` + - `dispose()` + +## Public exports (lifecycle.js) +- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration. +- Returned API: + - `startOpenCode()` + - `restartOpenCode()` + - `waitForOpenCodeReady(timeoutMs?, intervalMs?)` + - `waitForAgentPresence(agentName, timeoutMs?, intervalMs?)` + - `refreshOpenCodeAfterConfigChange(reason, options?)` + - `bootstrapOpenCodeAtStartup()` + - `startHealthMonitoring(healthCheckIntervalMs)` + - `killProcessOnPort(port)` + +## Public exports (env-runtime.js) +- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state. +- Returned API: + - `applyLoginShellEnvSnapshot()` + - `getLoginShellEnvSnapshot()` + - `ensureOpencodeCliEnv()` + - `applyOpencodeBinaryFromSettings()` + - `resolveOpencodeCliPath()` + - `resolveGitBinaryForSpawn()` + - `resolveWslExecutablePath()` + - `buildWslExecArgs(execArgs, distroOverride?)` + - `opencodeShimInterpreter(opencodePath)` + - `isExecutable(filePath)` + - `searchPathFor(binaryName)` + - `clearResolvedOpenCodeBinary()` + +## Public exports (env-config.js) +- `resolveOpenCodeEnvConfig(options?)`: resolves and validates OpenCode host/port/hostname environment configuration. +- Returned object fields: + - `configuredOpenCodePort` + - `configuredOpenCodeHost` + - `effectivePort` + - `configuredOpenCodeHostname` + +## Public exports (hmr-state-runtime.js) +- `createHmrStateRuntime(dependencies)`: creates runtime for HMR state container initialization and runtime<->HMR state synchronization. +- Returned API: + - `getOrCreateHmrState()` + - `ensureUserProvidedOpenCodePassword(hmrState)` + - `getUserProvidedOpenCodePassword(hmrState)` + - `resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword })` + - `syncStateFromRuntime(hmrState, runtime)` + - `restoreRuntimeFromState({ hmrState, userProvidedOpenCodePassword })` + +## Public exports (bootstrap-runtime.js) +- `createBootstrapRuntime(dependencies)`: creates runtime for base app route bootstrap and UI auth controller initialization. +- Returned API: + - `setupBaseRoutes(app, options)` + +## Public exports (network-runtime.js) +- `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns. +- Returned API: + - `waitForReady(url, timeoutMs?)` + - `normalizeApiPrefix(prefix)` + - `setDetectedOpenCodeApiPrefix()` + - `buildOpenCodeUrl(path, prefixOverride?)` + - `ensureOpenCodeApiPrefix()` + - `scheduleOpenCodeApiDetection()` + +## Public exports (settings-runtime.js) +- `createSettingsRuntime(dependencies)`: creates settings lifecycle runtime for read/migrate/persist concerns. +- Returned API: + - `readSettingsFromDisk()` + - `readSettingsFromDiskMigrated()` + - `writeSettingsToDisk(settings)` + - `persistSettings(changes)` + +## Public exports (settings-helpers.js) +- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping. +- Returned API: + - `normalizePwaAppName(value, fallback?)` + - `sanitizeSettingsUpdate(payload)` + - `mergePersistedSettings(current, changes)` + - `formatSettingsResponse(settings)` + +## Public exports (settings-normalization-runtime.js) +- `createSettingsNormalizationRuntime(dependencies)`: creates normalization/sanitization runtime for shared settings and tunnel helper logic. +- Returned API: + - `normalizeDirectoryPath(value)` + - `normalizePathForPersistence(value)` + - `normalizeSettingsPaths(input)` + - `normalizeTunnelBootstrapTtlMs(value)` + - `normalizeTunnelSessionTtlMs(value)` + - `normalizeManagedRemoteTunnelHostname(value)` + - `normalizeManagedRemoteTunnelPresets(value)` + - `normalizeManagedRemoteTunnelPresetTokens(value)` + - `isUnsafeSkillRelativePath(value)` + - `sanitizeTypographySizesPartial(input)` + - `normalizeStringArray(input)` + - `sanitizeModelRefs(input, limit)` + - `sanitizeSkillCatalogs(input)` + - `sanitizeProjects(input)` + +## Public exports (theme-runtime.js) +- `createThemeRuntime(dependencies)`: creates custom theme runtime for on-disk theme discovery and JSON normalization/validation. +- Returned API: + - `normalizeThemeJson(raw)` + - `readCustomThemesFromDisk()` + +## Public exports (project-directory-runtime.js) +- `createProjectDirectoryRuntime(dependencies)`: creates runtime for request/project directory candidate normalization and validation. +- Returned API: + - `resolveDirectoryCandidate(value)` + - `validateDirectoryPath(candidate)` + - `resolveProjectDirectory(req)` + - `resolveOptionalProjectDirectory(req)` + +## Public exports (config-entity-routes.js) +- `registerConfigEntityRoutes(app, dependencies)`: registers configuration entity routes: + - Agents: `/api/config/agents/:name` and `/api/config/agents/:name/config` + - Commands: `/api/config/commands/:name` + - MCP servers: `/api/config/mcp` and `/api/config/mcp/:name` + +## Public exports (auth-state-runtime.js) +- `createOpenCodeAuthStateRuntime(dependencies)`: creates runtime for managed OpenCode auth password state and request headers. +- Returned API: + - `getOpenCodeAuthHeaders()` + - `isOpenCodeConnectionSecure()` + - `ensureLocalOpenCodeServerPassword(options?)` + +## Public exports (core-routes.js) +- `registerServerStatusRoutes(app, dependencies)`: registers status/system endpoints: + - `GET /health` + - `POST /api/system/shutdown` + - `GET /api/system/info` +- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware: + - `GET /auth/session` + - `POST /auth/session` + - `GET /connect` + - `app.use('/api', ...)` auth/tunnel guard +- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints: + - `GET /api/config/themes` + - `POST /api/config/reload` +- `registerCommonRequestMiddleware(app, dependencies)`: registers shared request middleware stack: + - conditional JSON body parser behavior for `/api/*` vs non-API requests + - URL-encoded parser setup + - request logging middleware + +## Public exports (cli-options.js) +- `parseServeCliOptions(options)`: parses serve CLI flags and environment-derived defaults: + - Port/host/ui-password + - Tunnel provider/mode/config/token/hostname + - Legacy `--tunnel` shorthand normalization + +## Public exports (cli-entry-runtime.js) +- `runCliEntryIfMain(dependencies)`: detects direct CLI execution and runs server startup with parsed CLI options. + +## Public exports (server-utils-runtime.js) +- `createServerUtilsRuntime(dependencies)`: creates server utility runtime for OpenCode orchestration helpers. +- Returned API: + - `setOpenCodePort(port)` + - `waitForOpenCodePort(timeoutMs?)` + - `buildAugmentedPath()` + - `parseSseDataPayload(block)` + - `fetchAgentsSnapshot()` + - `fetchProvidersSnapshot()` + - `fetchModelsSnapshot()` + - `setupProxy(app)` + +## Public exports (shutdown-runtime.js) +- `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown runtime for managed OpenCode and web server teardown sequencing. +- Returned API: + - `gracefulShutdown(options?)` + +## Public exports (server-startup-runtime.js) +- `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring. +- Returned API: + - `resolveBindHost(host)` + - `startListeningAndMaybeTunnel(options)` + - `attachProcessHandlers(options)` + +## Public exports (static-routes-runtime.js) +- `createStaticRoutesRuntime(dependencies)`: creates runtime for static dist resolution and static route registration. +- Returned API: + - `registerStaticRoutes(app)` + +## Public exports (feature-routes-runtime.js) +- `createFeatureRoutesRuntime(dependencies)`: creates runtime for main feature route registration orchestration. +- Returned API: + - `registerRoutes(app, routeDependencies)` + +## Public exports (opencode-resolution-runtime.js) +- `createOpenCodeResolutionRuntime(dependencies)`: creates runtime for OpenCode binary/source snapshot resolution. +- Returned API: + - `getOpenCodeResolutionSnapshot(settings)` + +## Public exports (tunnel-wiring-runtime.js) +- `createTunnelWiringRuntime(dependencies)`: creates runtime for tunnel service construction and tunnel route registration. +- Returned API: + - `initialize(app, initialPort)` + +## Public exports (startup-pipeline-runtime.js) +- `createStartupPipelineRuntime(dependencies)`: creates runtime for terminal wiring, proxy/bootstrap scheduling, static route registration, and server startup/listen flow. +- Returned API: + - `run(options)` + +## Public exports (openchamber-routes.js) +- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints: + - `GET /api/openchamber/update-check` + - `POST /api/openchamber/update-install` + - `GET /api/openchamber/models-metadata` + - `GET /api/zen/models` + +## Public exports (pwa-manifest-routes.js) +- `registerPwaManifestRoute(app, dependencies)`: registers PWA manifest endpoint with dynamic app-name resolution and recent-session shortcuts: + - `GET /manifest.webmanifest` + +## Public exports (project-icon-routes.js) +- `registerProjectIconRoutes(app, dependencies)`: registers project icon routes and owns icon storage/discovery flow: + - `GET /api/projects/:projectId/icon` + - `PUT /api/projects/:projectId/icon` + - `DELETE /api/projects/:projectId/icon` + - `POST /api/projects/:projectId/icon/discover` + +## Public exports (skill-routes.js) +- `registerSkillRoutes(app, dependencies)`: registers skills-related routes: + - Skills config CRUD and metadata under `/api/config/skills*` + - Skills catalog listing/source pagination, scan, and install routes + - Supporting skill file read/write/delete routes + +## Public exports (proxy.js) +- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware. +- Owns: + - SSE forwarders: `GET /api/global/event`, `GET /api/event` + - Session message forwarder: `POST /api/session/:sessionId/message` + - Generic `/api/*` forwarding with hop-by-hop header filtering + - Windows `/session` merge fallback path behavior + - OpenCode readiness gate for proxied `/api` requests + +## Public exports (watcher.js) +- `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime. +- Returned API: + - `start()` + - `stop()` + ## Storage and configuration - Provider auth: `~/.local/share/opencode/auth.json`. - User config: `~/.config/opencode/opencode.json`. @@ -52,7 +348,7 @@ This module provides OpenCode server integration utilities for the web server ru ## Notes for contributors - This module serves as foundation for OpenCode-related server utilities. -- Index.js is currently a baseline placeholder; direct imports use submodule paths. +- Route ownership moved to module-level `routes.js`; `index.js` wires dependencies only. - All file writes include automatic backup before modification. - Config merging follows priority: custom > project > user. - UI auth uses scrypt for password hashing with constant-time comparison. diff --git a/packages/web/server/lib/opencode/auth-state-runtime.js b/packages/web/server/lib/opencode/auth-state-runtime.js new file mode 100644 index 00000000..9c8ce9fc --- /dev/null +++ b/packages/web/server/lib/opencode/auth-state-runtime.js @@ -0,0 +1,88 @@ +export const createOpenCodeAuthStateRuntime = (dependencies) => { + const { + crypto, + process, + getAuthPassword, + setAuthPassword, + getAuthSource, + setAuthSource, + getUserProvidedPassword, + syncToHmrState, + } = dependencies; + + const normalizeOpenCodePassword = (value) => { + if (typeof value !== 'string') { + return ''; + } + return value.trim(); + }; + + const isValidOpenCodePassword = (password) => typeof password === 'string' && password.trim().length > 0; + + const generateSecureOpenCodePassword = () => + crypto + .randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + + const setOpenCodeAuthState = (password, source) => { + const normalized = normalizeOpenCodePassword(password); + if (!isValidOpenCodePassword(normalized)) { + setAuthPassword(null); + setAuthSource(null); + delete process.env.OPENCODE_SERVER_PASSWORD; + syncToHmrState(); + return null; + } + + setAuthPassword(normalized); + setAuthSource(source); + process.env.OPENCODE_SERVER_PASSWORD = normalized; + syncToHmrState(); + return normalized; + }; + + const getOpenCodeAuthHeaders = () => { + const password = normalizeOpenCodePassword(getAuthPassword() || process.env.OPENCODE_SERVER_PASSWORD || ''); + + if (!password) { + return {}; + } + + const credentials = Buffer.from(`opencode:${password}`).toString('base64'); + return { Authorization: `Basic ${credentials}` }; + }; + + const isOpenCodeConnectionSecure = () => Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization'); + + const ensureLocalOpenCodeServerPassword = async ({ rotateManaged = false } = {}) => { + const userProvidedPassword = getUserProvidedPassword(); + if (isValidOpenCodePassword(userProvidedPassword)) { + return setOpenCodeAuthState(userProvidedPassword, 'user-env'); + } + + if (rotateManaged) { + const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated'); + console.log('Rotated secure password for managed local OpenCode instance'); + return rotatedPassword; + } + + const currentPassword = getAuthPassword(); + const currentSource = getAuthSource(); + if (isValidOpenCodePassword(currentPassword)) { + return setOpenCodeAuthState(currentPassword, currentSource || 'generated'); + } + + const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated'); + console.log('Generated secure password for managed local OpenCode instance'); + return generatedPassword; + }; + + return { + getOpenCodeAuthHeaders, + isOpenCodeConnectionSecure, + ensureLocalOpenCodeServerPassword, + }; +}; diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js new file mode 100644 index 00000000..30fc693e --- /dev/null +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -0,0 +1,119 @@ +export const createBootstrapRuntime = (dependencies) => { + const { + createUiAuth, + registerServerStatusRoutes, + registerCommonRequestMiddleware, + registerAuthAndAccessRoutes, + registerTtsRoutes, + registerNotificationRoutes, + registerOpenChamberRoutes, + express, + } = dependencies; + + const setupBaseRoutes = (app, options) => { + const { + process, + openchamberVersion, + runtimeName, + serverStartedAt, + gracefulShutdown, + getHealthSnapshot, + uiPassword, + tunnelAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + resolveZenModel, + sayTTSCapability, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + sessionRuntime, + setPushInitialized, + fs, + os, + path, + server, + __dirname, + openchamberDataDir, + modelsDevApiUrl, + modelsMetadataCacheTtl, + fetchFreeZenModels, + getCachedZenModels, + } = options; + + registerServerStatusRoutes(app, { + process, + openchamberVersion, + runtimeName, + serverStartedAt, + gracefulShutdown, + getHealthSnapshot, + }); + + registerCommonRequestMiddleware(app, { express }); + + const uiAuthController = createUiAuth({ password: uiPassword }); + if (uiAuthController.enabled) { + console.log('UI password protection enabled for browser sessions'); + } + + registerAuthAndAccessRoutes(app, { + tunnelAuthController, + uiAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + }); + + registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }); + + registerNotificationRoutes(app, { + uiAuthController, + ensurePushInitialized, + getOrCreateVapidKeys, + getUiSessionTokenFromRequest, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + addOrUpdatePushSubscription, + removePushSubscription, + updateUiVisibility, + isUiVisible, + getSessionActivitySnapshot: sessionRuntime.getSessionActivitySnapshot, + getSessionStateSnapshot: sessionRuntime.getSessionStateSnapshot, + getSessionAttentionSnapshot: sessionRuntime.getSessionAttentionSnapshot, + getSessionState: sessionRuntime.getSessionState, + getSessionAttentionState: sessionRuntime.getSessionAttentionState, + markSessionViewed: sessionRuntime.markSessionViewed, + markSessionUnviewed: sessionRuntime.markSessionUnviewed, + markUserMessageSent: sessionRuntime.markUserMessageSent, + setPushInitialized, + }); + + registerOpenChamberRoutes(app, { + fs, + os, + path, + process, + server, + __dirname, + openchamberDataDir, + modelsDevApiUrl, + modelsMetadataCacheTtl, + readSettingsFromDiskMigrated, + fetchFreeZenModels, + getCachedZenModels, + }); + + return { + uiAuthController, + }; + }; + + return { + setupBaseRoutes, + }; +}; diff --git a/packages/web/server/lib/opencode/cli-entry-runtime.js b/packages/web/server/lib/opencode/cli-entry-runtime.js new file mode 100644 index 00000000..89f5f719 --- /dev/null +++ b/packages/web/server/lib/opencode/cli-entry-runtime.js @@ -0,0 +1,43 @@ +export const runCliEntryIfMain = (dependencies) => { + const { + process, + currentFilename, + parseServeCliOptions, + defaultPort, + cloudflareProvider, + managedLocalMode, + setExitOnShutdown, + startServer, + } = dependencies; + + const isCliExecution = process.argv[1] === currentFilename; + if (!isCliExecution) { + return; + } + + const cliOptions = parseServeCliOptions({ + argv: process.argv.slice(2), + env: process.env, + defaultPort, + cloudflareProvider, + managedLocalMode, + }); + + setExitOnShutdown(true); + startServer({ + port: cliOptions.port, + host: cliOptions.host, + tryCfTunnel: cliOptions.tryCfTunnel, + tunnelProvider: cliOptions.tunnelProvider, + tunnelMode: cliOptions.tunnelMode, + tunnelConfigPath: cliOptions.tunnelConfigPath, + tunnelToken: cliOptions.tunnelToken, + tunnelHostname: cliOptions.tunnelHostname, + attachSignals: true, + exitOnShutdown: true, + uiPassword: cliOptions.uiPassword, + }).catch((error) => { + console.error('Failed to start server:', error); + process.exit(1); + }); +}; diff --git a/packages/web/server/lib/opencode/cli-options.js b/packages/web/server/lib/opencode/cli-options.js new file mode 100644 index 00000000..2ded944e --- /dev/null +++ b/packages/web/server/lib/opencode/cli-options.js @@ -0,0 +1,128 @@ +export const parseServeCliOptions = ({ + argv = [], + env = {}, + defaultPort, + cloudflareProvider, + managedLocalMode, +}) => { + const args = Array.isArray(argv) ? [...argv] : []; + const envPassword = + env.OPENCHAMBER_UI_PASSWORD || + env.OPENCODE_UI_PASSWORD || + null; + const envCfTunnel = env.OPENCHAMBER_TRY_CF_TUNNEL === 'true'; + const envTunnelProvider = env.OPENCHAMBER_TUNNEL_PROVIDER || undefined; + const envTunnelMode = env.OPENCHAMBER_TUNNEL_MODE || undefined; + const envTunnelConfigRaw = env.OPENCHAMBER_TUNNEL_CONFIG; + const envTunnelConfig = typeof envTunnelConfigRaw === 'string' + ? (envTunnelConfigRaw.trim().length > 0 ? envTunnelConfigRaw.trim() : null) + : undefined; + const envTunnelToken = env.OPENCHAMBER_TUNNEL_TOKEN || undefined; + const envTunnelHostname = env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined; + + const options = { + port: defaultPort, + host: undefined, + uiPassword: envPassword, + tryCfTunnel: envCfTunnel, + tunnelProvider: envTunnelProvider, + tunnelMode: envTunnelMode, + tunnelConfigPath: envTunnelConfig, + tunnelToken: envTunnelToken, + tunnelHostname: envTunnelHostname, + }; + + const consumeValue = (currentIndex, inlineValue) => { + if (typeof inlineValue === 'string') { + return { value: inlineValue, nextIndex: currentIndex }; + } + const nextArg = args[currentIndex + 1]; + if (typeof nextArg === 'string' && !nextArg.startsWith('--')) { + return { value: nextArg, nextIndex: currentIndex + 1 }; + } + return { value: undefined, nextIndex: currentIndex }; + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg.startsWith('--')) { + continue; + } + + const eqIndex = arg.indexOf('='); + const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2); + const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined; + + if (optionName === 'port' || optionName === 'p') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + const parsedPort = parseInt(value ?? '', 10); + options.port = Number.isFinite(parsedPort) ? parsedPort : defaultPort; + continue; + } + + if (optionName === 'host') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.host = typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; + continue; + } + + if (optionName === 'ui-password') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.uiPassword = typeof value === 'string' ? value : ''; + continue; + } + + if (optionName === 'try-cf-tunnel') { + options.tryCfTunnel = true; + continue; + } + + if (optionName === 'tunnel-provider') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelProvider = typeof value === 'string' ? value : options.tunnelProvider; + continue; + } + + if (optionName === 'tunnel-mode') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelMode = typeof value === 'string' ? value : options.tunnelMode; + continue; + } + + if (optionName === 'tunnel-config') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelConfigPath = typeof value === 'string' ? value : null; + continue; + } + + if (optionName === 'tunnel-token') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelToken = typeof value === 'string' ? value : options.tunnelToken; + continue; + } + + if (optionName === 'tunnel-hostname') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelHostname = typeof value === 'string' ? value : options.tunnelHostname; + continue; + } + + if (optionName === 'tunnel') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.tunnelProvider = cloudflareProvider; + options.tunnelMode = managedLocalMode; + options.tunnelConfigPath = typeof value === 'string' ? value : null; + } + } + + return options; +}; diff --git a/packages/web/server/lib/opencode/config-entity-routes.js b/packages/web/server/lib/opencode/config-entity-routes.js new file mode 100644 index 00000000..3dadcd62 --- /dev/null +++ b/packages/web/server/lib/opencode/config-entity-routes.js @@ -0,0 +1,362 @@ +export const registerConfigEntityRoutes = (app, dependencies) => { + const { + resolveProjectDirectory, + resolveOptionalProjectDirectory, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, + } = dependencies; + + app.get('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const sources = getAgentSources(agentName, directory); + + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + + res.json({ + name: agentName, + sources: sources, + scope, + isBuiltIn: !sources.md.exists && !sources.json.exists + }); + } catch (error) { + console.error('Failed to get agent sources:', error); + res.status(500).json({ error: 'Failed to get agent configuration metadata' }); + } + }); + + app.get('/api/config/agents/:name/config', async (req, res) => { + try { + const agentName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const configInfo = getAgentConfig(agentName, directory); + res.json(configInfo); + } catch (error) { + console.error('Failed to get agent config:', error); + res.status(500).json({ error: 'Failed to get agent configuration' }); + } + }); + + app.post('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const { scope, ...config } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log('[Server] Creating agent:', agentName); + console.log('[Server] Config received:', JSON.stringify(config, null, 2)); + console.log('[Server] Scope:', scope, 'Working directory:', directory); + + createAgent(agentName, config, directory, scope); + await refreshOpenCodeAfterConfigChange('agent creation', { + agentName + }); + + res.json({ + success: true, + requiresReload: true, + message: `Agent ${agentName} created successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to create agent:', error); + res.status(500).json({ error: error.message || 'Failed to create agent' }); + } + }); + + app.patch('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const updates = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log(`[Server] Updating agent: ${agentName}`); + console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); + console.log('[Server] Working directory:', directory); + + updateAgent(agentName, updates, directory); + await refreshOpenCodeAfterConfigChange('agent update'); + + console.log(`[Server] Agent ${agentName} updated successfully`); + + res.json({ + success: true, + requiresReload: true, + message: `Agent ${agentName} updated successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to update agent:', error); + console.error('[Server] Error stack:', error.stack); + res.status(500).json({ error: error.message || 'Failed to update agent' }); + } + }); + + app.delete('/api/config/agents/:name', async (req, res) => { + try { + const agentName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + deleteAgent(agentName, directory); + await refreshOpenCodeAfterConfigChange('agent deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `Agent ${agentName} deleted successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to delete agent:', error); + res.status(500).json({ error: error.message || 'Failed to delete agent' }); + } + }); + + app.get('/api/config/mcp', async (req, res) => { + try { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + const configs = listMcpConfigs(directory); + res.json(configs); + } catch (error) { + console.error('[API:GET /api/config/mcp] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to list MCP configs' }); + } + }); + + app.get('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + const config = getMcpConfig(name, directory); + if (!config) { + return res.status(404).json({ error: `MCP server "${name}" not found` }); + } + res.json(config); + } catch (error) { + console.error('[API:GET /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to get MCP config' }); + } + }); + + app.post('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const { scope, ...config } = req.body || {}; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`); + + createMcpConfig(name, config, directory, scope); + await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name }); + + res.json({ + success: true, + requiresReload: true, + message: `MCP server "${name}" created. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[API:POST /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to create MCP server' }); + } + }); + + app.patch('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const updates = req.body; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`); + + updateMcpConfig(name, updates, directory); + await refreshOpenCodeAfterConfigChange('mcp update'); + + res.json({ + success: true, + requiresReload: true, + message: `MCP server "${name}" updated. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[API:PATCH /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to update MCP server' }); + } + }); + + app.delete('/api/config/mcp/:name', async (req, res) => { + try { + const name = req.params.name; + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`); + + deleteMcpConfig(name, directory); + await refreshOpenCodeAfterConfigChange('mcp deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `MCP server "${name}" deleted. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[API:DELETE /api/config/mcp/:name] Failed:', error); + res.status(500).json({ error: error.message || 'Failed to delete MCP server' }); + } + }); + + app.get('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const sources = getCommandSources(commandName, directory); + + const scope = sources.md.exists + ? sources.md.scope + : (sources.json.exists ? sources.json.scope : null); + + res.json({ + name: commandName, + sources: sources, + scope, + isBuiltIn: !sources.md.exists && !sources.json.exists + }); + } catch (error) { + console.error('Failed to get command sources:', error); + res.status(500).json({ error: 'Failed to get command configuration metadata' }); + } + }); + + app.post('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const { scope, ...config } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log('[Server] Creating command:', commandName); + console.log('[Server] Config received:', JSON.stringify(config, null, 2)); + console.log('[Server] Scope:', scope, 'Working directory:', directory); + + createCommand(commandName, config, directory, scope); + await refreshOpenCodeAfterConfigChange('command creation', { + commandName + }); + + res.json({ + success: true, + requiresReload: true, + message: `Command ${commandName} created successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to create command:', error); + res.status(500).json({ error: error.message || 'Failed to create command' }); + } + }); + + app.patch('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const updates = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log(`[Server] Updating command: ${commandName}`); + console.log('[Server] Updates:', JSON.stringify(updates, null, 2)); + console.log('[Server] Working directory:', directory); + + updateCommand(commandName, updates, directory); + await refreshOpenCodeAfterConfigChange('command update'); + + console.log(`[Server] Command ${commandName} updated successfully`); + + res.json({ + success: true, + requiresReload: true, + message: `Command ${commandName} updated successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to update command:', error); + console.error('[Server] Error stack:', error.stack); + res.status(500).json({ error: error.message || 'Failed to update command' }); + } + }); + + app.delete('/api/config/commands/:name', async (req, res) => { + try { + const commandName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + deleteCommand(commandName, directory); + await refreshOpenCodeAfterConfigChange('command deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `Command ${commandName} deleted successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to delete command:', error); + res.status(500).json({ error: error.message || 'Failed to delete command' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js new file mode 100644 index 00000000..9454a54f --- /dev/null +++ b/packages/web/server/lib/opencode/core-routes.js @@ -0,0 +1,186 @@ +export const registerServerStatusRoutes = (app, dependencies) => { + const { + process, + openchamberVersion, + runtimeName, + serverStartedAt, + gracefulShutdown, + getHealthSnapshot, + } = dependencies; + + app.get('/health', (_req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + ...getHealthSnapshot(), + }); + }); + + app.post('/api/system/shutdown', (_req, res) => { + res.json({ ok: true }); + gracefulShutdown({ exitProcess: false }).catch((error) => { + console.error('Shutdown request failed:', error?.message || error); + }); + }); + + app.get('/api/system/info', (_req, res) => { + res.json({ + openchamberVersion, + runtime: runtimeName, + pid: process.pid, + startedAt: serverStartedAt, + }); + }); +}; + +export const registerAuthAndAccessRoutes = (app, dependencies) => { + const { + tunnelAuthController, + uiAuthController, + readSettingsFromDiskMigrated, + normalizeTunnelSessionTtlMs, + } = dependencies; + + app.get('/auth/session', async (req, res) => { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req); + if (tunnelSession) { + return res.json({ authenticated: true, scope: 'tunnel' }); + } + tunnelAuthController.clearTunnelSessionCookie(req, res); + return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true }); + } + + try { + await uiAuthController.handleSessionStatus(req, res); + } catch { + res.status(500).json({ error: 'Internal server error' }); + } + }); + + app.post('/auth/session', (req, res) => { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true }); + } + return uiAuthController.handleSessionCreate(req, res); + }); + + app.get('/connect', async (req, res) => { + try { + const token = typeof req.query?.t === 'string' ? req.query.t : ''; + const settings = await readSettingsFromDiskMigrated(); + const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); + + const exchange = tunnelAuthController.exchangeBootstrapToken({ + req, + res, + token, + sessionTtlMs: tunnelSessionTtlMs, + }); + + res.setHeader('Cache-Control', 'no-store'); + + if (!exchange.ok) { + if (exchange.reason === 'rate-limited') { + res.setHeader('Retry-After', String(exchange.retryAfter || 60)); + return res.status(429).type('text/plain').send('Too many attempts. Please try again later.'); + } + return res.status(401).type('text/plain').send('Connection link is invalid or expired.'); + } + + return res.redirect(302, '/'); + } catch { + return res.status(500).type('text/plain').send('Failed to process connect request.'); + } + }); + + app.use('/api', async (req, res, next) => { + try { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + return tunnelAuthController.requireTunnelSession(req, res, next); + } + await uiAuthController.requireAuth(req, res, next); + } catch (err) { + next(err); + } + }); +}; + +export const registerSettingsUtilityRoutes = (app, dependencies) => { + const { + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + } = dependencies; + + app.get('/api/config/themes', async (_req, res) => { + try { + const customThemes = await readCustomThemesFromDisk(); + res.json({ themes: customThemes }); + } catch (error) { + console.error('Failed to load custom themes:', error); + res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' }); + } + }); + + app.post('/api/config/reload', async (_req, res) => { + try { + console.log('[Server] Manual configuration reload requested'); + + await refreshOpenCodeAfterConfigChange('manual configuration reload'); + + res.json({ + success: true, + requiresReload: true, + message: 'Configuration reloaded successfully. Refreshing interface…', + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to reload configuration:', error); + res.status(500).json({ + error: error.message || 'Failed to reload configuration', + success: false, + }); + } + }); +}; + +export const registerCommonRequestMiddleware = (app, dependencies) => { + const { express } = dependencies; + + app.use((req, res, next) => { + if ( + req.path.startsWith('/api/config/agents') || + req.path.startsWith('/api/config/commands') || + req.path.startsWith('/api/config/mcp') || + req.path.startsWith('/api/config/settings') || + req.path.startsWith('/api/config/skills') || + req.path.startsWith('/api/projects') || + req.path.startsWith('/api/fs') || + req.path.startsWith('/api/git') || + req.path.startsWith('/api/prompts') || + req.path.startsWith('/api/terminal') || + req.path.startsWith('/api/opencode') || + req.path.startsWith('/api/push') || + req.path.startsWith('/api/voice') || + req.path.startsWith('/api/tts') || + req.path.startsWith('/api/openchamber/tunnel') + ) { + express.json({ limit: '50mb' })(req, res, next); + } else if (req.path.startsWith('/api')) { + next(); + } else { + express.json({ limit: '50mb' })(req, res, next); + } + }); + + app.use(express.urlencoded({ extended: true, limit: '50mb' })); + + app.use((req, _res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); + next(); + }); +}; diff --git a/packages/web/server/lib/opencode/env-config.js b/packages/web/server/lib/opencode/env-config.js new file mode 100644 index 00000000..fc493f0b --- /dev/null +++ b/packages/web/server/lib/opencode/env-config.js @@ -0,0 +1,72 @@ +export const resolveOpenCodeEnvConfig = (options = {}) => { + const env = options.env && typeof options.env === 'object' ? options.env : {}; + const logger = options.logger ?? console; + + const configuredOpenCodePort = (() => { + const raw = + env.OPENCODE_PORT || + env.OPENCHAMBER_OPENCODE_PORT || + env.OPENCHAMBER_INTERNAL_PORT; + if (!raw) { + return null; + } + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + })(); + + const configuredOpenCodeHost = (() => { + const raw = typeof env.OPENCODE_HOST === 'string' ? env.OPENCODE_HOST.trim() : ''; + if (!raw) return null; + + const warnInvalidHost = (reason) => { + logger.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`); + }; + + let url; + try { + url = new URL(raw); + } catch { + warnInvalidHost('not a valid URL'); + return null; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`); + return null; + } + const port = parseInt(url.port, 10); + if (!Number.isFinite(port) || port <= 0) { + warnInvalidHost('must include an explicit port (example: http://hostname:4096)'); + return null; + } + if (url.pathname !== '/' || url.search || url.hash) { + warnInvalidHost('must not include path, query, or hash'); + return null; + } + return { origin: url.origin, port }; + })(); + + // OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set + const effectivePort = configuredOpenCodeHost?.port ?? configuredOpenCodePort; + + const configuredOpenCodeHostname = (() => { + const raw = env.OPENCHAMBER_OPENCODE_HOSTNAME; + if (typeof raw !== 'string') { + return '127.0.0.1'; + } + const trimmed = raw.trim(); + if (!trimmed) { + logger.warn( + `[config] Ignoring OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: empty after trimming`, + ); + return '127.0.0.1'; + } + return trimmed; + })(); + + return { + configuredOpenCodePort, + configuredOpenCodeHost, + effectivePort, + configuredOpenCodeHostname, + }; +}; diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js new file mode 100644 index 00000000..0d1abdf5 --- /dev/null +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -0,0 +1,908 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export const createOpenCodeEnvRuntime = (deps) => { + const { + state, + normalizeDirectoryPath, + readSettingsFromDiskMigrated, + ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + } = deps; + + const parseNullSeparatedEnvSnapshot = (raw) => { + if (typeof raw !== 'string' || raw.length === 0) { + return null; + } + + const result = {}; + const entries = raw.split('\0'); + for (const entry of entries) { + if (!entry) { + continue; + } + const idx = entry.indexOf('='); + if (idx <= 0) { + continue; + } + const key = entry.slice(0, idx); + const value = entry.slice(idx + 1); + result[key] = value; + } + + return Object.keys(result).length > 0 ? result : null; + }; + + const isExecutable = (filePath) => { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + if (process.platform === 'win32') { + const ext = path.extname(filePath).toLowerCase(); + if (!ext) return true; + return ['.exe', '.cmd', '.bat', '.com'].includes(ext); + } + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } + }; + + const searchPathFor = (binaryName) => { + const current = process.env.PATH || ''; + const parts = current.split(path.delimiter).filter(Boolean); + for (const dir of parts) { + const candidate = path.join(dir, binaryName); + if (isExecutable(candidate)) { + return candidate; + } + } + return null; + }; + + const prependToPath = (dir) => { + const trimmed = typeof dir === 'string' ? dir.trim() : ''; + if (!trimmed) return; + const current = process.env.PATH || ''; + const parts = current.split(path.delimiter).filter(Boolean); + if (parts.includes(trimmed)) return; + process.env.PATH = [trimmed, ...parts].join(path.delimiter); + }; + + const getWindowsShellEnvSnapshot = () => { + const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : ''); + + const psScript = + "Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }"; + + const powershellCandidates = [ + 'pwsh.exe', + 'powershell.exe', + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]; + + for (const shellPath of powershellCandidates) { + try { + const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + if (result.status !== 0) { + continue; + } + const parsed = parseResult(result.stdout); + if (parsed) { + return parsed; + } + } catch { + } + } + + const comspec = process.env.ComSpec || 'cmd.exe'; + try { + const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) { + return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0')); + } + } catch { + } + + return null; + }; + + const getLoginShellEnvSnapshot = () => { + if (state.cachedLoginShellEnvSnapshot !== undefined) { + return state.cachedLoginShellEnvSnapshot; + } + + if (process.platform === 'win32') { + const windowsSnapshot = getWindowsShellEnvSnapshot(); + state.cachedLoginShellEnvSnapshot = windowsSnapshot; + return windowsSnapshot; + } + + const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + + for (const shellPath of shellCandidates) { + if (!isExecutable(shellPath)) { + continue; + } + + try { + const result = spawnSync(shellPath, ['-lic', 'env -0'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + + if (result.status !== 0) { + continue; + } + + const parsed = parseNullSeparatedEnvSnapshot(result.stdout || ''); + if (parsed) { + state.cachedLoginShellEnvSnapshot = parsed; + return parsed; + } + } catch { + } + } + + state.cachedLoginShellEnvSnapshot = null; + return null; + }; + + const mergePathValues = (preferred, fallback) => { + const merged = new Set(); + + const addSegments = (value) => { + if (typeof value !== 'string' || !value) { + return; + } + for (const segment of value.split(path.delimiter)) { + if (segment) { + merged.add(segment); + } + } + }; + + addSegments(preferred); + addSegments(fallback); + + return Array.from(merged).join(path.delimiter); + }; + + const applyLoginShellEnvSnapshot = () => { + const snapshot = getLoginShellEnvSnapshot(); + if (!snapshot) { + return; + } + + const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']); + for (const [key, value] of Object.entries(snapshot)) { + if (skipKeys.has(key)) { + continue; + } + const existing = process.env[key]; + if (typeof existing === 'string' && existing.length > 0) { + continue; + } + process.env[key] = value; + } + + process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || ''); + }; + + const isWslExecutableValue = (value) => { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + if (!trimmed) return false; + return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); + }; + + const clearWslOpencodeResolution = () => { + state.useWslForOpencode = false; + state.resolvedWslBinary = null; + state.resolvedWslOpencodePath = null; + state.resolvedWslDistro = null; + }; + + const resolveWslExecutablePath = () => { + if (process.platform !== 'win32') { + return null; + } + + const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + try { + const result = spawnSync('where', ['wsl'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) { + return found; + } + } + } catch { + } + + const systemRoot = process.env.SystemRoot || 'C:\\Windows'; + const fallback = path.join(systemRoot, 'System32', 'wsl.exe'); + if (isExecutable(fallback)) { + return fallback; + } + + return null; + }; + + const buildWslExecArgs = (execArgs, distroOverride = null) => { + const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0 + ? distroOverride.trim() + : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; + + const prefix = distro ? ['-d', distro] : []; + return [...prefix, '--exec', ...execArgs]; + }; + + const probeWslForOpencode = () => { + if (process.platform !== 'win32') { + return null; + } + + const wslBinary = resolveWslExecutablePath(); + if (!wslBinary) { + return null; + } + + try { + const result = spawnSync( + wslBinary, + buildWslExecArgs(['sh', '-lc', 'command -v opencode']), + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 6000, + windowsHide: true, + } + ); + + if (result.status !== 0) { + return null; + } + + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines[0] || ''; + if (!found) { + return null; + } + + return { + wslBinary, + opencodePath: found, + distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }; + } catch { + return null; + } + }; + + const applyWslOpencodeResolution = ({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) => { + const resolvedWsl = wslBinary || resolveWslExecutablePath(); + if (!resolvedWsl) { + return null; + } + + state.useWslForOpencode = true; + state.resolvedWslBinary = resolvedWsl; + state.resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0 + ? opencodePath.trim() + : 'opencode'; + state.resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO; + state.resolvedOpencodeBinary = `wsl:${state.resolvedWslOpencodePath}`; + state.resolvedOpencodeBinarySource = source; + + delete process.env.OPENCODE_BINARY; + return state.resolvedOpencodeBinary; + }; + + const resolveOpencodeCliPath = () => { + const explicit = [ + process.env.OPENCODE_BINARY, + process.env.OPENCODE_PATH, + process.env.OPENCHAMBER_OPENCODE_PATH, + process.env.OPENCHAMBER_OPENCODE_BIN, + ] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'env'; + return candidate; + } + } + + const resolvedFromPath = searchPathFor('opencode'); + if (resolvedFromPath) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'path'; + return resolvedFromPath; + } + + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.opencode', 'bin', 'opencode'), + path.join(home, '.bun', 'bin', 'opencode'), + path.join(home, '.local', 'bin', 'opencode'), + path.join(home, 'bin', 'opencode'), + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + '/bin/opencode', + ]; + + const winFallbacks = (() => { + const userProfile = process.env.USERPROFILE || home; + const appData = process.env.APPDATA || ''; + const localAppData = process.env.LOCALAPPDATA || ''; + const programData = process.env.ProgramData || 'C:\\ProgramData'; + + return [ + path.join(userProfile, '.opencode', 'bin', 'opencode.exe'), + path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'), + path.join(appData, 'npm', 'opencode.cmd'), + path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'), + path.join(programData, 'chocolatey', 'bin', 'opencode.exe'), + path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), + path.join(userProfile, '.bun', 'bin', 'opencode.exe'), + path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), + localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', + ].filter(Boolean); + })(); + + const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; + for (const candidate of fallbacks) { + if (isExecutable(candidate)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'fallback'; + return candidate; + } + } + + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'where'; + return found; + } + } + } catch { + } + const wsl = probeWslForOpencode(); + if (wsl) { + return applyWslOpencodeResolution({ + wslBinary: wsl.wslBinary, + opencodePath: wsl.opencodePath, + source: 'wsl', + distro: wsl.distro, + }); + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'shell'; + return found; + } + } + } catch { + } + } + + return null; + }; + + const resolveNodeCliPath = () => { + const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const resolvedFromPath = searchPathFor('node'); + if (resolvedFromPath) { + return resolvedFromPath; + } + + const unixFallbacks = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/bin/node']; + for (const candidate of unixFallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) return found; + } + } catch { + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + } + } + + return null; + }; + + const resolveBunCliPath = () => { + const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const resolvedFromPath = searchPathFor('bun'); + if (resolvedFromPath) { + return resolvedFromPath; + } + + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.bun', 'bin', 'bun'), + '/opt/homebrew/bin/bun', + '/usr/local/bin/bun', + '/usr/bin/bun', + '/bin/bun', + ]; + for (const candidate of unixFallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + const userProfile = process.env.USERPROFILE || home; + const winFallbacks = [ + path.join(userProfile, '.bun', 'bin', 'bun.exe'), + path.join(userProfile, '.bun', 'bin', 'bun.cmd'), + ]; + for (const candidate of winFallbacks) { + if (isExecutable(candidate)) return candidate; + } + + try { + const result = spawnSync('where', ['bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) return found; + } + } catch { + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + } + } + + return null; + }; + + const ensureBunCliEnv = () => { + if (state.resolvedBunBinary) { + return state.resolvedBunBinary; + } + + const resolved = resolveBunCliPath(); + if (resolved) { + prependToPath(path.dirname(resolved)); + state.resolvedBunBinary = resolved; + return resolved; + } + + return null; + }; + + const ensureNodeCliEnv = () => { + if (state.resolvedNodeBinary) { + return state.resolvedNodeBinary; + } + + const resolved = resolveNodeCliPath(); + if (resolved) { + prependToPath(path.dirname(resolved)); + state.resolvedNodeBinary = resolved; + return resolved; + } + + return null; + }; + + const readShebang = (opencodePath) => { + if (!opencodePath || typeof opencodePath !== 'string') { + return null; + } + try { + const fd = fs.openSync(opencodePath, 'r'); + try { + const buf = Buffer.alloc(256); + const bytes = fs.readSync(fd, buf, 0, buf.length, 0); + const head = buf.subarray(0, bytes).toString('utf8'); + const firstLine = head.split(/\r?\n/, 1)[0] || ''; + if (!firstLine.startsWith('#!')) { + return null; + } + const shebang = firstLine.slice(2).trim(); + if (!shebang) { + return null; + } + return shebang; + } finally { + try { + fs.closeSync(fd); + } catch { + } + } + } catch { + return null; + } + }; + + const opencodeShimInterpreter = (opencodePath) => { + const shebang = readShebang(opencodePath); + if (!shebang) return null; + if (/\bnode\b/i.test(shebang)) return 'node'; + if (/\bbun\b/i.test(shebang)) return 'bun'; + return null; + }; + + const ensureOpencodeShimRuntime = (opencodePath) => { + const runtime = opencodeShimInterpreter(opencodePath); + if (runtime === 'node') { + ensureNodeCliEnv(); + } + if (runtime === 'bun') { + ensureBunCliEnv(); + } + }; + + const normalizeOpencodeBinarySetting = (raw) => { + if (typeof raw !== 'string') { + return null; + } + const trimmed = normalizeDirectoryPath(raw).trim(); + if (!trimmed) { + return ''; + } + + try { + const stat = fs.statSync(trimmed); + if (stat.isDirectory()) { + const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + return path.join(trimmed, bin); + } + } catch { + } + + return trimmed; + }; + + const applyOpencodeBinaryFromSettings = async () => { + try { + const settings = await readSettingsFromDiskMigrated(); + if (!settings || typeof settings !== 'object') { + return null; + } + if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) { + return null; + } + + const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary); + + if (normalized === '') { + delete process.env.OPENCODE_BINARY; + state.resolvedOpencodeBinary = null; + state.resolvedOpencodeBinarySource = null; + clearWslOpencodeResolution(); + return null; + } + + const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; + const explicitWslPath = process.platform === 'win32' && typeof raw === 'string' + ? raw.match(/^wsl:\s*(.+)$/i) + : null; + + if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) { + const probe = probeWslForOpencode(); + const applied = applyWslOpencodeResolution({ + wslBinary: probe?.wslBinary || resolveWslExecutablePath(), + opencodePath: explicitWslPath[1].trim(), + source: 'settings-wsl-path', + distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }); + if (applied) { + return applied; + } + } + + if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) { + const probe = probeWslForOpencode(); + const applied = applyWslOpencodeResolution({ + wslBinary: probe?.wslBinary || normalized || raw || null, + opencodePath: probe?.opencodePath || 'opencode', + source: 'settings-wsl', + distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO, + }); + if (applied) { + return applied; + } + } + + if (normalized && isExecutable(normalized)) { + clearWslOpencodeResolution(); + process.env.OPENCODE_BINARY = normalized; + prependToPath(path.dirname(normalized)); + state.resolvedOpencodeBinary = normalized; + state.resolvedOpencodeBinarySource = 'settings'; + ensureOpencodeShimRuntime(normalized); + return normalized; + } + + if (raw) { + console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); + } + } catch { + } + + return null; + }; + + const ensureOpencodeCliEnv = () => { + if (state.resolvedOpencodeBinary) { + if (state.useWslForOpencode) { + return state.resolvedOpencodeBinary; + } + ensureOpencodeShimRuntime(state.resolvedOpencodeBinary); + return state.resolvedOpencodeBinary; + } + + const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : ''; + if (existing && isExecutable(existing)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinary = existing; + state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'env'; + prependToPath(path.dirname(existing)); + ensureOpencodeShimRuntime(existing); + return state.resolvedOpencodeBinary; + } + + const resolved = resolveOpencodeCliPath(); + if (resolved) { + if (state.useWslForOpencode) { + state.resolvedOpencodeBinary = resolved; + state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'wsl'; + console.log(`Resolved opencode CLI via WSL: ${state.resolvedWslOpencodePath || 'opencode'}`); + return resolved; + } + + process.env.OPENCODE_BINARY = resolved; + prependToPath(path.dirname(resolved)); + ensureOpencodeShimRuntime(resolved); + state.resolvedOpencodeBinary = resolved; + state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'unknown'; + console.log(`Resolved opencode CLI: ${resolved}`); + return resolved; + } + + clearWslOpencodeResolution(); + return null; + }; + + const resolveGitBinaryForSpawn = () => { + if (process.platform !== 'win32') { + return 'git'; + } + + if (state.resolvedGitBinary) { + return state.resolvedGitBinary; + } + + const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean); + for (const candidate of explicit) { + if (isExecutable(candidate)) { + state.resolvedGitBinary = candidate; + return state.resolvedGitBinary; + } + } + + const candidates = []; + const normalizeGitCandidate = (candidate) => { + if (typeof candidate !== 'string') { + return ''; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return ''; + } + const ext = path.extname(trimmed).toLowerCase(); + if (ext === '.cmd' || ext === '.bat' || ext === '.com') { + const exeCandidate = trimmed.slice(0, -ext.length) + '.exe'; + if (isExecutable(exeCandidate)) { + return exeCandidate; + } + } + return trimmed; + }; + + const pathCandidate = normalizeGitCandidate(searchPathFor('git')); + if (pathCandidate && isExecutable(pathCandidate)) { + candidates.push(pathCandidate); + } + + const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe')); + if (pathExeCandidate && isExecutable(pathExeCandidate)) { + candidates.push(pathExeCandidate); + } + + const programRoots = [ + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + process.env.LocalAppData, + ] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean); + for (const root of programRoots) { + const installCandidates = [ + path.join(root, 'Git', 'cmd', 'git.exe'), + path.join(root, 'Git', 'bin', 'git.exe'), + path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'), + path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'), + path.join(root, 'Programs', 'Git', 'bin', 'git.exe'), + ]; + for (const candidate of installCandidates) { + const normalized = normalizeGitCandidate(candidate); + if (normalized && isExecutable(normalized)) { + candidates.push(normalized); + } + } + } + + const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe')); + state.resolvedGitBinary = preferredExe || candidates[0] || 'git.exe'; + return state.resolvedGitBinary; + }; + + const clearResolvedOpenCodeBinary = () => { + state.resolvedOpencodeBinary = null; + }; + + return { + applyLoginShellEnvSnapshot, + ensureOpencodeCliEnv, + applyOpencodeBinaryFromSettings, + getLoginShellEnvSnapshot, + resolveOpencodeCliPath, + isExecutable, + searchPathFor, + resolveGitBinaryForSpawn, + resolveWslExecutablePath, + buildWslExecArgs, + opencodeShimInterpreter, + clearResolvedOpenCodeBinary, + }; +}; diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js new file mode 100644 index 00000000..a2c8cad3 --- /dev/null +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -0,0 +1,216 @@ +import { registerFsRoutes } from '../fs/routes.js'; +import { registerQuotaRoutes } from '../quota/routes.js'; +import { registerGitHubRoutes } from '../github/routes.js'; +import { registerGitRoutes } from '../git/routes.js'; +import { registerConfigEntityRoutes } from './config-entity-routes.js'; +import { registerSettingsUtilityRoutes } from './core-routes.js'; +import { registerProjectIconRoutes } from './project-icon-routes.js'; +import { registerSkillRoutes } from './skill-routes.js'; +import { registerOpenCodeRoutes } from './routes.js'; + +export const createFeatureRoutesRuntime = (dependencies) => { + const { + clientReloadDelayMs, + } = dependencies; + + let quotaProviders = null; + const getQuotaProviders = async () => { + if (!quotaProviders) { + quotaProviders = await import('../quota/index.js'); + } + return quotaProviders; + }; + + const registerRoutes = async (app, routeDependencies) => { + const { + crypto, + fs, + os, + path, + fsPromises, + spawn, + resolveGitBinaryForSpawn, + createFsSearchRuntime, + openchamberDataDir, + openchamberUserConfigRoot, + normalizeDirectoryPath, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + validateDirectoryPath, + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort, + buildAugmentedPath, + } = routeDependencies; + + const { getProviderSources, removeProviderConfig } = await import('./index.js'); + + registerSettingsUtilityRoutes(app, { + readCustomThemesFromDisk, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + }); + + registerOpenCodeRoutes(app, { + crypto, + clientReloadDelayMs, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + validateDirectoryPath, + resolveProjectDirectory, + getProviderSources, + removeProviderConfig, + refreshOpenCodeAfterConfigChange, + }); + + registerProjectIconRoutes(app, { + fsPromises, + path, + crypto, + openchamberDataDir, + sanitizeProjects, + readSettingsFromDiskMigrated, + persistSettings, + createFsSearchRuntime, + spawn, + resolveGitBinaryForSpawn, + }); + + const { + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, + } = await import('./index.js'); + + registerConfigEntityRoutes(app, { + resolveProjectDirectory, + resolveOptionalProjectDirectory, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + getAgentSources, + getAgentConfig, + createAgent, + updateAgent, + deleteAgent, + getCommandSources, + createCommand, + updateCommand, + deleteCommand, + listMcpConfigs, + getMcpConfig, + createMcpConfig, + updateMcpConfig, + deleteMcpConfig, + }); + + const { + getSkillSources, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + SKILL_SCOPE, + SKILL_DIR, + } = await import('./index.js'); + + const { + getCuratedSkillsSources, + getCacheKey, + getCachedScan, + setCachedScan, + parseSkillRepoSource, + scanSkillsRepository, + installSkillsFromRepository, + scanClawdHubPage, + installSkillsFromClawdHub, + isClawdHubSource, + } = await import('../skills-catalog/index.js'); + const { getProfiles, getProfile } = await import('../git/index.js'); + + registerSkillRoutes(app, { + fs, + path, + os, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + readSettingsFromDisk, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort, + getSkillSources, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + SKILL_SCOPE, + SKILL_DIR, + getCuratedSkillsSources, + getCacheKey, + getCachedScan, + setCachedScan, + parseSkillRepoSource, + scanSkillsRepository, + installSkillsFromRepository, + scanClawdHubPage, + installSkillsFromClawdHub, + isClawdHubSource, + getProfiles, + getProfile, + }); + + registerQuotaRoutes(app, { getQuotaProviders }); + registerGitHubRoutes(app); + registerGitRoutes(app); + registerFsRoutes(app, { + os, + path, + fsPromises, + spawn, + crypto, + normalizeDirectoryPath, + resolveProjectDirectory, + buildAugmentedPath, + resolveGitBinaryForSpawn, + openchamberUserConfigRoot, + }); + }; + + return { + registerRoutes, + }; +}; diff --git a/packages/web/server/lib/opencode/hmr-state-runtime.js b/packages/web/server/lib/opencode/hmr-state-runtime.js new file mode 100644 index 00000000..c06da01c --- /dev/null +++ b/packages/web/server/lib/opencode/hmr-state-runtime.js @@ -0,0 +1,85 @@ +export const createHmrStateRuntime = (dependencies) => { + const { + globalThisLike, + os, + processLike, + stateKey, + } = dependencies; + + const getOrCreateHmrState = () => { + if (!globalThisLike[stateKey]) { + globalThisLike[stateKey] = { + openCodeProcess: null, + openCodePort: null, + openCodeWorkingDirectory: os.homedir(), + isShuttingDown: false, + signalsAttached: false, + userProvidedOpenCodePassword: undefined, + openCodeAuthPassword: null, + openCodeAuthSource: null, + }; + } + return globalThisLike[stateKey]; + }; + + const ensureUserProvidedOpenCodePassword = (hmrState) => { + if (typeof hmrState.userProvidedOpenCodePassword !== 'undefined') { + return; + } + const initialPassword = typeof processLike.env.OPENCODE_SERVER_PASSWORD === 'string' + ? processLike.env.OPENCODE_SERVER_PASSWORD.trim() + : ''; + hmrState.userProvidedOpenCodePassword = initialPassword || null; + }; + + const getUserProvidedOpenCodePassword = (hmrState) => ( + typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0 + ? hmrState.userProvidedOpenCodePassword + : null + ); + + const resolveOpenCodeAuthFromState = ({ hmrState, userProvidedOpenCodePassword }) => ({ + openCodeAuthPassword: + typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0 + ? hmrState.openCodeAuthPassword + : userProvidedOpenCodePassword, + openCodeAuthSource: + typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0 + ? hmrState.openCodeAuthSource + : (userProvidedOpenCodePassword ? 'user-env' : null), + }); + + const syncStateFromRuntime = (hmrState, runtime) => { + hmrState.openCodeProcess = runtime.openCodeProcess; + hmrState.openCodePort = runtime.openCodePort; + hmrState.openCodeBaseUrl = runtime.openCodeBaseUrl; + hmrState.isShuttingDown = runtime.isShuttingDown; + hmrState.signalsAttached = runtime.signalsAttached; + hmrState.openCodeWorkingDirectory = runtime.openCodeWorkingDirectory; + hmrState.openCodeAuthPassword = runtime.openCodeAuthPassword; + hmrState.openCodeAuthSource = runtime.openCodeAuthSource; + }; + + const restoreRuntimeFromState = ({ hmrState, userProvidedOpenCodePassword }) => { + const auth = resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword }); + return { + openCodeProcess: hmrState.openCodeProcess, + openCodePort: hmrState.openCodePort, + openCodeBaseUrl: hmrState.openCodeBaseUrl ?? null, + isShuttingDown: hmrState.isShuttingDown, + signalsAttached: hmrState.signalsAttached, + openCodeWorkingDirectory: hmrState.openCodeWorkingDirectory, + openCodeAuthPassword: auth.openCodeAuthPassword, + openCodeAuthSource: auth.openCodeAuthSource, + }; + }; + + return { + getOrCreateHmrState, + ensureUserProvidedOpenCodePassword, + getUserProvidedOpenCodePassword, + resolveOpenCodeAuthFromState, + syncStateFromRuntime, + restoreRuntimeFromState, + }; +}; diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js new file mode 100644 index 00000000..ece1826a --- /dev/null +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -0,0 +1,630 @@ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; + +export const createOpenCodeLifecycleRuntime = (deps) => { + const { + state, + env, + syncToHmrState, + syncFromHmrState, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + waitForReady, + normalizeApiPrefix, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + ensureLocalOpenCodeServerPassword, + buildWslExecArgs, + resolveWslExecutablePath, + opencodeShimInterpreter, + setOpenCodePort, + setDetectedOpenCodeApiPrefix, + setupProxy, + ensureOpenCodeApiPrefix, + clearResolvedOpenCodeBinary, + } = deps; + + const killProcessOnPort = (port) => { + if (!port) return; + try { + const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true }); + const output = result.stdout || ''; + const myPid = process.pid; + for (const pidStr of output.split(/\s+/)) { + const pid = parseInt(pidStr.trim(), 10); + if (pid && pid !== myPid) { + try { + spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 }); + } catch { + } + } + } + } catch { + } + }; + + const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => { + let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; + let args = ['serve', '--hostname', hostname, '--port', String(port)]; + + if (process.platform === 'win32' && state.useWslForOpencode) { + const wslBinary = state.resolvedWslBinary || resolveWslExecutablePath(); + if (!wslBinary) { + throw new Error('WSL executable not found while attempting to launch OpenCode from WSL'); + } + + const wslOpencode = state.resolvedWslOpencodePath && state.resolvedWslOpencodePath.trim().length > 0 + ? state.resolvedWslOpencodePath.trim() + : 'opencode'; + const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname; + + binary = wslBinary; + args = buildWslExecArgs([ + wslOpencode, + 'serve', + '--hostname', + serveHost, + '--port', + String(port), + ], state.resolvedWslDistro); + } + + if (process.platform === 'win32' && !state.useWslForOpencode) { + const interpreter = opencodeShimInterpreter(binary); + if (interpreter) { + args.unshift(binary); + binary = interpreter; + } else { + try { + const shimContent = fs.readFileSync(binary, 'utf8'); + const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/); + if (jsMatch) { + const candidate = path.resolve(path.dirname(binary), jsMatch[0]); + if (fs.existsSync(candidate)) { + const realInterp = opencodeShimInterpreter(candidate); + if (realInterp) { + args.unshift(candidate); + binary = realInterp; + } + } + } + } catch { + } + } + } + + const child = spawn(binary, args, { + cwd, + env: processEnv, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const url = await new Promise((resolve, reject) => { + let output = ''; + let done = false; + const finish = (handler, value) => { + if (done) return; + done = true; + clearTimeout(timer); + child.stdout?.off('data', onStdout); + child.stderr?.off('data', onStderr); + child.off('exit', onExit); + child.off('error', onError); + handler(value); + }; + + const onStdout = (chunk) => { + output += chunk.toString(); + const lines = output.split('\n'); + for (const line of lines) { + if (!line.startsWith('opencode server listening')) continue; + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (!match) { + finish(reject, new Error(`Failed to parse server url from output: ${line}`)); + return; + } + finish(resolve, match[1]); + return; + } + }; + + const onStderr = (chunk) => { + output += chunk.toString(); + }; + + const onExit = (code) => { + finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`)); + }; + + const onError = (error) => { + finish(reject, error); + }; + + const timer = setTimeout(() => { + finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`)); + }, timeout); + + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.on('exit', onExit); + child.on('error', onError); + }); + + return { + url, + close() { + try { + child.kill('SIGTERM'); + } catch { + } + }, + }; + }; + + const resolveManagedOpenCodePort = async (requestedPort, hostname = '127.0.0.1') => { + if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) { + return requestedPort; + } + + return await new Promise((resolve, reject) => { + const server = net.createServer(); + const cleanup = () => { + server.removeAllListeners('error'); + server.removeAllListeners('listening'); + }; + + server.once('error', (error) => { + cleanup(); + reject(error); + }); + + server.once('listening', () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : 0; + server.close(() => { + cleanup(); + if (port > 0) { + resolve(port); + return; + } + reject(new Error('Failed to allocate OpenCode port')); + }); + }); + + server.listen(0, hostname); + }); + }; + + const isOpenCodeProcessHealthy = async () => { + if (!state.openCodeProcess || !state.openCodePort) { + return false; + } + + try { + const response = await fetch(`http://127.0.0.1:${state.openCodePort}/session`, { + method: 'GET', + headers: getOpenCodeAuthHeaders(), + signal: AbortSignal.timeout(2000), + }); + return response.ok; + } catch { + return false; + } + }; + + const probeExternalOpenCode = async (port, origin) => { + if (!port || port <= 0) { + return false; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const base = origin ?? `http://127.0.0.1:${port}`; + const response = await fetch(`${base}/global/health`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: controller.signal, + }); + clearTimeout(timeout); + if (!response.ok) return false; + const body = await response.json().catch(() => null); + return body?.healthy === true; + } catch { + return false; + } + }; + + const waitForOpenCodePort = async (timeoutMs = 15000) => { + if (state.openCodePort !== null) { + return state.openCodePort; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + if (state.openCodePort !== null) { + return state.openCodePort; + } + } + + throw new Error('Timed out waiting for OpenCode port'); + }; + + const startOpenCode = async () => { + const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0; + const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME); + console.log( + desiredPort > 0 + ? `Starting OpenCode on requested port ${desiredPort}...` + : `Starting OpenCode on allocated port ${spawnPort}...` + ); + + await applyOpencodeBinaryFromSettings(); + ensureOpencodeCliEnv(); + const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true }); + + try { + const serverInstance = await createManagedOpenCodeServerProcess({ + hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME, + port: spawnPort, + timeout: 30000, + cwd: state.openCodeWorkingDirectory, + env: { + ...process.env, + OPENCODE_SERVER_PASSWORD: openCodePassword, + }, + }); + + if (!serverInstance || !serverInstance.url) { + throw new Error('OpenCode server started but URL is missing'); + } + + const url = new URL(serverInstance.url); + const port = parseInt(url.port, 10); + const prefix = normalizeApiPrefix(url.pathname); + + if (await waitForReady(serverInstance.url, 10000)) { + setOpenCodePort(port); + setDetectedOpenCodeApiPrefix(prefix); + + state.isOpenCodeReady = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + + return serverInstance; + } + + try { + serverInstance.close(); + } catch { + } + throw new Error('Server started but health check failed (timeout)'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + state.lastOpenCodeError = message; + state.openCodePort = null; + syncToHmrState(); + console.error(`Failed to start OpenCode: ${message}`); + throw error; + } + }; + + const restartOpenCode = async () => { + if (state.isShuttingDown) return; + if (state.currentRestartPromise) { + await state.currentRestartPromise; + return; + } + + state.currentRestartPromise = (async () => { + state.isRestartingOpenCode = true; + state.isOpenCodeReady = false; + state.openCodeNotReadySince = Date.now(); + console.log('Restarting OpenCode process...'); + + if (state.isExternalOpenCode) { + console.log('Re-probing external OpenCode server...'); + const probePort = state.openCodePort || env.ENV_CONFIGURED_OPENCODE_PORT || 4096; + const probeOrigin = state.openCodeBaseUrl ?? env.ENV_CONFIGURED_OPENCODE_HOST?.origin; + const healthy = await probeExternalOpenCode(probePort, probeOrigin); + if (healthy) { + console.log(`External OpenCode server on port ${probePort} is healthy`); + setOpenCodePort(probePort); + state.isOpenCodeReady = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else { + state.lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`; + console.error(state.lastOpenCodeError); + throw new Error(state.lastOpenCodeError); + } + + if (state.expressApp) { + setupProxy(state.expressApp); + ensureOpenCodeApiPrefix(); + } + return; + } + + const portToKill = state.openCodePort; + + if (state.openCodeProcess) { + console.log('Stopping existing OpenCode process...'); + try { + state.openCodeProcess.close(); + } catch (error) { + console.warn('Error closing OpenCode process:', error); + } + state.openCodeProcess = null; + syncToHmrState(); + } + + killProcessOnPort(portToKill); + await new Promise((resolve) => setTimeout(resolve, 250)); + + if (env.ENV_CONFIGURED_OPENCODE_PORT) { + console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`); + setOpenCodePort(env.ENV_CONFIGURED_OPENCODE_PORT); + } else { + state.openCodePort = null; + syncToHmrState(); + } + + state.openCodeApiPrefixDetected = true; + state.openCodeApiPrefix = ''; + if (state.openCodeApiDetectionTimer) { + clearTimeout(state.openCodeApiDetectionTimer); + state.openCodeApiDetectionTimer = null; + } + + state.lastOpenCodeError = null; + state.openCodeProcess = await startOpenCode(); + syncToHmrState(); + + if (state.expressApp) { + setupProxy(state.expressApp); + ensureOpenCodeApiPrefix(); + } + })(); + + try { + await state.currentRestartPromise; + } catch (error) { + console.error(`Failed to restart OpenCode: ${error.message}`); + state.lastOpenCodeError = error.message; + if (!env.ENV_CONFIGURED_OPENCODE_PORT) { + state.openCodePort = null; + syncToHmrState(); + } + state.openCodeApiPrefixDetected = true; + state.openCodeApiPrefix = ''; + throw error; + } finally { + state.currentRestartPromise = null; + state.isRestartingOpenCode = false; + } + }; + + const waitForOpenCodeReady = async (timeoutMs = 20000, intervalMs = 400) => { + if (!state.openCodePort) { + throw new Error('OpenCode port is not available'); + } + + const deadline = Date.now() + timeoutMs; + let lastError = null; + + while (Date.now() < deadline) { + try { + const [configResult, agentResult] = await Promise.all([ + fetch(buildOpenCodeUrl('/config', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }).catch((error) => error), + fetch(buildOpenCodeUrl('/agent', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }).catch((error) => error), + ]); + + if (configResult instanceof Error) { + lastError = configResult; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + if (!configResult.ok) { + lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + await configResult.json().catch(() => null); + + if (agentResult instanceof Error) { + lastError = agentResult; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + if (!agentResult.ok) { + lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + await agentResult.json().catch(() => []); + + state.isOpenCodeReady = true; + state.lastOpenCodeError = null; + return; + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + if (lastError) { + state.lastOpenCodeError = lastError.message || String(lastError); + throw lastError; + } + + const timeoutError = new Error('Timed out waiting for OpenCode to become ready'); + state.lastOpenCodeError = timeoutError.message; + throw timeoutError; + }; + + const waitForAgentPresence = async (agentName, timeoutMs = 15000, intervalMs = 300) => { + if (!state.openCodePort) { + throw new Error('OpenCode port is not available'); + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(buildOpenCodeUrl('/agent'), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + + if (response.ok) { + const agents = await response.json(); + if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) { + return; + } + } + } catch { + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Agent "${agentName}" not available after OpenCode restart`); + }; + + const refreshOpenCodeAfterConfigChange = async (reason, options = {}) => { + const { agentName } = options; + + console.log(`Refreshing OpenCode after ${reason}`); + clearResolvedOpenCodeBinary(); + await applyOpencodeBinaryFromSettings(); + + await restartOpenCode(); + + try { + await waitForOpenCodeReady(); + state.isOpenCodeReady = true; + state.openCodeNotReadySince = 0; + + if (agentName) { + await waitForAgentPresence(agentName); + } + + state.isOpenCodeReady = true; + state.openCodeNotReadySince = 0; + } catch (error) { + state.isOpenCodeReady = false; + state.openCodeNotReadySince = Date.now(); + console.error(`Failed to refresh OpenCode after ${reason}:`, error.message); + throw error; + } + }; + + const bootstrapOpenCodeAtStartup = async () => { + try { + syncFromHmrState(); + if (await isOpenCodeProcessHealthy()) { + console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`); + } else if (env.ENV_SKIP_OPENCODE_START && env.ENV_EFFECTIVE_PORT) { + const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`; + console.log(`Using external OpenCode server at ${label} (skip-start mode)`); + state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; + setOpenCodePort(env.ENV_EFFECTIVE_PORT); + state.isOpenCodeReady = true; + state.isExternalOpenCode = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else if (env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(env.ENV_EFFECTIVE_PORT, env.ENV_CONFIGURED_OPENCODE_HOST?.origin)) { + const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`; + console.log(`Auto-detected existing OpenCode server at ${label}`); + state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null; + setOpenCodePort(env.ENV_EFFECTIVE_PORT); + state.isOpenCodeReady = true; + state.isExternalOpenCode = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { + console.log('Auto-detected existing OpenCode server on default port 4096'); + setOpenCodePort(4096); + state.isOpenCodeReady = true; + state.isExternalOpenCode = true; + state.lastOpenCodeError = null; + state.openCodeNotReadySince = 0; + syncToHmrState(); + } else { + if (env.ENV_EFFECTIVE_PORT) { + console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`); + setOpenCodePort(env.ENV_EFFECTIVE_PORT); + } else { + state.openCodePort = null; + syncToHmrState(); + } + + state.lastOpenCodeError = null; + state.openCodeProcess = await startOpenCode(); + syncToHmrState(); + } + await waitForOpenCodePort(); + try { + await waitForOpenCodeReady(); + } catch (error) { + console.error(`OpenCode readiness check failed: ${error.message}`); + } + } catch (error) { + console.error(`Failed to start OpenCode: ${error.message}`); + console.log('Continuing without OpenCode integration...'); + state.lastOpenCodeError = error.message; + } + }; + + const startHealthMonitoring = (healthCheckIntervalMs) => { + if (state.healthCheckInterval) { + clearInterval(state.healthCheckInterval); + } + + state.healthCheckInterval = setInterval(async () => { + if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return; + + try { + const healthy = await isOpenCodeProcessHealthy(); + if (!healthy) { + console.log('OpenCode process not running, restarting...'); + await restartOpenCode(); + } + } catch (error) { + console.error(`Health check error: ${error.message}`); + } + }, healthCheckIntervalMs); + }; + + return { + killProcessOnPort, + startOpenCode, + restartOpenCode, + waitForOpenCodeReady, + waitForAgentPresence, + refreshOpenCodeAfterConfigChange, + bootstrapOpenCodeAtStartup, + startHealthMonitoring, + }; +}; diff --git a/packages/web/server/lib/opencode/network-runtime.js b/packages/web/server/lib/opencode/network-runtime.js new file mode 100644 index 00000000..7ca7874e --- /dev/null +++ b/packages/web/server/lib/opencode/network-runtime.js @@ -0,0 +1,98 @@ +export const createOpenCodeNetworkRuntime = (deps) => { + const { + state, + getOpenCodeAuthHeaders, + } = deps; + + const normalizeApiPrefix = (prefix) => { + if (!prefix) { + return ''; + } + + if (prefix.includes('://')) { + try { + const parsed = new URL(prefix); + return normalizeApiPrefix(parsed.pathname); + } catch { + return ''; + } + } + + const trimmed = prefix.trim(); + if (!trimmed || trimmed === '/') { + return ''; + } + const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading; + }; + + const waitForReady = async (url, timeoutMs = 10000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(`${url.replace(/\/+$/, '')}/global/health`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: controller.signal, + }); + clearTimeout(timeout); + + if (response.ok) { + const body = await response.json().catch(() => null); + if (body?.healthy === true) { + return true; + } + } + } catch { + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; + }; + + const setDetectedOpenCodeApiPrefix = () => { + state.openCodeApiPrefix = ''; + state.openCodeApiPrefixDetected = true; + if (state.openCodeApiDetectionTimer) { + clearTimeout(state.openCodeApiDetectionTimer); + state.openCodeApiDetectionTimer = null; + } + }; + + const buildOpenCodeUrl = (path, prefixOverride) => { + if (!state.openCodePort) { + throw new Error('OpenCode port is not available'); + } + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : ''); + const fullPath = `${prefix}${normalizedPath}`; + const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`; + return `${base}${fullPath}`; + }; + + const detectOpenCodeApiPrefix = () => { + state.openCodeApiPrefixDetected = true; + state.openCodeApiPrefix = ''; + return true; + }; + + const ensureOpenCodeApiPrefix = () => detectOpenCodeApiPrefix(); + + const scheduleOpenCodeApiDetection = () => { + return; + }; + + return { + waitForReady, + normalizeApiPrefix, + setDetectedOpenCodeApiPrefix, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + scheduleOpenCodeApiDetection, + }; +}; diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js new file mode 100644 index 00000000..faba7cb6 --- /dev/null +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -0,0 +1,284 @@ +export const registerOpenChamberRoutes = (app, dependencies) => { + const { + fs, + os, + path, + process, + server, + __dirname, + openchamberDataDir, + modelsDevApiUrl, + modelsMetadataCacheTtl, + readSettingsFromDiskMigrated, + fetchFreeZenModels, + getCachedZenModels, + } = dependencies; + + let cachedModelsMetadata = null; + let cachedModelsMetadataTimestamp = 0; + + app.get('/api/openchamber/update-check', async (req, res) => { + try { + const { checkForUpdates } = await import('../package-manager.js'); + const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); + const parseReportUsage = (value) => { + if (typeof value !== 'string') return true; + const normalized = value.trim().toLowerCase(); + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + return true; + }; + const inferDeviceClass = (ua) => { + const value = (ua || '').toLowerCase(); + if (!value) return 'unknown'; + if (value.includes('ipad') || value.includes('tablet')) return 'tablet'; + if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile'; + return 'desktop'; + }; + const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''; + + const updateInfo = await checkForUpdates({ + appType: parseString(req.query.appType), + deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent), + platform: parseString(req.query.platform), + arch: parseString(req.query.arch), + instanceMode: parseString(req.query.instanceMode), + currentVersion: parseString(req.query.currentVersion), + reportUsage: parseReportUsage(parseString(req.query.reportUsage)), + }); + res.json(updateInfo); + } catch (error) { + console.error('Failed to check for updates:', error); + res.status(500).json({ + available: false, + error: error instanceof Error ? error.message : 'Failed to check for updates', + }); + } + }); + + app.post('/api/openchamber/update-install', async (_req, res) => { + try { + const { spawn: spawnChild } = await import('child_process'); + const { + checkForUpdates, + getUpdateCommand, + detectPackageManager, + } = await import('../package-manager.js'); + + const updateInfo = await checkForUpdates(); + if (!updateInfo.available) { + return res.status(400).json({ error: 'No update available' }); + } + + const pm = detectPackageManager(); + const updateCmd = getUpdateCommand(pm); + const isContainer = + fs.existsSync('/.dockerenv') || + Boolean(process.env.CONTAINER) || + process.env.container === 'docker'; + + if (isContainer) { + res.json({ + success: true, + message: 'Update starting, server will stay online', + version: updateInfo.version, + packageManager: pm, + autoRestart: false, + }); + + setTimeout(() => { + console.log(`\nInstalling update using ${pm} (container mode)...`); + console.log(`Running: ${updateCmd}`); + + const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh'; + const shellFlag = process.platform === 'win32' ? '/c' : '-c'; + const child = spawnChild(shell, [shellFlag, updateCmd], { + detached: true, + stdio: 'ignore', + env: process.env, + }); + child.unref(); + }, 500); + + return; + } + + const currentPort = server.address()?.port || 3000; + const tmpDir = os.tmpdir(); + const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`); + let storedOptions = { port: currentPort, daemon: true }; + try { + const content = await fs.promises.readFile(instanceFilePath, 'utf8'); + storedOptions = JSON.parse(content); + } catch { + } + + const isWindows = process.platform === 'win32'; + const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`; + const quoteCmd = (value) => { + const stringValue = String(value); + return `"${stringValue.replace(/"/g, '""')}"`; + }; + + const cliPath = path.resolve(__dirname, '..', '..', 'bin', 'cli.js'); + const restartParts = [ + isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath), + isWindows ? quoteCmd(cliPath) : quotePosix(cliPath), + 'serve', + '--port', + String(storedOptions.port), + '--daemon', + ]; + let restartCmdPrimary = restartParts.join(' '); + let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`; + if (storedOptions.uiPassword) { + if (isWindows) { + const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); + restartCmdPrimary += ` --ui-password "${escapedPw}"`; + restartCmdFallback += ` --ui-password "${escapedPw}"`; + } else { + const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''"); + restartCmdPrimary += ` --ui-password '${escapedPw}'`; + restartCmdFallback += ` --ui-password '${escapedPw}'`; + } + } + const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`; + + res.json({ + success: true, + message: 'Update starting, server will restart shortly', + version: updateInfo.version, + packageManager: pm, + autoRestart: true, + }); + + setTimeout(() => { + console.log(`\nInstalling update using ${pm}...`); + console.log(`Running: ${updateCmd}`); + + const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh'; + const shellFlag = isWindows ? '/c' : '-c'; + const script = isWindows + ? ` + timeout /t 2 /nobreak >nul + ${updateCmd} + if %ERRORLEVEL% EQU 0 ( + echo Update successful, restarting OpenChamber... + ${restartCmd} + ) else ( + echo Update failed + exit /b 1 + ) + ` + : ` + sleep 2 + ${updateCmd} + if [ $? -eq 0 ]; then + echo "Update successful, restarting OpenChamber..." + ${restartCmd} + else + echo "Update failed" + exit 1 + fi + `; + + const updateLogPath = path.join(openchamberDataDir, 'update-install.log'); + let logFd = null; + try { + fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); + logFd = fs.openSync(updateLogPath, 'a'); + } catch (logError) { + console.warn('Failed to open update log file, continuing without log capture:', logError); + } + + const child = spawnChild(shell, [shellFlag, script], { + detached: true, + stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore', + env: process.env, + }); + child.unref(); + + if (logFd !== null) { + try { + fs.closeSync(logFd); + } catch { + } + } + + console.log('Update process spawned, shutting down server...'); + + setTimeout(() => { + process.exit(0); + }, 500); + }, 500); + } catch (error) { + console.error('Failed to install update:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Failed to install update', + }); + } + }); + + app.get('/api/openchamber/models-metadata', async (_req, res) => { + const now = Date.now(); + + if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) { + res.setHeader('Cache-Control', 'public, max-age=60'); + return res.json(cachedModelsMetadata); + } + + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; + + try { + const response = await fetch(modelsDevApiUrl, { + signal: controller?.signal, + headers: { + Accept: 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`models.dev responded with status ${response.status}`); + } + + const metadata = await response.json(); + cachedModelsMetadata = metadata; + cachedModelsMetadataTimestamp = Date.now(); + + res.setHeader('Cache-Control', 'public, max-age=300'); + res.json(metadata); + } catch (error) { + console.warn('Failed to fetch models.dev metadata via server:', error); + + if (cachedModelsMetadata) { + res.setHeader('Cache-Control', 'public, max-age=60'); + res.json(cachedModelsMetadata); + } else { + const statusCode = error?.name === 'AbortError' ? 504 : 502; + res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); + } + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + }); + + app.get('/api/zen/models', async (_req, res) => { + try { + const models = await fetchFreeZenModels(); + res.setHeader('Cache-Control', 'public, max-age=300'); + res.json({ models }); + } catch (error) { + console.warn('Failed to fetch zen models:', error); + const cachedZenModels = getCachedZenModels(); + if (cachedZenModels) { + res.setHeader('Cache-Control', 'public, max-age=60'); + res.json(cachedZenModels); + } else { + const statusCode = error?.name === 'AbortError' ? 504 : 502; + res.status(statusCode).json({ error: 'Failed to retrieve zen models' }); + } + } + }); +}; diff --git a/packages/web/server/lib/opencode/opencode-resolution-runtime.js b/packages/web/server/lib/opencode/opencode-resolution-runtime.js new file mode 100644 index 00000000..5495f8e9 --- /dev/null +++ b/packages/web/server/lib/opencode/opencode-resolution-runtime.js @@ -0,0 +1,67 @@ +export const createOpenCodeResolutionRuntime = (dependencies) => { + const { + path, + resolveOpencodeCliPath, + applyOpencodeBinaryFromSettings, + ensureOpencodeCliEnv, + opencodeShimInterpreter, + getResolvedState, + setResolvedOpencodeBinarySource, + } = dependencies; + + const getOpenCodeResolutionSnapshot = async (settings) => { + const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null; + + const { resolvedOpencodeBinarySource: previousSource } = getResolvedState(); + const detectedNow = resolveOpencodeCliPath(); + const { resolvedOpencodeBinarySource: rawDetectedSourceNow } = getResolvedState(); + setResolvedOpencodeBinarySource(previousSource); + + await applyOpencodeBinaryFromSettings(); + ensureOpencodeCliEnv(); + + const { + resolvedOpencodeBinary, + resolvedOpencodeBinarySource, + useWslForOpencode, + resolvedWslBinary, + resolvedWslOpencodePath, + resolvedWslDistro, + resolvedNodeBinary, + resolvedBunBinary, + } = getResolvedState(); + + const resolved = resolvedOpencodeBinary || null; + const source = resolvedOpencodeBinarySource || null; + const detectedSourceNow = + detectedNow && + resolved && + detectedNow === resolved && + rawDetectedSourceNow === 'env' && + source && + source !== 'env' + ? source + : rawDetectedSourceNow; + const shim = resolved ? opencodeShimInterpreter(resolved) : null; + + return { + configured, + resolved, + resolvedDir: resolved ? path.dirname(resolved) : null, + source, + detectedNow, + detectedSourceNow, + shim, + viaWsl: useWslForOpencode, + wslBinary: resolvedWslBinary || null, + wslPath: resolvedWslOpencodePath || null, + wslDistro: resolvedWslDistro || null, + node: resolvedNodeBinary || null, + bun: resolvedBunBinary || null, + }; + }; + + return { + getOpenCodeResolutionSnapshot, + }; +}; diff --git a/packages/web/server/lib/opencode/project-directory-runtime.js b/packages/web/server/lib/opencode/project-directory-runtime.js new file mode 100644 index 00000000..ee7d99a7 --- /dev/null +++ b/packages/web/server/lib/opencode/project-directory-runtime.js @@ -0,0 +1,109 @@ +export const createProjectDirectoryRuntime = (dependencies) => { + const { + fsPromises, + path, + normalizeDirectoryPath, + readSettingsFromDiskMigrated, + getReadSettingsFromDiskMigrated, + sanitizeProjects, + } = dependencies; + + const resolveDirectoryCandidate = (value) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const normalized = normalizeDirectoryPath(trimmed); + return path.resolve(normalized); + }; + + const validateDirectoryPath = async (candidate) => { + const resolved = resolveDirectoryCandidate(candidate); + if (!resolved) { + return { ok: false, error: 'Directory parameter is required' }; + } + try { + const stats = await fsPromises.stat(resolved); + if (!stats.isDirectory()) { + return { ok: false, error: 'Specified path is not a directory' }; + } + return { ok: true, directory: resolved }; + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return { ok: false, error: 'Directory not found' }; + } + if (err && typeof err === 'object' && err.code === 'EACCES') { + return { ok: false, error: 'Access to directory denied' }; + } + return { ok: false, error: 'Failed to validate directory' }; + } + }; + + const resolveProjectDirectory = async (req) => { + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requested = headerDirectory || queryDirectory || null; + + if (requested) { + const validated = await validateDirectoryPath(requested); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + return { directory: validated.directory, error: null }; + } + + const readSettings = typeof getReadSettingsFromDiskMigrated === 'function' + ? getReadSettingsFromDiskMigrated() + : readSettingsFromDiskMigrated; + const settings = await readSettings(); + const projects = sanitizeProjects(settings.projects) || []; + if (projects.length === 0) { + return { directory: null, error: 'Directory parameter or active project is required' }; + } + + const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : ''; + const active = projects.find((project) => project.id === activeId) || projects[0]; + if (!active || !active.path) { + return { directory: null, error: 'Directory parameter or active project is required' }; + } + + const validated = await validateDirectoryPath(active.path); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + + return { directory: validated.directory, error: null }; + }; + + const resolveOptionalProjectDirectory = async (req) => { + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requested = headerDirectory || queryDirectory || null; + + if (!requested) { + return { directory: null, error: null }; + } + + const validated = await validateDirectoryPath(requested); + if (!validated.ok) { + return { directory: null, error: validated.error }; + } + + return { directory: validated.directory, error: null }; + }; + + return { + resolveDirectoryCandidate, + validateDirectoryPath, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + }; +}; diff --git a/packages/web/server/lib/opencode/project-icon-routes.js b/packages/web/server/lib/opencode/project-icon-routes.js new file mode 100644 index 00000000..634ad972 --- /dev/null +++ b/packages/web/server/lib/opencode/project-icon-routes.js @@ -0,0 +1,397 @@ +export const registerProjectIconRoutes = (app, dependencies) => { + const { + fsPromises, + path, + crypto, + openchamberDataDir, + sanitizeProjects, + readSettingsFromDiskMigrated, + persistSettings, + createFsSearchRuntime, + spawn, + resolveGitBinaryForSpawn, + } = dependencies; + + const projectIconsDirPath = path.join(openchamberDataDir, 'project-icons'); + const projectIconMimeToExtension = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + 'image/x-icon': 'ico', + }; + const projectIconExtensionToMime = Object.fromEntries( + Object.entries(projectIconMimeToExtension).map(([mime, ext]) => [ext, mime]) + ); + const projectIconSupportedMimes = new Set(Object.keys(projectIconMimeToExtension)); + const projectIconMaxBytes = 5 * 1024 * 1024; + const projectIconThemeColors = { + light: '#111111', + dark: '#f5f5f5', + }; + const projectIconHexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/; + + const normalizeProjectIconMime = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'image/jpg') { + return 'image/jpeg'; + } + if (projectIconSupportedMimes.has(normalized)) { + return normalized; + } + return null; + }; + + const projectIconBaseName = (projectId) => { + const hash = crypto.createHash('sha1').update(projectId).digest('hex'); + return `project-${hash}`; + }; + + const projectIconPathForMime = (projectId, mime) => { + const normalizedMime = normalizeProjectIconMime(mime); + if (!normalizedMime) { + return null; + } + const ext = projectIconMimeToExtension[normalizedMime]; + return path.join(projectIconsDirPath, `${projectIconBaseName(projectId)}.${ext}`); + }; + + const projectIconPathCandidates = (projectId) => { + const base = projectIconBaseName(projectId); + return Object.values(projectIconMimeToExtension).map((ext) => path.join(projectIconsDirPath, `${base}.${ext}`)); + }; + + const removeProjectIconFiles = async (projectId, keepPath) => { + const candidates = projectIconPathCandidates(projectId); + await Promise.all(candidates.map(async (candidatePath) => { + if (keepPath && candidatePath === keepPath) { + return; + } + try { + await fsPromises.unlink(candidatePath); + } catch (error) { + if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { + throw error; + } + } + })); + }; + + const parseProjectIconDataUrl = (value) => { + if (typeof value !== 'string') { + return { ok: false, error: 'dataUrl is required' }; + } + + const trimmed = value.trim(); + const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i); + if (!match) { + return { ok: false, error: 'Invalid dataUrl format' }; + } + + const mime = normalizeProjectIconMime(match[1]); + if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) { + return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' }; + } + + try { + const base64 = match[2].replace(/\s+/g, ''); + const bytes = Buffer.from(base64, 'base64'); + if (bytes.length === 0) { + return { ok: false, error: 'Icon content is empty' }; + } + if (bytes.length > projectIconMaxBytes) { + return { ok: false, error: 'Icon exceeds size limit (5 MB)' }; + } + return { ok: true, mime, bytes }; + } catch { + return { ok: false, error: 'Failed to decode icon data' }; + } + }; + + const normalizeProjectIconThemeVariant = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'light' || normalized === 'dark') { + return normalized; + } + return null; + }; + + const normalizeProjectIconColor = (value) => { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + if (!projectIconHexColorPattern.test(normalized)) { + return null; + } + return normalized; + }; + + const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => { + if (typeof svgMarkup !== 'string') { + return svgMarkup; + } + + const color = iconColor || projectIconThemeColors[themeVariant]; + if (!color) { + return svgMarkup; + } + + const svgTagIndex = svgMarkup.search(/', svgTagIndex); + if (svgOpenTagEndIndex === -1) { + return svgMarkup; + } + + const overrideStyle = ``; + return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`; + }; + + const findProjectById = (settings, projectId) => { + const projects = sanitizeProjects(settings?.projects) || []; + const index = projects.findIndex((project) => project.id === projectId); + if (index === -1) { + return { projects, index: -1, project: null }; + } + return { projects, index, project: projects[index] }; + }; + + const fsSearchRuntime = createFsSearchRuntime({ + fsPromises, + path, + spawn, + resolveGitBinaryForSpawn, + }); + + app.get('/api/projects/:projectId/icon', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + const metadataMime = normalizeProjectIconMime(project.iconImage?.mime); + const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null; + const candidates = preferredPath + ? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)] + : projectIconPathCandidates(projectId); + + const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme; + const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery); + const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor; + const requestedIconColor = normalizeProjectIconColor(iconColorQuery); + + for (const iconPath of candidates) { + try { + const data = await fsPromises.readFile(iconPath); + const ext = path.extname(iconPath).slice(1).toLowerCase(); + const resolvedMime = metadataMime || projectIconExtensionToMime[ext] || 'application/octet-stream'; + const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime; + + if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) { + const svgMarkup = data.toString('utf8'); + const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(themedSvgMarkup); + } + + if (resolvedMime === 'image/svg+xml' && requestedIconColor) { + const svgMarkup = data.toString('utf8'); + const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor); + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(themedSvgMarkup); + } + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + return res.send(data); + } catch (error) { + if (!error || typeof error !== 'object' || error.code !== 'ENOENT') { + console.warn('Failed to read project icon:', error); + return res.status(500).json({ error: 'Failed to read project icon' }); + } + } + } + + return res.status(404).json({ error: 'Project icon not found' }); + } catch (error) { + console.warn('Failed to load project icon:', error); + return res.status(500).json({ error: 'Failed to load project icon' }); + } + }); + + app.put('/api/projects/:projectId/icon', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + const parsed = parseProjectIconDataUrl(req.body?.dataUrl); + if (!parsed.ok) { + return res.status(400).json({ error: parsed.error }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { projects, project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + const iconPath = projectIconPathForMime(projectId, parsed.mime); + if (!iconPath) { + return res.status(400).json({ error: 'Unsupported icon format' }); + } + + await fsPromises.mkdir(projectIconsDirPath, { recursive: true }); + await fsPromises.writeFile(iconPath, parsed.bytes); + await removeProjectIconFiles(projectId, iconPath); + + const updatedAt = Date.now(); + const nextProjects = projects.map((entry) => ( + entry.id === projectId + ? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } } + : entry + )); + const updatedSettings = await persistSettings({ projects: nextProjects }); + const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; + + return res.json({ project: updatedProject, settings: updatedSettings }); + } catch (error) { + console.warn('Failed to upload project icon:', error); + return res.status(500).json({ error: 'Failed to upload project icon' }); + } + }); + + app.delete('/api/projects/:projectId/icon', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { projects, project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + await removeProjectIconFiles(projectId); + + const nextProjects = projects.map((entry) => ( + entry.id === projectId + ? { ...entry, iconImage: null } + : entry + )); + const updatedSettings = await persistSettings({ projects: nextProjects }); + const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; + + return res.json({ project: updatedProject, settings: updatedSettings }); + } catch (error) { + console.warn('Failed to remove project icon:', error); + return res.status(500).json({ error: 'Failed to remove project icon' }); + } + }); + + app.post('/api/projects/:projectId/icon/discover', async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : ''; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + try { + const settings = await readSettingsFromDiskMigrated(); + const { projects, project } = findProjectById(settings, projectId); + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + const force = req.body?.force === true; + if (project.iconImage?.source === 'custom' && !force) { + return res.json({ + project, + skipped: true, + reason: 'custom-icon-present', + }); + } + + const faviconCandidates = await fsSearchRuntime.searchFilesystemFiles(project.path, { + limit: 200, + query: 'favicon', + includeHidden: true, + respectGitignore: false, + }); + + const filtered = faviconCandidates + .filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path)) + .sort((a, b) => a.path.length - b.path.length); + + const selected = filtered[0]; + if (!selected) { + return res.status(404).json({ error: 'No favicon found in project' }); + } + + const ext = path.extname(selected.path).slice(1).toLowerCase(); + const mime = projectIconExtensionToMime[ext] || null; + if (!mime) { + return res.status(415).json({ error: 'Unsupported favicon format' }); + } + + const bytes = await fsPromises.readFile(selected.path); + if (bytes.length === 0) { + return res.status(400).json({ error: 'Discovered icon is empty' }); + } + if (bytes.length > projectIconMaxBytes) { + return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' }); + } + + const iconPath = projectIconPathForMime(projectId, mime); + if (!iconPath) { + return res.status(415).json({ error: 'Unsupported favicon format' }); + } + + await fsPromises.mkdir(projectIconsDirPath, { recursive: true }); + await fsPromises.writeFile(iconPath, bytes); + await removeProjectIconFiles(projectId, iconPath); + + const updatedAt = Date.now(); + const nextProjects = projects.map((entry) => ( + entry.id === projectId + ? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } } + : entry + )); + const updatedSettings = await persistSettings({ projects: nextProjects }); + const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null; + + return res.json({ + project: updatedProject, + settings: updatedSettings, + discoveredPath: selected.path, + }); + } catch (error) { + console.warn('Failed to discover project icon:', error); + return res.status(500).json({ error: 'Failed to discover project icon' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js new file mode 100644 index 00000000..ffeb2ae4 --- /dev/null +++ b/packages/web/server/lib/opencode/proxy.js @@ -0,0 +1,169 @@ +import express from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; + +export const registerOpenCodeProxy = (app, deps) => { + const { + fs, + os, + path, + OPEN_CODE_READY_GRACE_MS, + getRuntime, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + } = deps; + + if (app.get('opencodeProxyConfigured')) { + return; + } + + const runtime = getRuntime(); + if (runtime.openCodePort) { + console.log(`Setting up proxy to OpenCode on port ${runtime.openCodePort}`); + } else { + console.log('Setting up OpenCode API gate (OpenCode not started yet)'); + } + app.set('opencodeProxyConfigured', true); + + // Ensure API prefix is detected before proxying + app.use('/api', (_req, _res, next) => { + ensureOpenCodeApiPrefix(); + next(); + }); + + // Readiness gate — return 503 while OpenCode is starting/restarting + app.use('/api', (req, res, next) => { + if ( + req.path.startsWith('/themes/custom') || + req.path.startsWith('/push') || + req.path.startsWith('/config/agents') || + req.path.startsWith('/config/opencode-resolution') || + req.path.startsWith('/config/settings') || + req.path.startsWith('/config/skills') || + req.path === '/config/reload' || + req.path === '/health' + ) { + return next(); + } + + const runtimeState = getRuntime(); + const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince; + const stillWaiting = + (!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) || + runtimeState.isRestartingOpenCode || + !runtimeState.openCodePort; + + if (stillWaiting) { + return res.status(503).json({ + error: 'OpenCode is restarting', + restarting: true, + }); + } + + next(); + }); + + // Windows: session merge for cross-directory session listing + if (process.platform === 'win32') { + app.get('/api/session', async (req, res, next) => { + const rawUrl = req.originalUrl || req.url || ''; + if (rawUrl.includes('directory=')) return next(); + + try { + const authHeaders = getOpenCodeAuthHeaders(); + const fetchOpts = { + method: 'GET', + headers: { Accept: 'application/json', ...authHeaders }, + signal: AbortSignal.timeout(10000), + }; + const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts); + const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : []; + const globalSessions = Array.isArray(globalPayload) ? globalPayload : []; + + const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); + let projectDirs = []; + try { + const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(settingsRaw); + projectDirs = (settings.projects || []) + .map((project) => (typeof project?.path === 'string' ? project.path.trim() : '')) + .filter(Boolean); + } catch { + } + + const seen = new Set( + globalSessions + .map((session) => (session && typeof session.id === 'string' ? session.id : null)) + .filter((id) => typeof id === 'string') + ); + const extraSessions = []; + for (const dir of projectDirs) { + const candidates = Array.from(new Set([ + dir, + dir.replace(/\\/g, '/'), + dir.replace(/\//g, '\\'), + ])); + for (const candidateDir of candidates) { + const encoded = encodeURIComponent(candidateDir); + try { + const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); + if (dirRes.ok) { + const dirPayload = await dirRes.json().catch(() => []); + const dirSessions = Array.isArray(dirPayload) ? dirPayload : []; + for (const session of dirSessions) { + const id = session && typeof session.id === 'string' ? session.id : null; + if (id && !seen.has(id)) { + seen.add(id); + extraSessions.push(session); + } + } + } + } catch { + } + } + } + + const merged = [...globalSessions, ...extraSessions]; + merged.sort((a, b) => { + const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0; + const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0; + return bTime - aTime; + }); + console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); + return res.json(merged); + } catch (error) { + console.log(`[SessionMerge] Error: ${error.message}, falling through`); + next(); + } + }); + } + + // http-proxy-middleware handles SSE, large bodies, timeouts correctly + const apiProxy = createProxyMiddleware({ + target: `http://127.0.0.1:${runtime.openCodePort || 3902}`, + changeOrigin: true, + pathRewrite: { '^/api': '' }, + // Dynamic target — port can change after restart + router: () => { + const rt = getRuntime(); + return `http://127.0.0.1:${rt.openCodePort || 3902}`; + }, + on: { + proxyReq: (proxyReq) => { + // Inject OpenCode auth headers + const authHeaders = getOpenCodeAuthHeaders(); + if (authHeaders.Authorization) { + proxyReq.setHeader('Authorization', authHeaders.Authorization); + } + }, + error: (err, _req, res) => { + console.error('[proxy] OpenCode proxy error:', err.message); + if (res && !res.headersSent && typeof res.status === 'function') { + res.status(503).json({ error: 'OpenCode service unavailable' }); + } + }, + }, + }); + + app.use('/api', apiProxy); +}; diff --git a/packages/web/server/lib/opencode/pwa-manifest-routes.js b/packages/web/server/lib/opencode/pwa-manifest-routes.js new file mode 100644 index 00000000..fd99e49b --- /dev/null +++ b/packages/web/server/lib/opencode/pwa-manifest-routes.js @@ -0,0 +1,238 @@ +const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant'; + +export const registerPwaManifestRoute = (app, dependencies) => { + const { + process, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, + } = dependencies; + + const recentPwaSessionsCache = new Map(); + + const getRecentPwaSessionShortcuts = async (req) => { + const now = Date.now(); + + const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null })); + const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string' + ? resolvedDirectoryResult.directory + : null; + + const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global'; + const cached = recentPwaSessionsCache.get(cacheKey); + if (cached && now - cached.at < 5000) { + return cached.data; + } + + const normalizeShortcutTitle = (value, fallback) => { + const normalized = normalizePwaAppName(value, fallback); + return normalized.length > 48 ? normalized.slice(0, 48) : normalized; + }; + + const toFiniteNumber = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; + }; + + const normalizeDirectory = (value) => { + if (typeof value !== 'string') { + return ''; + } + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + const normalized = trimmed.replace(/\\/g, '/'); + if (normalized === '/') { + return '/'; + } + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; + }; + + const sessionUpdatedAt = (session) => { + const time = session && typeof session.time === 'object' ? session.time : null; + return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0; + }; + + const filterSessionsByDirectory = (sessions, directory) => { + const normalizedDirectory = normalizeDirectory(directory); + if (!normalizedDirectory) { + return sessions; + } + + const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`; + return sessions.filter((session) => { + const sessionDirectory = normalizeDirectory(session?.directory); + if (!sessionDirectory) { + return false; + } + return sessionDirectory === normalizedDirectory || (prefix !== '/' && sessionDirectory.startsWith(prefix)); + }); + }; + + const listSessions = async (directory) => { + const query = (() => { + if (typeof directory !== 'string' || directory.length === 0) { + return ''; + } + const preparedDirectory = process.platform === 'win32' + ? directory.replace(/\//g, '\\\\') + : directory; + return `?directory=${encodeURIComponent(preparedDirectory)}`; + })(); + + const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(2500), + }); + + if (!response.ok) { + return []; + } + + const payload = await response.json().catch(() => null); + return Array.isArray(payload) ? payload : []; + }; + + try { + let payload = []; + + if (preferredDirectory) { + const scopedPayload = await listSessions(preferredDirectory); + const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory); + + if (filteredScopedPayload.length > 0) { + payload = filteredScopedPayload; + } else { + const globalPayload = await listSessions(null); + const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory); + payload = filteredGlobalPayload.length > 0 ? filteredGlobalPayload : globalPayload; + } + } else { + payload = await listSessions(null); + } + + const seen = new Set(); + const rows = []; + + for (const item of payload) { + if (!item || typeof item !== 'object') { + continue; + } + + const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : ''; + if (!id || seen.has(id)) { + continue; + } + + seen.add(id); + const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`); + const updatedAt = sessionUpdatedAt(item); + + rows.push({ id, title, updatedAt }); + } + + rows.sort((a, b) => b.updatedAt - a.updatedAt); + + const shortcuts = rows.slice(0, 3).map((session) => ({ + name: session.title, + short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title, + description: 'Open recent session', + url: `/?session=${encodeURIComponent(session.id)}`, + icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + })); + + recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts }); + return shortcuts; + } catch { + recentPwaSessionsCache.set(cacheKey, { at: now, data: [] }); + return []; + } + }; + + app.get('/manifest.webmanifest', async (req, res) => { + const hasQueryOverride = + typeof req.query?.pwa_name === 'string' + || typeof req.query?.app_name === 'string' + || typeof req.query?.appName === 'string'; + + let queryValueRaw = ''; + if (typeof req.query?.pwa_name === 'string') { + queryValueRaw = req.query.pwa_name; + } else if (typeof req.query?.app_name === 'string') { + queryValueRaw = req.query.app_name; + } else if (typeof req.query?.appName === 'string') { + queryValueRaw = req.query.appName; + } + + const queryOverrideName = normalizePwaAppName(queryValueRaw, ''); + + let storedName = ''; + try { + const settings = await readSettingsFromDiskMigrated(); + storedName = normalizePwaAppName(settings?.pwaAppName, ''); + } catch { + storedName = ''; + } + + const appName = hasQueryOverride + ? (queryOverrideName || DEFAULT_PWA_APP_NAME) + : (storedName || DEFAULT_PWA_APP_NAME); + + const shortName = appName.length > 30 ? appName.slice(0, 30) : appName; + const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req); + + const manifest = { + name: appName, + short_name: shortName, + description: 'Web interface companion for OpenCode AI coding agent', + id: '/', + start_url: '/', + scope: '/', + display: 'standalone', + background_color: '#151313', + theme_color: '#edb449', + orientation: 'any', + icons: [ + { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' }, + { src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, + { src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' }, + { src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' }, + { src: '/favicon-32.png', sizes: '32x32', type: 'image/png' }, + { src: '/favicon-16.png', sizes: '16x16', type: 'image/png' }, + ], + shortcuts: [ + { + name: 'Appearance Settings', + short_name: 'Settings', + description: 'Open appearance settings', + url: '/?settings=appearance', + icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }], + }, + ...recentSessionShortcuts, + ], + categories: ['developer', 'tools', 'productivity'], + lang: 'en', + }; + + res.setHeader('Cache-Control', 'no-store, must-revalidate'); + res.type('application/manifest+json'); + res.send(JSON.stringify(manifest)); + }); +}; diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js new file mode 100644 index 00000000..3dde098e --- /dev/null +++ b/packages/web/server/lib/opencode/routes.js @@ -0,0 +1,206 @@ +export const registerOpenCodeRoutes = (app, dependencies) => { + const { + crypto, + clientReloadDelayMs, + getOpenCodeResolutionSnapshot, + formatSettingsResponse, + readSettingsFromDisk, + readSettingsFromDiskMigrated, + persistSettings, + sanitizeProjects, + validateDirectoryPath, + resolveProjectDirectory, + getProviderSources, + removeProviderConfig, + refreshOpenCodeAfterConfigChange, + } = dependencies; + + let authLibrary = null; + const getAuthLibrary = async () => { + if (!authLibrary) { + authLibrary = await import('./auth.js'); + } + return authLibrary; + }; + + app.get('/api/config/settings', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + res.json(formatSettingsResponse(settings)); + } catch (error) { + console.error('Failed to read settings:', error); + res.status(500).json({ error: 'Failed to read settings' }); + } + }); + + app.get('/api/config/opencode-resolution', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + const resolution = await getOpenCodeResolutionSnapshot(settings); + res.json(resolution); + } catch (error) { + console.error('Failed to resolve OpenCode binary:', error); + res.status(500).json({ error: 'Failed to resolve OpenCode binary' }); + } + }); + + app.put('/api/config/settings', async (req, res) => { + console.log('[API:PUT /api/config/settings] Received request'); + try { + const updated = await persistSettings(req.body ?? {}); + console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`); + res.json(updated); + } catch (error) { + console.error('[API:PUT /api/config/settings] Failed to save settings:', error); + console.error('[API:PUT /api/config/settings] Error stack:', error.stack); + res.status(500).json({ error: 'Failed to save settings' }); + } + }); + + app.get('/api/provider/:providerId/source', async (req, res) => { + try { + const { providerId } = req.params; + if (!providerId) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requestedDirectory = headerDirectory || queryDirectory || null; + + let directory = null; + const resolved = await resolveProjectDirectory(req); + if (resolved.directory) { + directory = resolved.directory; + } else if (requestedDirectory) { + return res.status(400).json({ error: resolved.error }); + } + + const sources = getProviderSources(providerId, directory); + const { getProviderAuth } = await getAuthLibrary(); + const auth = getProviderAuth(providerId); + sources.sources.auth.exists = Boolean(auth); + + return res.json({ + providerId, + sources: sources.sources, + }); + } catch (error) { + console.error('Failed to get provider sources:', error); + return res.status(500).json({ error: error.message || 'Failed to get provider sources' }); + } + }); + + app.delete('/api/provider/:providerId/auth', async (req, res) => { + try { + const { providerId } = req.params; + if (!providerId) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + + const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth'; + const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + const requestedDirectory = headerDirectory || queryDirectory || null; + let directory = null; + + if (scope === 'project' || requestedDirectory) { + const resolved = await resolveProjectDirectory(req); + if (!resolved.directory) { + return res.status(400).json({ error: resolved.error }); + } + directory = resolved.directory; + } else { + const resolved = await resolveProjectDirectory(req); + if (resolved.directory) { + directory = resolved.directory; + } + } + + let removed = false; + if (scope === 'auth') { + const { removeProviderAuth } = await getAuthLibrary(); + removed = removeProviderAuth(providerId); + } else if (scope === 'user' || scope === 'project' || scope === 'custom') { + removed = removeProviderConfig(providerId, directory, scope); + } else if (scope === 'all') { + const { removeProviderAuth } = await getAuthLibrary(); + const authRemoved = removeProviderAuth(providerId); + const userRemoved = removeProviderConfig(providerId, directory, 'user'); + const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false; + const customRemoved = removeProviderConfig(providerId, directory, 'custom'); + removed = authRemoved || userRemoved || projectRemoved || customRemoved; + } else { + return res.status(400).json({ error: 'Invalid scope' }); + } + + if (removed) { + await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`); + } + + return res.json({ + success: true, + removed, + requiresReload: removed, + message: removed ? 'Provider disconnected successfully' : 'Provider was not connected', + reloadDelayMs: removed ? clientReloadDelayMs : undefined, + }); + } catch (error) { + console.error('Failed to disconnect provider:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect provider' }); + } + }); + + app.post('/api/opencode/directory', async (req, res) => { + try { + const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : ''; + if (!requestedPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + const validated = await validateDirectoryPath(requestedPath); + if (!validated.ok) { + return res.status(400).json({ error: validated.error }); + } + + const resolvedPath = validated.directory; + const currentSettings = await readSettingsFromDisk(); + const existingProjects = sanitizeProjects(currentSettings.projects) || []; + const existing = existingProjects.find((project) => project.path === resolvedPath) || null; + + const nextProjects = existing + ? existingProjects + : [ + ...existingProjects, + { + id: crypto.randomUUID(), + path: resolvedPath, + addedAt: Date.now(), + lastOpenedAt: Date.now(), + }, + ]; + + const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id; + + const updated = await persistSettings({ + projects: nextProjects, + activeProjectId, + lastDirectory: resolvedPath, + }); + + return res.json({ + success: true, + restarted: false, + path: resolvedPath, + settings: updated, + }); + } catch (error) { + console.error('Failed to update OpenCode working directory:', error); + return res.status(500).json({ error: error.message || 'Failed to update working directory' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/server-startup-runtime.js b/packages/web/server/lib/opencode/server-startup-runtime.js new file mode 100644 index 00000000..ddcc4700 --- /dev/null +++ b/packages/web/server/lib/opencode/server-startup-runtime.js @@ -0,0 +1,138 @@ +export const createServerStartupRuntime = (dependencies) => { + const { + process, + crypto, + server, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached, + setSignalsAttached, + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + } = dependencies; + + const resolveBindHost = (host) => + host + || (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0 + ? process.env.OPENCHAMBER_HOST.trim() + : '127.0.0.1'); + + const startListeningAndMaybeTunnel = async ({ + port, + bindHost, + startupTunnelRequest, + onTunnelReady, + }) => { + let activePort = port; + + await new Promise((resolve, reject) => { + const onError = (error) => { + server.off('error', onError); + reject(error); + }; + server.once('error', onError); + const onListening = async () => { + server.off('error', onError); + const addressInfo = server.address(); + activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port; + + try { + process.send?.({ type: 'openchamber:ready', port: activePort }); + } catch { + // ignore + } + + const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]') + ? 'localhost' + : (bindHost.includes(':') ? `[${bindHost}]` : bindHost); + console.log(`OpenChamber server listening on ${bindHost}:${activePort}`); + console.log(`Health check: http://${displayHost}:${activePort}/health`); + console.log(`Web interface: http://${displayHost}:${activePort}`); + + if (startupTunnelRequest) { + const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK + ? 'Quick Tunnel' + : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL + ? 'Managed Local Tunnel' + : (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel')); + console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`); + try { + const { publicUrl, mode } = await startTunnelWithNormalizedRequest({ + provider: startupTunnelRequest.provider, + mode: startupTunnelRequest.mode, + intent: startupTunnelRequest.intent, + hostname: startupTunnelRequest.hostname, + token: startupTunnelRequest.token, + configPath: startupTunnelRequest.configPath, + selectedPresetId: '', + selectedPresetName: '', + }); + if (publicUrl) { + tunnelAuthController.setActiveTunnel({ + tunnelId: crypto.randomUUID(), + publicUrl, + mode, + }); + const settings = await readSettingsFromDiskMigrated(); + const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null + ? null + : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); + const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); + const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; + if (onTunnelReady) { + onTunnelReady(publicUrl, connectUrl); + } else { + console.log(`\n🌐 Tunnel URL: ${connectUrl}`); + console.log('🔑 One-time connect link (expires after first use)\n'); + } + } else if (onTunnelReady) { + onTunnelReady(publicUrl, null); + } + } catch (error) { + console.error(`Failed to start tunnel: ${error.message}`); + console.log('Continuing without tunnel...'); + } + } + + resolve(); + }; + + server.listen(port, bindHost, onListening); + }); + + return { activePort }; + }; + + const attachProcessHandlers = ({ attachSignals }) => { + if (attachSignals && !getSignalsAttached()) { + const handleSignal = async () => { + await gracefulShutdown(); + }; + process.on('SIGTERM', handleSignal); + process.on('SIGINT', handleSignal); + process.on('SIGQUIT', handleSignal); + setSignalsAttached(true); + syncToHmrState(); + } + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + gracefulShutdown(); + }); + }; + + return { + resolveBindHost, + startListeningAndMaybeTunnel, + attachProcessHandlers, + }; +}; diff --git a/packages/web/server/lib/opencode/server-utils-runtime.js b/packages/web/server/lib/opencode/server-utils-runtime.js new file mode 100644 index 00000000..940d0c92 --- /dev/null +++ b/packages/web/server/lib/opencode/server-utils-runtime.js @@ -0,0 +1,168 @@ +import { registerOpenCodeProxy } from './proxy.js'; + +export const createServerUtilsRuntime = (dependencies) => { + const { + fs, + os, + path, + process, + openCodeReadyGraceMs, + longRequestTimeoutMs, + getRuntime, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + getUiNotificationClients, + getOpenCodePort, + setOpenCodePortState, + syncToHmrState, + markOpenCodeNotReady, + setOpenCodeNotReadySince, + clearLastOpenCodeError, + getLoginShellPath, + } = dependencies; + + const setOpenCodePort = (port) => { + if (!Number.isFinite(port) || port <= 0) { + return; + } + + const numericPort = Math.trunc(port); + const currentPort = getOpenCodePort(); + const portChanged = currentPort !== numericPort; + + if (portChanged || currentPort === null) { + setOpenCodePortState(numericPort); + syncToHmrState(); + console.log(`Detected OpenCode port: ${numericPort}`); + + if (portChanged) { + markOpenCodeNotReady(); + } + setOpenCodeNotReadySince(Date.now()); + } + + clearLastOpenCodeError(); + }; + + const waitForOpenCodePort = async (timeoutMs = 15000) => { + if (getOpenCodePort() !== null) { + return getOpenCodePort(); + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + if (getOpenCodePort() !== null) { + return getOpenCodePort(); + } + } + + throw new Error('Timed out waiting for OpenCode port'); + }; + + const buildAugmentedPath = () => { + const augmented = new Set(); + + const loginShellPath = getLoginShellPath(); + if (loginShellPath) { + for (const segment of loginShellPath.split(path.delimiter)) { + if (segment) { + augmented.add(segment); + } + } + } + + const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean); + for (const segment of current) { + augmented.add(segment); + } + + return Array.from(augmented).join(path.delimiter); + }; + + const parseSseDataPayload = (block) => { + if (!block || typeof block !== 'string') { + return null; + } + const dataLines = block + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).replace(/^\s/, '')); + + if (dataLines.length === 0) { + return null; + } + + const payloadText = dataLines.join('\n').trim(); + if (!payloadText) { + return null; + } + + try { + const parsed = JSON.parse(payloadText); + if ( + parsed && + typeof parsed === 'object' && + typeof parsed.payload === 'object' && + parsed.payload !== null + ) { + return parsed.payload; + } + return parsed; + } catch { + return null; + } + }; + + const fetchArraySnapshot = async (route, invalidMessage) => { + if (!getOpenCodePort()) { + throw new Error('OpenCode port is not available'); + } + + const response = await fetch(buildOpenCodeUrl(route), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch ${invalidMessage} (status ${response.status})`); + } + + const payload = await response.json().catch(() => null); + if (!Array.isArray(payload)) { + throw new Error(`Invalid ${invalidMessage} payload from OpenCode`); + } + return payload; + }; + + const fetchAgentsSnapshot = () => fetchArraySnapshot('/agent', 'agents snapshot'); + const fetchProvidersSnapshot = () => fetchArraySnapshot('/provider', 'providers snapshot'); + const fetchModelsSnapshot = () => fetchArraySnapshot('/model', 'models snapshot'); + + const setupProxy = (app) => { + registerOpenCodeProxy(app, { + fs, + os, + path, + OPEN_CODE_READY_GRACE_MS: openCodeReadyGraceMs, + LONG_REQUEST_TIMEOUT_MS: longRequestTimeoutMs, + getRuntime, + getOpenCodeAuthHeaders, + buildOpenCodeUrl, + ensureOpenCodeApiPrefix, + getUiNotificationClients, + }); + }; + + return { + setOpenCodePort, + waitForOpenCodePort, + buildAugmentedPath, + parseSseDataPayload, + fetchAgentsSnapshot, + fetchProvidersSnapshot, + fetchModelsSnapshot, + setupProxy, + }; +}; diff --git a/packages/web/server/lib/opencode/session-runtime.js b/packages/web/server/lib/opencode/session-runtime.js new file mode 100644 index 00000000..0f181281 --- /dev/null +++ b/packages/web/server/lib/opencode/session-runtime.js @@ -0,0 +1,320 @@ +const SESSION_COOLDOWN_DURATION_MS = 2000; +const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; + +const extractSessionStatusUpdate = (payload) => { + if (!payload || payload.type !== 'session.status') { + return null; + } + + const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; + const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; + const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; + const type = typeof info.type === 'string' ? info.type.trim() : ''; + + if (!sessionId || !type) { + return null; + } + + return { + sessionId, + type, + eventId: typeof payload.id === 'string' ? payload.id : '', + attempt: typeof info.attempt === 'number' ? info.attempt : undefined, + message: typeof info.message === 'string' ? info.message : undefined, + next: typeof info.next === 'number' ? info.next : undefined, + }; +}; + +const deriveSessionActivityTransitions = (payload) => { + const update = extractSessionStatusUpdate(payload); + if (!update) { + return []; + } + + if (update.type === 'busy' || update.type === 'retry') { + return [{ sessionId: update.sessionId, phase: 'busy' }]; + } + if (update.type === 'idle') { + return [{ sessionId: update.sessionId, phase: 'cooldown' }]; + } + return []; +}; + +export const createSessionRuntime = ({ writeSseEvent, getNotificationClients }) => { + const sessionActivityPhases = new Map(); + const sessionActivityCooldowns = new Map(); + const sessionStates = new Map(); + const sessionAttentionStates = new Map(); + + const getOrCreateAttentionState = (sessionId) => { + if (!sessionId || typeof sessionId !== 'string') return null; + + let state = sessionAttentionStates.get(sessionId); + if (!state) { + state = { + needsAttention: false, + lastUserMessageAt: null, + lastStatusChangeAt: Date.now(), + viewedByClients: new Set(), + status: 'idle', + }; + sessionAttentionStates.set(sessionId, state); + } + return state; + }; + + const setSessionActivityPhase = (sessionId, phase) => { + if (!sessionId || typeof sessionId !== 'string') return false; + + const current = sessionActivityPhases.get(sessionId); + if (current?.phase === phase) return false; + if (phase === 'cooldown' && current?.phase !== 'busy') { + return false; + } + + const existingTimer = sessionActivityCooldowns.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + sessionActivityCooldowns.delete(sessionId); + } + + sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); + + if (phase === 'cooldown') { + const timer = setTimeout(() => { + const now = sessionActivityPhases.get(sessionId); + if (now?.phase === 'cooldown') { + sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); + } + sessionActivityCooldowns.delete(sessionId); + }, SESSION_COOLDOWN_DURATION_MS); + sessionActivityCooldowns.set(sessionId, timer); + } + + return true; + }; + + const updateSessionAttentionStatus = (sessionId, status) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + + const prevStatus = state.status; + state.status = status; + state.lastStatusChangeAt = Date.now(); + + if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') { + if (state.lastUserMessageAt && state.viewedByClients.size === 0) { + state.needsAttention = true; + } + } + }; + + const updateSessionState = (sessionId, status, eventId, metadata = {}) => { + if (!sessionId || typeof sessionId !== 'string') return; + + const now = Date.now(); + const existing = sessionStates.get(sessionId); + const existingAttentionState = sessionAttentionStates.get(sessionId); + if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) { + return; + } + + sessionStates.set(sessionId, { + status, + lastUpdateAt: now, + lastEventId: eventId || `server-${now}`, + metadata: { ...existing?.metadata, ...metadata }, + }); + + updateSessionAttentionStatus(sessionId, status); + const attentionState = sessionAttentionStates.get(sessionId); + const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention; + const clients = getNotificationClients(); + if (clients.size > 0 && (!existing || existing.status !== status || attentionChanged)) { + const state = sessionStates.get(sessionId); + for (const res of clients) { + try { + writeSseEvent(res, { + type: 'openchamber:session-status', + properties: { + sessionId, + status: state.status, + timestamp: state.lastUpdateAt, + metadata: state.metadata, + needsAttention: attentionState?.needsAttention ?? false, + }, + }); + } catch { + } + } + } + + const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle'; + setSessionActivityPhase(sessionId, phase); + }; + + const getSessionStateSnapshot = () => { + const result = {}; + const now = Date.now(); + for (const [sessionId, data] of sessionStates) { + if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue; + result[sessionId] = { + status: data.status, + lastUpdateAt: data.lastUpdateAt, + metadata: data.metadata, + }; + } + return result; + }; + + const getSessionState = (sessionId) => { + if (!sessionId) return null; + return sessionStates.get(sessionId) || null; + }; + + const markSessionViewed = (sessionId, clientId) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + + const wasNeedsAttention = state.needsAttention; + state.viewedByClients.add(clientId); + + if (wasNeedsAttention) { + state.needsAttention = false; + const clients = getNotificationClients(); + for (const res of clients) { + try { + writeSseEvent(res, { + type: 'openchamber:session-status', + properties: { + sessionId, + status: state.status, + timestamp: Date.now(), + metadata: {}, + needsAttention: false, + }, + }); + } catch { + } + } + } + }; + + const markSessionUnviewed = (sessionId, clientId) => { + const state = sessionAttentionStates.get(sessionId); + if (!state) return; + state.viewedByClients.delete(clientId); + }; + + const markUserMessageSent = (sessionId) => { + const state = getOrCreateAttentionState(sessionId); + if (!state) return; + state.lastUserMessageAt = Date.now(); + }; + + const getSessionAttentionSnapshot = () => { + const result = {}; + const now = Date.now(); + for (const [sessionId, state] of sessionAttentionStates) { + if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue; + result[sessionId] = { + needsAttention: state.needsAttention, + lastUserMessageAt: state.lastUserMessageAt, + lastStatusChangeAt: state.lastStatusChangeAt, + status: state.status, + isViewed: state.viewedByClients.size > 0, + }; + } + return result; + }; + + const getSessionAttentionState = (sessionId) => { + if (!sessionId) return null; + const state = sessionAttentionStates.get(sessionId); + if (!state) return null; + return { + needsAttention: state.needsAttention, + lastUserMessageAt: state.lastUserMessageAt, + lastStatusChangeAt: state.lastStatusChangeAt, + status: state.status, + isViewed: state.viewedByClients.size > 0, + }; + }; + + const getSessionActivitySnapshot = () => { + const result = {}; + for (const [sessionId, data] of sessionActivityPhases) { + result[sessionId] = { type: data.phase }; + } + return result; + }; + + const resetAllSessionActivityToIdle = () => { + for (const timer of sessionActivityCooldowns.values()) { + clearTimeout(timer); + } + sessionActivityCooldowns.clear(); + const now = Date.now(); + for (const [sessionId] of sessionActivityPhases) { + sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now }); + } + }; + + const cleanupOldSessionStates = () => { + const now = Date.now(); + for (const [sessionId, data] of sessionStates) { + if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) { + sessionStates.delete(sessionId); + } + } + for (const [sessionId, state] of sessionAttentionStates) { + if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) { + sessionAttentionStates.delete(sessionId); + } + } + }; + + const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS); + + const processOpenCodeSsePayload = (payload) => { + const transitions = deriveSessionActivityTransitions(payload); + for (const activity of transitions) { + setSessionActivityPhase(activity.sessionId, activity.phase); + } + + if (payload && payload.type === 'session.status') { + const update = extractSessionStatusUpdate(payload); + if (update) { + updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, { + attempt: update.attempt, + message: update.message, + next: update.next, + }); + } + } + }; + + const dispose = () => { + clearInterval(cleanupInterval); + for (const timer of sessionActivityCooldowns.values()) { + clearTimeout(timer); + } + sessionActivityCooldowns.clear(); + }; + + return { + processOpenCodeSsePayload, + getSessionActivitySnapshot, + getSessionStateSnapshot, + getSessionAttentionSnapshot, + getSessionState, + getSessionAttentionState, + markSessionViewed, + markSessionUnviewed, + markUserMessageSent, + resetAllSessionActivityToIdle, + dispose, + }; +}; diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js new file mode 100644 index 00000000..71e2d219 --- /dev/null +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -0,0 +1,602 @@ +export const createSettingsHelpers = (dependencies) => { + const { + normalizePathForPersistence, + normalizeDirectoryPath, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + sanitizeTypographySizesPartial, + normalizeStringArray, + sanitizeModelRefs, + sanitizeSkillCatalogs, + sanitizeProjects, + } = dependencies; + + const PWA_APP_NAME_MAX_LENGTH = 64; + + const normalizePwaAppName = (value, fallback = '') => { + if (typeof value !== 'string') { + return fallback; + } + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return fallback; + } + return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH); + }; + + const sanitizeSettingsUpdate = (payload) => { + if (!payload || typeof payload !== 'object') { + return {}; + } + + const candidate = payload; + const result = {}; + + if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) { + result.themeId = candidate.themeId; + } + if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) { + result.themeVariant = candidate.themeVariant; + } + if (typeof candidate.useSystemTheme === 'boolean') { + result.useSystemTheme = candidate.useSystemTheme; + } + if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) { + result.lightThemeId = candidate.lightThemeId; + } + if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) { + result.darkThemeId = candidate.darkThemeId; + } + if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) { + result.splashBgLight = candidate.splashBgLight.trim(); + } + if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) { + result.splashFgLight = candidate.splashFgLight.trim(); + } + if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) { + result.splashBgDark = candidate.splashBgDark.trim(); + } + if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) { + result.splashFgDark = candidate.splashFgDark.trim(); + } + if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) { + const normalized = normalizePathForPersistence(candidate.lastDirectory); + if (typeof normalized === 'string' && normalized.length > 0) { + result.lastDirectory = normalized; + } + } + if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { + const normalized = normalizePathForPersistence(candidate.homeDirectory); + if (typeof normalized === 'string' && normalized.length > 0) { + result.homeDirectory = normalized; + } + } + + // Absolute path to the opencode CLI binary (optional override). + // Accept empty-string to clear (we persist an empty string sentinel so the running + // process can reliably drop a previously applied OPENCODE_BINARY override). + if (typeof candidate.opencodeBinary === 'string') { + const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim(); + result.opencodeBinary = normalized; + } + if (Array.isArray(candidate.projects)) { + const projects = sanitizeProjects(candidate.projects); + if (projects) { + result.projects = projects; + } + } + if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) { + result.activeProjectId = candidate.activeProjectId; + } + + if (Array.isArray(candidate.approvedDirectories)) { + result.approvedDirectories = normalizeStringArray( + candidate.approvedDirectories + .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + ); + } + if (Array.isArray(candidate.securityScopedBookmarks)) { + result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks); + } + if (Array.isArray(candidate.pinnedDirectories)) { + result.pinnedDirectories = normalizeStringArray( + candidate.pinnedDirectories + .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + ); + } + + + if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) { + result.uiFont = candidate.uiFont; + } + if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) { + result.monoFont = candidate.monoFont; + } + if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) { + result.markdownDisplayMode = candidate.markdownDisplayMode; + } + if (typeof candidate.githubClientId === 'string') { + const trimmed = candidate.githubClientId.trim(); + if (trimmed.length > 0) { + result.githubClientId = trimmed; + } + } + if (typeof candidate.githubScopes === 'string') { + const trimmed = candidate.githubScopes.trim(); + if (trimmed.length > 0) { + result.githubScopes = trimmed; + } + } + if (typeof candidate.showReasoningTraces === 'boolean') { + result.showReasoningTraces = candidate.showReasoningTraces; + } + if (typeof candidate.showTextJustificationActivity === 'boolean') { + result.showTextJustificationActivity = candidate.showTextJustificationActivity; + } + if (typeof candidate.showDeletionDialog === 'boolean') { + result.showDeletionDialog = candidate.showDeletionDialog; + } + if (typeof candidate.nativeNotificationsEnabled === 'boolean') { + result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled; + } + if (typeof candidate.notificationMode === 'string') { + const mode = candidate.notificationMode.trim(); + if (mode === 'always' || mode === 'hidden-only') { + result.notificationMode = mode; + } + } + if (typeof candidate.notifyOnSubtasks === 'boolean') { + result.notifyOnSubtasks = candidate.notifyOnSubtasks; + } + if (typeof candidate.notifyOnCompletion === 'boolean') { + result.notifyOnCompletion = candidate.notifyOnCompletion; + } + if (typeof candidate.notifyOnError === 'boolean') { + result.notifyOnError = candidate.notifyOnError; + } + if (typeof candidate.notifyOnQuestion === 'boolean') { + result.notifyOnQuestion = candidate.notifyOnQuestion; + } + if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') { + result.notificationTemplates = candidate.notificationTemplates; + } + if (typeof candidate.summarizeLastMessage === 'boolean') { + result.summarizeLastMessage = candidate.summarizeLastMessage; + } + if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) { + result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold)); + } + if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) { + result.summaryLength = Math.max(10, Math.round(candidate.summaryLength)); + } + if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) { + result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength)); + } + if (typeof candidate.usageAutoRefresh === 'boolean') { + result.usageAutoRefresh = candidate.usageAutoRefresh; + } + if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) { + result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs))); + } + if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') { + result.usageDisplayMode = candidate.usageDisplayMode; + } + if (Array.isArray(candidate.usageDropdownProviders)) { + result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders); + } + if (typeof candidate.autoDeleteEnabled === 'boolean') { + result.autoDeleteEnabled = candidate.autoDeleteEnabled; + } + if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { + const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays))); + result.autoDeleteAfterDays = normalizedDays; + } + if (candidate.tunnelBootstrapTtlMs === null) { + result.tunnelBootstrapTtlMs = null; + } else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) { + result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs); + } + if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) { + result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs); + } + if (typeof candidate.tunnelProvider === 'string') { + const provider = normalizeTunnelProvider(candidate.tunnelProvider); + if (provider) { + result.tunnelProvider = provider; + } + } + if (typeof candidate.tunnelMode === 'string') { + result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode); + } + if (candidate.managedLocalTunnelConfigPath === null) { + result.managedLocalTunnelConfigPath = null; + } else if (typeof candidate.managedLocalTunnelConfigPath === 'string') { + const trimmed = candidate.managedLocalTunnelConfigPath.trim(); + result.managedLocalTunnelConfigPath = trimmed.length > 0 ? normalizeOptionalPath(trimmed) : null; + } + if (typeof candidate.managedRemoteTunnelHostname === 'string') { + const hostname = normalizeManagedRemoteTunnelHostname(candidate.managedRemoteTunnelHostname); + result.managedRemoteTunnelHostname = hostname; + } + if (candidate.managedRemoteTunnelToken === null) { + result.managedRemoteTunnelToken = null; + } else if (typeof candidate.managedRemoteTunnelToken === 'string') { + result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim(); + } + const managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets); + if (managedRemoteTunnelPresets) { + result.managedRemoteTunnelPresets = managedRemoteTunnelPresets; + } + const managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens); + if (managedRemoteTunnelPresetTokens) { + result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens; + } + if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') { + const id = candidate.managedRemoteTunnelSelectedPresetId.trim(); + result.managedRemoteTunnelSelectedPresetId = id || undefined; + } + + const typography = sanitizeTypographySizesPartial(candidate.typographySizes); + if (typography) { + result.typographySizes = typography; + } + + if (typeof candidate.defaultModel === 'string') { + const trimmed = candidate.defaultModel.trim(); + result.defaultModel = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.defaultVariant === 'string') { + const trimmed = candidate.defaultVariant.trim(); + result.defaultVariant = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.defaultAgent === 'string') { + const trimmed = candidate.defaultAgent.trim(); + result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.defaultGitIdentityId === 'string') { + const trimmed = candidate.defaultGitIdentityId.trim(); + result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.queueModeEnabled === 'boolean') { + result.queueModeEnabled = candidate.queueModeEnabled; + } + if (typeof candidate.autoCreateWorktree === 'boolean') { + result.autoCreateWorktree = candidate.autoCreateWorktree; + } + if (typeof candidate.gitmojiEnabled === 'boolean') { + result.gitmojiEnabled = candidate.gitmojiEnabled; + } + if (typeof candidate.zenModel === 'string') { + const trimmed = candidate.zenModel.trim(); + result.zenModel = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.gitProviderId === 'string') { + const trimmed = candidate.gitProviderId.trim(); + result.gitProviderId = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.gitModelId === 'string') { + const trimmed = candidate.gitModelId.trim(); + result.gitModelId = trimmed.length > 0 ? trimmed : undefined; + } + if (typeof candidate.pwaAppName === 'string') { + result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined); + } + if (typeof candidate.toolCallExpansion === 'string') { + const mode = candidate.toolCallExpansion.trim(); + if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') { + result.toolCallExpansion = mode; + } + } + if (typeof candidate.inputSpellcheckEnabled === 'boolean') { + result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; + } + if (typeof candidate.showToolFileIcons === 'boolean') { + result.showToolFileIcons = candidate.showToolFileIcons; + } + if (typeof candidate.showExpandedBashTools === 'boolean') { + result.showExpandedBashTools = candidate.showExpandedBashTools; + } + if (typeof candidate.showExpandedEditTools === 'boolean') { + result.showExpandedEditTools = candidate.showExpandedEditTools; + } + if (typeof candidate.chatRenderMode === 'string') { + const mode = candidate.chatRenderMode.trim(); + if (mode === 'sorted' || mode === 'live') { + result.chatRenderMode = mode; + } + } + if (typeof candidate.activityRenderMode === 'string') { + const mode = candidate.activityRenderMode.trim(); + if (mode === 'collapsed' || mode === 'summary') { + result.activityRenderMode = mode; + } + } + if (typeof candidate.mermaidRenderingMode === 'string') { + const mode = candidate.mermaidRenderingMode.trim(); + if (mode === 'svg' || mode === 'ascii') { + result.mermaidRenderingMode = mode; + } + } + if (typeof candidate.userMessageRenderingMode === 'string') { + const mode = candidate.userMessageRenderingMode.trim(); + if (mode === 'markdown' || mode === 'plain') { + result.userMessageRenderingMode = mode; + } + } + if (typeof candidate.stickyUserHeader === 'boolean') { + result.stickyUserHeader = candidate.stickyUserHeader; + } + if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) { + result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize))); + } + if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) { + result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize))); + } + if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) { + result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding))); + } + if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) { + result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius))); + } + if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { + result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset))); + } + + const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); + if (favoriteModels) { + result.favoriteModels = favoriteModels; + } + + const recentModels = sanitizeModelRefs(candidate.recentModels, 16); + if (recentModels) { + result.recentModels = recentModels; + } + if (typeof candidate.diffLayoutPreference === 'string') { + const mode = candidate.diffLayoutPreference.trim(); + if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') { + result.diffLayoutPreference = mode; + } + } + if (typeof candidate.diffViewMode === 'string') { + const mode = candidate.diffViewMode.trim(); + if (mode === 'single' || mode === 'stacked') { + result.diffViewMode = mode; + } + } + if (typeof candidate.directoryShowHidden === 'boolean') { + result.directoryShowHidden = candidate.directoryShowHidden; + } + if (typeof candidate.filesViewShowGitignored === 'boolean') { + result.filesViewShowGitignored = candidate.filesViewShowGitignored; + } + if (typeof candidate.openInAppId === 'string') { + const trimmed = candidate.openInAppId.trim(); + if (trimmed.length > 0) { + result.openInAppId = trimmed; + } + } + + // Message limit — single setting for fetch / trim / Load More chunk + if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) { + result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit))); + } + + const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs); + if (skillCatalogs) { + result.skillCatalogs = skillCatalogs; + } + + // Usage model selections - which models appear in dropdown + if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') { + const sanitized = {}; + for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) { + if (typeof providerId === 'string' && Array.isArray(models)) { + const validModels = models.filter((m) => typeof m === 'string' && m.length > 0); + if (validModels.length > 0) { + sanitized[providerId] = validModels; + } + } + } + if (Object.keys(sanitized).length > 0) { + result.usageSelectedModels = sanitized; + } + } + + // Usage page collapsed families - for "Other Models" section + if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') { + const sanitized = {}; + for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) { + if (typeof providerId === 'string' && Array.isArray(families)) { + const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); + if (validFamilies.length > 0) { + sanitized[providerId] = validFamilies; + } + } + } + if (Object.keys(sanitized).length > 0) { + result.usageCollapsedFamilies = sanitized; + } + } + + // Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed) + if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') { + const sanitized = {}; + for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) { + if (typeof providerId === 'string' && Array.isArray(families)) { + const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0); + if (validFamilies.length > 0) { + sanitized[providerId] = validFamilies; + } + } + } + if (Object.keys(sanitized).length > 0) { + result.usageExpandedFamilies = sanitized; + } + } + + // Custom model groups configuration + if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') { + const sanitized = {}; + for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) { + if (typeof providerId !== 'string') continue; + + const providerConfig = {}; + + // customGroups: array of {id, label, models, order} + if (Array.isArray(config.customGroups)) { + const validGroups = config.customGroups + .filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string') + .map((g) => ({ + id: g.id.slice(0, 64), + label: g.label.slice(0, 128), + models: Array.isArray(g.models) + ? g.models.filter((m) => typeof m === 'string').slice(0, 500) + : [], + order: typeof g.order === 'number' ? g.order : 0, + })); + if (validGroups.length > 0) { + providerConfig.customGroups = validGroups; + } + } + + // modelAssignments: Record + if (config.modelAssignments && typeof config.modelAssignments === 'object') { + const assignments = {}; + for (const [model, groupId] of Object.entries(config.modelAssignments)) { + if (typeof model === 'string' && typeof groupId === 'string') { + assignments[model] = groupId; + } + } + if (Object.keys(assignments).length > 0) { + providerConfig.modelAssignments = assignments; + } + } + + // renamedGroups: Record + if (config.renamedGroups && typeof config.renamedGroups === 'object') { + const renamed = {}; + for (const [groupId, label] of Object.entries(config.renamedGroups)) { + if (typeof groupId === 'string' && typeof label === 'string') { + renamed[groupId] = label.slice(0, 128); + } + } + if (Object.keys(renamed).length > 0) { + providerConfig.renamedGroups = renamed; + } + } + + if (Object.keys(providerConfig).length > 0) { + sanitized[providerId] = providerConfig; + } + } + if (Object.keys(sanitized).length > 0) { + result.usageModelGroups = sanitized; + } + } + + // Usage reporting opt-out (default: true/enabled) + if (typeof candidate.reportUsage === 'boolean') { + result.reportUsage = candidate.reportUsage; + } + + return result; + }; + + const mergePersistedSettings = (current, changes) => { + const baseApproved = Array.isArray(changes.approvedDirectories) + ? changes.approvedDirectories + : Array.isArray(current.approvedDirectories) + ? current.approvedDirectories + : []; + + const additionalApproved = []; + if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) { + additionalApproved.push(changes.lastDirectory); + } + if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) { + additionalApproved.push(changes.homeDirectory); + } + const projectEntries = Array.isArray(changes.projects) + ? changes.projects + : Array.isArray(current.projects) + ? current.projects + : []; + projectEntries.forEach((project) => { + if (project && typeof project.path === 'string' && project.path.length > 0) { + additionalApproved.push(project.path); + } + }); + const approvedSource = [...baseApproved, ...additionalApproved]; + + const baseBookmarks = Array.isArray(changes.securityScopedBookmarks) + ? changes.securityScopedBookmarks + : Array.isArray(current.securityScopedBookmarks) + ? current.securityScopedBookmarks + : []; + + const nextTypographySizes = changes.typographySizes + ? { + ...(current.typographySizes || {}), + ...changes.typographySizes + } + : current.typographySizes; + + const next = { + ...current, + ...changes, + approvedDirectories: Array.from( + new Set( + approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0) + ) + ), + securityScopedBookmarks: Array.from( + new Set( + baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0) + ) + ), + typographySizes: nextTypographySizes + }; + + return next; + }; + + const formatSettingsResponse = (settings) => { + const sanitized = sanitizeSettingsUpdate(settings); + delete sanitized.managedRemoteTunnelToken; + const approved = normalizeStringArray(settings.approvedDirectories); + const bookmarks = normalizeStringArray(settings.securityScopedBookmarks); + const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; + const pwaAppName = normalizePwaAppName(settings?.pwaAppName, ''); + + return { + ...sanitized, + hasManagedRemoteTunnelToken, + ...(pwaAppName ? { pwaAppName } : {}), + approvedDirectories: approved, + securityScopedBookmarks: bookmarks, + pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), + typographySizes: sanitizeTypographySizesPartial(settings.typographySizes), + showReasoningTraces: + typeof settings.showReasoningTraces === 'boolean' + ? settings.showReasoningTraces + : typeof sanitized.showReasoningTraces === 'boolean' + ? sanitized.showReasoningTraces + : false + }; + }; + + return { + normalizePwaAppName, + sanitizeSettingsUpdate, + mergePersistedSettings, + formatSettingsResponse, + }; +}; diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.js b/packages/web/server/lib/opencode/settings-normalization-runtime.js new file mode 100644 index 00000000..5c5c6c9c --- /dev/null +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.js @@ -0,0 +1,428 @@ +export const createSettingsNormalizationRuntime = (dependencies) => { + const { + os, + path, + processLike, + tunnelBootstrapTtlDefaultMs, + tunnelBootstrapTtlMinMs, + tunnelBootstrapTtlMaxMs, + tunnelSessionTtlDefaultMs, + tunnelSessionTtlMinMs, + tunnelSessionTtlMaxMs, + } = dependencies; + + const normalizeDirectoryPath = (value) => { + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + if (trimmed === '~') { + return os.homedir(); + } + + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return path.join(os.homedir(), trimmed.slice(2)); + } + + return trimmed; + }; + + const normalizePathForPersistence = (value) => { + if (typeof value !== 'string') { + return value; + } + + const normalized = normalizeDirectoryPath(value); + if (typeof normalized !== 'string') { + return normalized; + } + + const trimmed = normalized.trim(); + if (!trimmed) { + return trimmed; + } + + if (processLike.platform !== 'win32') { + return trimmed; + } + + return trimmed.replace(/\//g, '\\'); + }; + + const areStringArraysEqual = (a, b) => { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + }; + + const normalizeStringArray = (input) => { + if (!Array.isArray(input)) { + return []; + } + return Array.from( + new Set( + input.filter((entry) => typeof entry === 'string' && entry.length > 0) + ) + ); + }; + + const sanitizeProjects = (input) => { + if (!Array.isArray(input)) { + return undefined; + } + + const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; + const normalizeIconBackground = (value) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null; + }; + + const result = []; + const seenIds = new Set(); + const seenPaths = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + + const candidate = entry; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : ''; + const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : ''; + const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : ''; + const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object' + ? candidate.iconImage + : null; + const iconBackground = normalizeIconBackground(candidate.iconBackground); + const color = typeof candidate.color === 'string' ? candidate.color.trim() : ''; + const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null; + const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt) + ? Number(candidate.lastOpenedAt) + : null; + + if (!id || !normalizedPath) continue; + if (seenIds.has(id)) continue; + if (seenPaths.has(normalizedPath)) continue; + + seenIds.add(id); + seenPaths.add(normalizedPath); + + const project = { + id, + path: normalizedPath, + ...(label ? { label } : {}), + ...(icon ? { icon } : {}), + ...(iconBackground ? { iconBackground } : {}), + ...(color ? { color } : {}), + ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}), + ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), + }; + + if (candidate.iconImage === null) { + project.iconImage = null; + } else if (iconImage) { + const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : ''; + const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt) + ? Math.max(0, Math.round(iconImage.updatedAt)) + : 0; + const source = iconImage.source === 'custom' || iconImage.source === 'auto' + ? iconImage.source + : null; + if (mime && updatedAt > 0 && source) { + project.iconImage = { mime, updatedAt, source }; + } + } + + if (candidate.iconBackground === null) { + project.iconBackground = null; + } + + if (typeof candidate.sidebarCollapsed === 'boolean') { + project.sidebarCollapsed = candidate.sidebarCollapsed; + } + + result.push(project); + } + + return result; + }; + + const normalizeSettingsPaths = (input) => { + const settings = input && typeof input === 'object' ? input : {}; + let next = settings; + let changed = false; + + const ensureNext = () => { + if (next === settings) { + next = { ...settings }; + } + }; + + const normalizePathField = (key) => { + if (typeof settings[key] !== 'string' || settings[key].length === 0) { + return; + } + const normalized = normalizePathForPersistence(settings[key]); + if (normalized !== settings[key]) { + ensureNext(); + next[key] = normalized; + changed = true; + } + }; + + const normalizePathArrayField = (key) => { + if (!Array.isArray(settings[key])) { + return; + } + + const normalized = normalizeStringArray( + settings[key] + .map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + ); + + if (!areStringArraysEqual(normalized, settings[key])) { + ensureNext(); + next[key] = normalized; + changed = true; + } + }; + + normalizePathField('lastDirectory'); + normalizePathField('homeDirectory'); + normalizePathArrayField('approvedDirectories'); + normalizePathArrayField('pinnedDirectories'); + + if (Array.isArray(settings.projects)) { + const normalizedProjects = sanitizeProjects(settings.projects) || []; + if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) { + ensureNext(); + next.projects = normalizedProjects; + changed = true; + } + } + + return { settings: next, changed }; + }; + + const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value)); + + const normalizeTunnelBootstrapTtlMs = (value) => { + if (value === null) { + return null; + } + if (!Number.isFinite(value)) { + return tunnelBootstrapTtlDefaultMs; + } + return clampNumber(Math.round(value), tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs); + }; + + const normalizeTunnelSessionTtlMs = (value) => { + if (!Number.isFinite(value)) { + return tunnelSessionTtlDefaultMs; + } + return clampNumber(Math.round(value), tunnelSessionTtlMinMs, tunnelSessionTtlMaxMs); + }; + + const normalizeManagedRemoteTunnelHostname = (value) => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const parsed = (() => { + try { + if (trimmed.includes('://')) { + return new URL(trimmed); + } + return new URL(`https://${trimmed}`); + } catch { + return null; + } + })(); + + const hostname = parsed?.hostname?.trim().toLowerCase() || ''; + if (!hostname) { + return undefined; + } + return hostname; + }; + + const normalizeManagedRemoteTunnelPresets = (value) => { + if (!Array.isArray(value)) { + return undefined; + } + + const result = []; + const seenIds = new Set(); + const seenHostnames = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; + const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname); + if (!id || !name || !hostname) continue; + if (seenIds.has(id) || seenHostnames.has(hostname)) continue; + seenIds.add(id); + seenHostnames.add(hostname); + result.push({ id, name, hostname }); + } + + return result; + }; + + const normalizeManagedRemoteTunnelPresetTokens = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const result = {}; + for (const [rawId, rawToken] of Object.entries(value)) { + const id = typeof rawId === 'string' ? rawId.trim() : ''; + const token = typeof rawToken === 'string' ? rawToken.trim() : ''; + if (!id || !token) { + continue; + } + result[id] = token; + } + + return Object.keys(result).length > 0 ? result : undefined; + }; + + const isUnsafeSkillRelativePath = (value) => { + if (typeof value !== 'string' || value.length === 0) { + return true; + } + + const normalized = value.replace(/\\/g, '/'); + if (path.posix.isAbsolute(normalized)) { + return true; + } + + return normalized.split('/').some((segment) => segment === '..'); + }; + + const sanitizeTypographySizesPartial = (input) => { + if (!input || typeof input !== 'object') { + return undefined; + } + const candidate = input; + const result = {}; + let populated = false; + + const assign = (key) => { + if (typeof candidate[key] === 'string' && candidate[key].length > 0) { + result[key] = candidate[key]; + populated = true; + } + }; + + assign('markdown'); + assign('code'); + assign('uiHeader'); + assign('uiLabel'); + assign('meta'); + assign('micro'); + + return populated ? result : undefined; + }; + + const sanitizeModelRefs = (input, limit) => { + if (!Array.isArray(input)) { + return undefined; + } + + const result = []; + const seen = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : ''; + const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : ''; + if (!providerID || !modelID) continue; + const key = `${providerID}/${modelID}`; + if (seen.has(key)) continue; + seen.add(key); + result.push({ providerID, modelID }); + if (result.length >= limit) break; + } + + return result; + }; + + const sanitizeSkillCatalogs = (input) => { + if (!Array.isArray(input)) { + return undefined; + } + + const result = []; + const seen = new Set(); + + for (const entry of input) { + if (!entry || typeof entry !== 'object') continue; + + const id = typeof entry.id === 'string' ? entry.id.trim() : ''; + const label = typeof entry.label === 'string' ? entry.label.trim() : ''; + const source = typeof entry.source === 'string' ? entry.source.trim() : ''; + const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : ''; + const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : ''; + + if (!id || !label || !source) continue; + if (seen.has(id)) continue; + seen.add(id); + + result.push({ + id, + label, + source, + ...(subpath ? { subpath } : {}), + ...(gitIdentityId ? { gitIdentityId } : {}), + }); + } + + return result; + }; + + return { + normalizeDirectoryPath, + normalizePathForPersistence, + normalizeSettingsPaths, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + isUnsafeSkillRelativePath, + sanitizeTypographySizesPartial, + normalizeStringArray, + sanitizeModelRefs, + sanitizeSkillCatalogs, + sanitizeProjects, + }; +}; diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js new file mode 100644 index 00000000..a57124ec --- /dev/null +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -0,0 +1,439 @@ +const DEFAULT_NOTIFICATION_TEMPLATES = { + completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, + error: { title: 'Tool error', message: '{last_message}' }, + question: { title: 'Input needed', message: '{last_message}' }, + subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' }, +}; + +const ensureNotificationTemplateShape = (templates) => { + const input = templates && typeof templates === 'object' ? templates : {}; + let changed = false; + const next = {}; + + for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) { + const currentEntry = input[event]; + const base = DEFAULT_NOTIFICATION_TEMPLATES[event]; + const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title; + const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message; + if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') { + changed = true; + } + next[event] = { title: currentTitle, message: currentMessage }; + } + + return { templates: next, changed }; +}; + +export const createSettingsRuntime = (deps) => { + const { + fsPromises, + path, + crypto, + SETTINGS_FILE_PATH, + sanitizeProjects, + sanitizeSettingsUpdate, + mergePersistedSettings, + normalizeSettingsPaths, + normalizeStringArray, + formatSettingsResponse, + resolveDirectoryCandidate, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + normalizeManagedRemoteTunnelPresetTokens, + syncManagedRemoteTunnelConfigWithPresets, + upsertManagedRemoteTunnelToken, + } = deps; + + let persistSettingsLock = Promise.resolve(); + + const readSettingsFromDisk = async () => { + try { + const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed; + } + return {}; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return {}; + } + console.warn('Failed to read settings file:', error); + return {}; + } + }; + + const writeSettingsToDisk = async (settings) => { + try { + await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8'); + } catch (error) { + console.warn('Failed to write settings file:', error); + throw error; + } + }; + + const validateProjectEntries = async (projects) => { + console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`); + + if (!Array.isArray(projects)) { + console.warn('[validateProjectEntries] Input is not an array, returning empty'); + return []; + } + + const validations = projects.map(async (project) => { + if (!project || typeof project.path !== 'string' || project.path.length === 0) { + console.error('[validateProjectEntries] Invalid project entry: missing or empty path', project); + return null; + } + try { + const stats = await fsPromises.stat(project.path); + if (!stats.isDirectory()) { + console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`); + return null; + } + return project; + } catch (error) { + const err = error; + console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`); + if (err && typeof err === 'object' && err.code === 'ENOENT') { + console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`); + return null; + } + console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`); + return project; + } + }); + + const results = (await Promise.all(validations)).filter((p) => p !== null); + + console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`); + return results; + }; + + const migrateSettingsFromLegacyLastDirectory = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const now = Date.now(); + + const sanitizedProjects = sanitizeProjects(settings.projects) || []; + let nextProjects = sanitizedProjects; + let nextActiveProjectId = + typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined; + + let changed = false; + + if (nextProjects.length === 0) { + const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : ''; + const candidate = legacy ? resolveDirectoryCandidate(legacy) : null; + + if (candidate) { + try { + const stats = await fsPromises.stat(candidate); + if (stats.isDirectory()) { + const id = crypto.randomUUID(); + nextProjects = [ + { + id, + path: candidate, + addedAt: now, + lastOpenedAt: now, + }, + ]; + nextActiveProjectId = id; + changed = true; + } + } catch { + // ignore invalid lastDirectory + } + } + } + + if (nextProjects.length > 0) { + const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null; + if (!active) { + nextActiveProjectId = nextProjects[0].id; + changed = true; + } + } else if (nextActiveProjectId) { + nextActiveProjectId = undefined; + changed = true; + } + + if (!changed) { + return { settings, changed: false }; + } + + const merged = mergePersistedSettings(settings, { + ...settings, + projects: nextProjects, + ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }), + }); + + return { settings: merged, changed: true }; + }; + + const migrateSettingsFromLegacyThemePreferences = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + + const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : ''; + const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : ''; + + const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0; + const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0; + + if (hasLight && hasDark) { + return { settings, changed: false }; + } + + const defaultLight = 'flexoki-light'; + const defaultDark = 'flexoki-dark'; + + let nextLightThemeId = hasLight ? settings.lightThemeId : undefined; + let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined; + + if (!hasLight) { + if (themeId && themeVariant === 'light') { + nextLightThemeId = themeId; + } else { + nextLightThemeId = defaultLight; + } + } + + if (!hasDark) { + if (themeId && themeVariant === 'dark') { + nextDarkThemeId = themeId; + } else { + nextDarkThemeId = defaultDark; + } + } + + const merged = mergePersistedSettings(settings, { + ...settings, + ...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}), + ...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}), + }); + + return { settings: merged, changed: true }; + }; + + const migrateSettingsFromLegacyCollapsedProjects = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const collapsed = Array.isArray(settings.collapsedProjects) + ? normalizeStringArray(settings.collapsedProjects) + : []; + + if (collapsed.length === 0 || !Array.isArray(settings.projects)) { + if (collapsed.length === 0) { + return { settings, changed: false }; + } + const next = { ...settings }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + } + + const set = new Set(collapsed); + const projects = sanitizeProjects(settings.projects) || []; + let changed = false; + + const nextProjects = projects.map((project) => { + const shouldCollapse = set.has(project.id); + if (project.sidebarCollapsed !== shouldCollapse) { + changed = true; + return { ...project, sidebarCollapsed: shouldCollapse }; + } + return project; + }); + + if (!changed) { + if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) { + const next = { ...settings }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + } + return { settings, changed: false }; + } + + const next = { ...settings, projects: nextProjects }; + delete next.collapsedProjects; + return { settings: next, changed: true }; + }; + + const migrateSettingsNotificationDefaults = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + let changed = false; + const next = { ...settings }; + + if (typeof settings.notifyOnSubtasks !== 'boolean') { + next.notifyOnSubtasks = true; + changed = true; + } + if (typeof settings.notifyOnCompletion !== 'boolean') { + next.notifyOnCompletion = true; + changed = true; + } + if (typeof settings.notifyOnError !== 'boolean') { + next.notifyOnError = true; + changed = true; + } + if (typeof settings.notifyOnQuestion !== 'boolean') { + next.notifyOnQuestion = true; + changed = true; + } + + const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates); + if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') { + next.notificationTemplates = templates; + changed = true; + } + + return { settings: changed ? next : settings, changed }; + }; + + const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => { + const settings = current && typeof current === 'object' ? current : {}; + const next = { ...settings }; + let changed = false; + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) { + next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname); + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) { + if (next.namedTunnelToken === null) { + next.managedRemoteTunnelToken = null; + } else if (typeof next.namedTunnelToken === 'string') { + next.managedRemoteTunnelToken = next.namedTunnelToken.trim(); + } + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) { + next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets); + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) { + next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens); + changed = true; + } + + if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId') + && Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) { + const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string' + ? next.namedTunnelSelectedPresetId.trim() + : ''; + if (selectedPresetId) { + next.managedRemoteTunnelSelectedPresetId = selectedPresetId; + } + changed = true; + } + + const legacyKeys = [ + 'namedTunnelHostname', + 'namedTunnelToken', + 'namedTunnelPresets', + 'namedTunnelPresetTokens', + 'namedTunnelSelectedPresetId', + ]; + for (const key of legacyKeys) { + if (Object.prototype.hasOwnProperty.call(next, key)) { + delete next[key]; + changed = true; + } + } + + return { settings: changed ? next : settings, changed }; + }; + + const readSettingsFromDiskMigrated = async () => { + const current = await readSettingsFromDisk(); + const migration1 = await migrateSettingsFromLegacyLastDirectory(current); + const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings); + const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings); + const migration4 = await migrateSettingsNotificationDefaults(migration3.settings); + const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings); + const migration6 = normalizeSettingsPaths(migration5.settings); + if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) { + await writeSettingsToDisk(migration6.settings); + } + return migration6.settings; + }; + + const persistSettings = async (changes) => { + persistSettingsLock = persistSettingsLock.then(async () => { + console.log('[persistSettings] Called with changes:', JSON.stringify(changes, null, 2)); + const current = await readSettingsFromDisk(); + console.log('[persistSettings] Current projects count:', Array.isArray(current.projects) ? current.projects.length : 'N/A'); + const sanitized = sanitizeSettingsUpdate(changes); + let next = mergePersistedSettings(current, sanitized); + + const normalizedState = normalizeSettingsPaths(next); + if (normalizedState.changed) { + next = normalizedState.settings; + } + + if (Array.isArray(next.projects)) { + console.log(`[persistSettings] Validating ${next.projects.length} projects...`); + const validated = await validateProjectEntries(next.projects); + console.log(`[persistSettings] After validation: ${validated.length} projects remain`); + next = { ...next, projects: validated }; + } + + if (Array.isArray(next.projects) && next.projects.length > 0) { + const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : ''; + const active = next.projects.find((project) => project.id === activeId) || null; + if (!active) { + console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`); + next = { ...next, activeProjectId: next.projects[0].id }; + } + } else if (next.activeProjectId) { + console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`); + next = { ...next, activeProjectId: undefined }; + } + + if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) { + await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets); + } + + if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) { + const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry])); + const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens) + .map(([presetId, token]) => { + const preset = presetsById.get(presetId); + if (!preset || typeof token !== 'string' || token.trim().length === 0) { + return null; + } + return { + id: preset.id, + name: preset.name, + hostname: preset.hostname, + token: token.trim(), + }; + }) + .filter(Boolean); + + for (const update of updates) { + await upsertManagedRemoteTunnelToken(update); + } + } + + await writeSettingsToDisk(next); + console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`); + return formatSettingsResponse(next); + }); + + return persistSettingsLock; + }; + + return { + readSettingsFromDisk, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + persistSettings, + }; +}; diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js new file mode 100644 index 00000000..1350e2eb --- /dev/null +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -0,0 +1,114 @@ +export const createGracefulShutdownRuntime = (dependencies) => { + const { + process, + shutdownTimeoutMs, + getExitOnShutdown, + getIsShuttingDown, + setIsShuttingDown, + syncToHmrState, + openCodeWatcherRuntime, + sessionRuntime, + getHealthCheckInterval, + clearHealthCheckInterval, + getTerminalRuntime, + setTerminalRuntime, + shouldSkipOpenCodeStop, + getOpenCodePort, + getOpenCodeProcess, + setOpenCodeProcess, + killProcessOnPort, + getServer, + getUiAuthController, + setUiAuthController, + getActiveTunnelController, + setActiveTunnelController, + tunnelAuthController, + } = dependencies; + + const gracefulShutdown = async (options = {}) => { + if (getIsShuttingDown()) return; + + setIsShuttingDown(true); + syncToHmrState(); + console.log('Starting graceful shutdown...'); + const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : getExitOnShutdown(); + + openCodeWatcherRuntime.stop(); + sessionRuntime.dispose(); + + const healthCheckInterval = getHealthCheckInterval(); + if (healthCheckInterval) { + clearHealthCheckInterval(healthCheckInterval); + } + + const terminalRuntime = getTerminalRuntime(); + if (terminalRuntime) { + try { + await terminalRuntime.shutdown(); + } catch { + } finally { + setTerminalRuntime(null); + } + } + + if (!shouldSkipOpenCodeStop()) { + const portToKill = getOpenCodePort(); + const openCodeProcess = getOpenCodeProcess(); + + if (openCodeProcess) { + console.log('Stopping OpenCode process...'); + try { + openCodeProcess.close(); + } catch (error) { + console.warn('Error closing OpenCode process:', error); + } + setOpenCodeProcess(null); + } + + killProcessOnPort(portToKill); + } else { + console.log('Skipping OpenCode shutdown (external server)'); + } + + const server = getServer(); + if (server) { + await Promise.race([ + new Promise((resolve) => { + server.close(() => { + console.log('HTTP server closed'); + resolve(); + }); + }), + new Promise((resolve) => { + setTimeout(() => { + console.warn('Server close timeout reached, forcing shutdown'); + resolve(); + }, shutdownTimeoutMs); + }), + ]); + } + + const uiAuthController = getUiAuthController(); + if (uiAuthController) { + uiAuthController.dispose(); + setUiAuthController(null); + } + + const activeTunnelController = getActiveTunnelController(); + if (activeTunnelController) { + console.log('Stopping active tunnel...'); + activeTunnelController.stop(); + setActiveTunnelController(null); + tunnelAuthController.clearActiveTunnel(); + } + + console.log('Graceful shutdown complete'); + if (exitProcess) { + process.exit(0); + } + }; + + return { + gracefulShutdown, + }; +}; diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js new file mode 100644 index 00000000..69ef2727 --- /dev/null +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -0,0 +1,707 @@ +export const registerSkillRoutes = (app, dependencies) => { + const { + fs, + path, + os, + resolveProjectDirectory, + resolveOptionalProjectDirectory, + readSettingsFromDisk, + sanitizeSkillCatalogs, + isUnsafeSkillRelativePath, + refreshOpenCodeAfterConfigChange, + clientReloadDelayMs, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getOpenCodePort, + getSkillSources, + discoverSkills, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + SKILL_SCOPE, + SKILL_DIR, + getCuratedSkillsSources, + getCacheKey, + getCachedScan, + setCachedScan, + parseSkillRepoSource, + scanSkillsRepository, + installSkillsFromRepository, + scanClawdHubPage, + installSkillsFromClawdHub, + isClawdHubSource, + getProfiles, + getProfile, + } = dependencies; + + const findWorktreeRootForSkills = (workingDirectory) => { + if (!workingDirectory) return null; + let current = path.resolve(workingDirectory); + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } + }; + + const getSkillProjectAncestors = (workingDirectory) => { + if (!workingDirectory) return []; + const result = []; + let current = path.resolve(workingDirectory); + const stop = findWorktreeRootForSkills(workingDirectory) || current; + while (true) { + result.push(current); + if (current === stop) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return result; + }; + + const isPathInside = (candidatePath, parentPath) => { + if (!candidatePath || !parentPath) return false; + const normalizedCandidate = path.resolve(candidatePath); + const normalizedParent = path.resolve(parentPath); + return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`); + }; + + const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => { + const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : ''; + const home = os.homedir(); + const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`) + ? 'agents' + : resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`) + ? 'claude' + : 'opencode'; + + const projectAncestors = getSkillProjectAncestors(workingDirectory); + const isProjectScoped = projectAncestors.some((ancestor) => { + const candidates = [ + path.join(ancestor, '.opencode'), + path.join(ancestor, '.claude', 'skills'), + path.join(ancestor, '.agents', 'skills'), + ]; + return candidates.some((candidate) => isPathInside(resolvedPath, candidate)); + }); + + if (isProjectScoped) { + return { scope: SKILL_SCOPE.PROJECT, source }; + } + + const userRoots = [ + path.join(home, '.config', 'opencode'), + path.join(home, '.opencode'), + path.join(home, '.claude', 'skills'), + path.join(home, '.agents', 'skills'), + process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null, + ].filter(Boolean); + + if (userRoots.some((root) => isPathInside(resolvedPath, root))) { + return { scope: SKILL_SCOPE.USER, source }; + } + + return { scope: SKILL_SCOPE.USER, source }; + }; + + const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => { + if (!getOpenCodePort()) { + return null; + } + + try { + const url = new URL(buildOpenCodeUrl('/skill', '')); + if (workingDirectory) { + url.searchParams.set('directory', workingDirectory); + } + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + ...getOpenCodeAuthHeaders(), + }, + signal: AbortSignal.timeout(8_000), + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json(); + if (!Array.isArray(payload)) { + return null; + } + + return payload + .map((item) => { + const name = typeof item?.name === 'string' ? item.name.trim() : ''; + const location = typeof item?.location === 'string' ? item.location : ''; + const description = typeof item?.description === 'string' ? item.description : ''; + if (!name || !location) { + return null; + } + const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory); + return { + name, + path: location, + scope: inferred.scope, + source: inferred.source, + description, + }; + }) + .filter(Boolean); + } catch { + return null; + } + }; + + const listGitIdentitiesForResponse = () => { + try { + const profiles = getProfiles(); + return profiles.map((p) => ({ id: p.id, name: p.name })); + } catch { + return []; + } + }; + + const resolveGitIdentity = (profileId) => { + if (!profileId) { + return null; + } + try { + const profile = getProfile(profileId); + const sshKey = profile?.sshKey; + if (typeof sshKey === 'string' && sshKey.trim()) { + return { sshKey: sshKey.trim() }; + } + } catch { + // ignore + } + return null; + }; + + app.get('/api/config/skills', async (req, res) => { + try { + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory); + + const enrichedSkills = skills.map((skill) => { + const sources = getSkillSources(skill.name, directory, skill); + return { + ...skill, + sources + }; + }); + + res.json({ skills: enrichedSkills }); + } catch (error) { + console.error('Failed to list skills:', error); + res.status(500).json({ error: 'Failed to list skills' }); + } + }); + + app.get('/api/config/skills/catalog', async (req, res) => { + try { + const { error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ error }); + } + + const curatedSources = getCuratedSkillsSources(); + const settings = await readSettingsFromDisk(); + const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; + + const customSources = customSourcesRaw.map((entry) => ({ + id: entry.id, + label: entry.label, + description: entry.source, + source: entry.source, + defaultSubpath: entry.subpath, + gitIdentityId: entry.gitIdentityId, + })); + + const sources = [...curatedSources, ...customSources]; + const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest); + + res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} }); + } catch (error) { + console.error('Failed to load skills catalog:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } }); + } + }); + + app.get('/api/config/skills/catalog/source', async (req, res) => { + try { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { + return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } }); + } + + const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null; + if (!sourceId) { + return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } }); + } + + const refresh = String(req.query.refresh || '').toLowerCase() === 'true'; + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null; + + const curatedSources = getCuratedSkillsSources(); + const settings = await readSettingsFromDisk(); + const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || []; + + const customSources = customSourcesRaw.map((entry) => ({ + id: entry.id, + label: entry.label, + description: entry.source, + source: entry.source, + defaultSubpath: entry.subpath, + gitIdentityId: entry.gitIdentityId, + })); + + const sources = [...curatedSources, ...customSources]; + const src = sources.find((entry) => entry.id === sourceId); + + if (!src) { + return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } }); + } + + const discovered = directory + ? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory)) + : []; + const installedByName = new Map(discovered.map((s) => [s.name, s])); + + if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { + const scanned = await scanClawdHubPage({ cursor: cursor || null }); + if (!scanned.ok) { + return res.status(500).json({ ok: false, error: scanned.error }); + } + + const items = (scanned.items || []).map((item) => { + const installed = installedByName.get(item.skillName); + return { + ...item, + sourceId: src.id, + installed: installed + ? { isInstalled: true, scope: installed.scope, source: installed.source } + : { isInstalled: false }, + }; + }); + + return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null }); + } + + const parsed = parseSkillRepoSource(src.source); + if (!parsed.ok) { + return res.status(400).json({ ok: false, error: parsed.error }); + } + + const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null; + const cacheKey = getCacheKey({ + normalizedRepo: parsed.normalizedRepo, + subpath: effectiveSubpath || '', + identityId: src.gitIdentityId || '', + }); + + let scanResult = !refresh ? getCachedScan(cacheKey) : null; + if (!scanResult) { + const scanned = await scanSkillsRepository({ + source: src.source, + subpath: src.defaultSubpath, + defaultSubpath: src.defaultSubpath, + identity: resolveGitIdentity(src.gitIdentityId), + }); + + if (!scanned.ok) { + return res.status(500).json({ ok: false, error: scanned.error }); + } + + scanResult = scanned; + setCachedScan(cacheKey, scanResult); + } + + const items = (scanResult.items || []).map((item) => { + const installed = installedByName.get(item.skillName); + return { + sourceId: src.id, + ...item, + gitIdentityId: src.gitIdentityId, + installed: installed + ? { isInstalled: true, scope: installed.scope, source: installed.source } + : { isInstalled: false }, + }; + }); + + return res.json({ ok: true, items }); + } catch (error) { + console.error('Failed to load catalog source:', error); + return res.status(500).json({ + ok: false, + error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' }, + }); + } + }); + + app.post('/api/config/skills/scan', async (req, res) => { + try { + const { source, subpath, gitIdentityId } = req.body || {}; + const identity = resolveGitIdentity(gitIdentityId); + + const result = await scanSkillsRepository({ + source, + subpath, + identity, + }); + + if (!result.ok) { + if (result.error?.kind === 'authRequired') { + return res.status(401).json({ + ok: false, + error: { + ...result.error, + identities: listGitIdentitiesForResponse(), + }, + }); + } + + return res.status(400).json({ ok: false, error: result.error }); + } + + res.json({ ok: true, items: result.items }); + } catch (error) { + console.error('Failed to scan skills repository:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } }); + } + }); + + app.post('/api/config/skills/install', async (req, res) => { + try { + const { + source, + subpath, + gitIdentityId, + scope, + targetSource, + selections, + conflictPolicy, + conflictDecisions, + } = req.body || {}; + + let workingDirectory = null; + if (scope === 'project') { + const resolved = await resolveProjectDirectory(req); + if (!resolved.directory) { + return res.status(400).json({ + ok: false, + error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' }, + }); + } + workingDirectory = resolved.directory; + } + + if (isClawdHubSource(source)) { + const result = await installSkillsFromClawdHub({ + scope, + targetSource, + workingDirectory, + userSkillDir: SKILL_DIR, + selections, + conflictPolicy, + conflictDecisions, + }); + + if (!result.ok) { + if (result.error?.kind === 'conflicts') { + return res.status(409).json({ ok: false, error: result.error }); + } + return res.status(400).json({ ok: false, error: result.error }); + } + + const installed = result.installed || []; + const skipped = result.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await refreshOpenCodeAfterConfigChange('skills install'); + } + + return res.json({ + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined, + }); + } + + const identity = resolveGitIdentity(gitIdentityId); + + const result = await installSkillsFromRepository({ + source, + subpath, + identity, + scope, + targetSource, + workingDirectory, + userSkillDir: SKILL_DIR, + selections, + conflictPolicy, + conflictDecisions, + }); + + if (!result.ok) { + if (result.error?.kind === 'conflicts') { + return res.status(409).json({ ok: false, error: result.error }); + } + + if (result.error?.kind === 'authRequired') { + return res.status(401).json({ + ok: false, + error: { + ...result.error, + identities: listGitIdentitiesForResponse(), + }, + }); + } + + return res.status(400).json({ ok: false, error: result.error }); + } + + const installed = result.installed || []; + const skipped = result.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await refreshOpenCodeAfterConfigChange('skills install'); + } + + res.json({ + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined, + }); + } catch (error) { + console.error('Failed to install skills:', error); + res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } }); + } + }); + + app.get('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + + res.json({ + name: skillName, + sources: sources, + scope: sources.md.scope, + source: sources.md.source, + exists: sources.md.exists + }); + } catch (error) { + console.error('Failed to get skill sources:', error); + res.status(500).json({ error: 'Failed to get skill configuration metadata' }); + } + }); + + app.get('/api/config/skills/:name/files/*filePath', async (req, res) => { + try { + const skillName = req.params.name; + const filePath = decodeURIComponent(req.params.filePath); + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + if (!sources.md.exists || !sources.md.dir) { + return res.status(404).json({ error: 'Skill not found' }); + } + + const content = readSkillSupportingFile(sources.md.dir, filePath); + if (content === null) { + return res.status(404).json({ error: 'File not found' }); + } + + res.json({ path: filePath, content }); + } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to read skill file:', error); + res.status(500).json({ error: 'Failed to read skill file' }); + } + }); + + app.post('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const { scope, source: skillSource, ...config } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log('[Server] Creating skill:', skillName); + console.log('[Server] Scope:', scope, 'Working directory:', directory); + + createSkill(skillName, { ...config, source: skillSource }, directory, scope); + await refreshOpenCodeAfterConfigChange('skill creation'); + + res.json({ + success: true, + requiresReload: true, + message: `Skill ${skillName} created successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to create skill:', error); + res.status(500).json({ error: error.message || 'Failed to create skill' }); + } + }); + + app.patch('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const updates = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + console.log(`[Server] Updating skill: ${skillName}`); + console.log('[Server] Working directory:', directory); + + updateSkill(skillName, updates, directory); + await refreshOpenCodeAfterConfigChange('skill update'); + + res.json({ + success: true, + requiresReload: true, + message: `Skill ${skillName} updated successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('[Server] Failed to update skill:', error); + res.status(500).json({ error: error.message || 'Failed to update skill' }); + } + }); + + app.put('/api/config/skills/:name/files/*filePath', async (req, res) => { + try { + const skillName = req.params.name; + const filePath = decodeURIComponent(req.params.filePath); + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } + const { content } = req.body; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + if (!sources.md.exists || !sources.md.dir) { + return res.status(404).json({ error: 'Skill not found' }); + } + + writeSkillSupportingFile(sources.md.dir, filePath, content || ''); + + res.json({ + success: true, + message: `File ${filePath} saved successfully`, + }); + } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to write skill file:', error); + res.status(500).json({ error: error.message || 'Failed to write skill file' }); + } + }); + + app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => { + try { + const skillName = req.params.name; + const filePath = decodeURIComponent(req.params.filePath); + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + .find((skill) => skill.name === skillName) || null; + const sources = getSkillSources(skillName, directory, discoveredSkill); + if (!sources.md.exists || !sources.md.dir) { + return res.status(404).json({ error: 'Skill not found' }); + } + + deleteSkillSupportingFile(sources.md.dir, filePath); + + res.json({ + success: true, + message: `File ${filePath} deleted successfully`, + }); + } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } + console.error('Failed to delete skill file:', error); + res.status(500).json({ error: error.message || 'Failed to delete skill file' }); + } + }); + + app.delete('/api/config/skills/:name', async (req, res) => { + try { + const skillName = req.params.name; + const { directory, error } = await resolveProjectDirectory(req); + if (!directory) { + return res.status(400).json({ error }); + } + + deleteSkill(skillName, directory); + await refreshOpenCodeAfterConfigChange('skill deletion'); + + res.json({ + success: true, + requiresReload: true, + message: `Skill ${skillName} deleted successfully. Reloading interface…`, + reloadDelayMs: clientReloadDelayMs, + }); + } catch (error) { + console.error('Failed to delete skill:', error); + res.status(500).json({ error: error.message || 'Failed to delete skill' }); + } + }); +}; diff --git a/packages/web/server/lib/opencode/startup-pipeline-runtime.js b/packages/web/server/lib/opencode/startup-pipeline-runtime.js new file mode 100644 index 00000000..f7227e13 --- /dev/null +++ b/packages/web/server/lib/opencode/startup-pipeline-runtime.js @@ -0,0 +1,107 @@ +export const createStartupPipelineRuntime = (dependencies) => { + const { + createTerminalRuntime, + createServerStartupRuntime, + } = dependencies; + + const run = async (options) => { + const { + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + terminalHeartbeatIntervalMs, + terminalRebindWindowMs, + terminalMaxRebindsPerWindow, + setupProxy, + scheduleOpenCodeApiDetection, + bootstrapOpenCodeAtStartup, + staticRoutesRuntime, + process, + crypto, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached, + setSignalsAttached, + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + host, + port, + startupTunnelRequest, + onTunnelReady, + tunnelRuntimeContext, + attachSignals, + } = options; + + const terminalRuntime = createTerminalRuntime({ + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: terminalHeartbeatIntervalMs, + TERMINAL_INPUT_WS_REBIND_WINDOW_MS: terminalRebindWindowMs, + TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: terminalMaxRebindsPerWindow, + }); + + setupProxy(app); + scheduleOpenCodeApiDetection(); + void bootstrapOpenCodeAtStartup(); + + staticRoutesRuntime.registerStaticRoutes(app); + + const serverStartupRuntime = createServerStartupRuntime({ + process, + crypto, + server, + normalizeTunnelBootstrapTtlMs, + readSettingsFromDiskMigrated, + tunnelAuthController, + startTunnelWithNormalizedRequest, + gracefulShutdown, + getSignalsAttached, + setSignalsAttached, + syncToHmrState, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + }); + + const bindHost = serverStartupRuntime.resolveBindHost(host); + const startupResult = await serverStartupRuntime.startListeningAndMaybeTunnel({ + port, + bindHost, + startupTunnelRequest, + onTunnelReady, + }); + tunnelRuntimeContext.setActivePort(startupResult.activePort); + + serverStartupRuntime.attachProcessHandlers({ attachSignals }); + + return { + terminalRuntime, + }; + }; + + return { + run, + }; +}; diff --git a/packages/web/server/lib/opencode/static-routes-runtime.js b/packages/web/server/lib/opencode/static-routes-runtime.js new file mode 100644 index 00000000..1f229239 --- /dev/null +++ b/packages/web/server/lib/opencode/static-routes-runtime.js @@ -0,0 +1,63 @@ +import { registerPwaManifestRoute } from './pwa-manifest-routes.js'; + +export const createStaticRoutesRuntime = (dependencies) => { + const { + fs, + path, + process, + __dirname, + express, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, + } = dependencies; + + const resolveDistPath = () => { + const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : ''; + if (env) { + return path.resolve(env); + } + return path.join(__dirname, '..', 'dist'); + }; + + const registerStaticRoutes = (app) => { + const distPath = resolveDistPath(); + + if (fs.existsSync(distPath)) { + console.log(`Serving static files from ${distPath}`); + app.use(express.static(distPath, { + setHeaders(res, filePath) { + // Service workers should never be long-cached; iOS is especially sensitive. + if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) { + res.setHeader('Cache-Control', 'no-store'); + } + }, + })); + + registerPwaManifestRoute(app, { + process, + resolveProjectDirectory, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + readSettingsFromDiskMigrated, + normalizePwaAppName, + }); + + app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); + return; + } + + console.warn(`Warning: ${distPath} not found, static files will not be served`); + app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + res.status(404).send('Static files not found. Please build the application first.'); + }); + }; + + return { + registerStaticRoutes, + }; +}; diff --git a/packages/web/server/lib/opencode/theme-runtime.js b/packages/web/server/lib/opencode/theme-runtime.js new file mode 100644 index 00000000..df2639e3 --- /dev/null +++ b/packages/web/server/lib/opencode/theme-runtime.js @@ -0,0 +1,167 @@ +export const createThemeRuntime = (dependencies) => { + const { + fsPromises, + path, + themesDir, + maxThemeJsonBytes, + logger, + } = dependencies; + + const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; + const isValidThemeColor = (value) => isNonEmptyString(value); + + const normalizeThemeJson = (raw) => { + if (!raw || typeof raw !== 'object') { + return null; + } + + const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null; + const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null; + if (!metadata || !colors) { + return null; + } + + const id = metadata.id; + const name = metadata.name; + const variant = metadata.variant; + if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) { + return null; + } + + const primary = colors.primary; + const surface = colors.surface; + const interactive = colors.interactive; + const status = colors.status; + const syntax = colors.syntax; + const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null; + const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null; + + if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) { + return null; + } + + // Minimal fields required by CSSVariableGenerator and diff/syntax rendering. + const required = [ + primary.base, + primary.foreground, + surface.background, + surface.foreground, + surface.muted, + surface.mutedForeground, + surface.elevated, + surface.elevatedForeground, + surface.subtle, + interactive.border, + interactive.selection, + interactive.selectionForeground, + interactive.focusRing, + interactive.hover, + status.error, + status.errorForeground, + status.errorBackground, + status.errorBorder, + status.warning, + status.warningForeground, + status.warningBackground, + status.warningBorder, + status.success, + status.successForeground, + status.successBackground, + status.successBorder, + status.info, + status.infoForeground, + status.infoBackground, + status.infoBorder, + syntaxBase.background, + syntaxBase.foreground, + syntaxBase.keyword, + syntaxBase.string, + syntaxBase.number, + syntaxBase.function, + syntaxBase.variable, + syntaxBase.type, + syntaxBase.comment, + syntaxBase.operator, + syntaxHighlights.diffAdded, + syntaxHighlights.diffRemoved, + syntaxHighlights.lineNumber, + ]; + + if (!required.every(isValidThemeColor)) { + return null; + } + + const tags = Array.isArray(metadata.tags) + ? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0) + : []; + + return { + ...raw, + metadata: { + ...metadata, + id: id.trim(), + name: name.trim(), + description: typeof metadata.description === 'string' ? metadata.description : '', + version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0', + variant, + tags, + }, + }; + }; + + const readCustomThemesFromDisk = async () => { + try { + const entries = await fsPromises.readdir(themesDir, { withFileTypes: true }); + const themes = []; + const seen = new Set(); + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().endsWith('.json')) continue; + + const filePath = path.join(themesDir, entry.name); + try { + const stat = await fsPromises.stat(filePath); + if (!stat.isFile()) continue; + if (stat.size > maxThemeJsonBytes) { + logger.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`); + continue; + } + + const rawText = await fsPromises.readFile(filePath, 'utf8'); + const parsed = JSON.parse(rawText); + const normalized = normalizeThemeJson(parsed); + if (!normalized) { + logger.warn(`[themes] Skip ${entry.name}: invalid theme JSON`); + continue; + } + + const id = normalized.metadata.id; + if (seen.has(id)) { + logger.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`); + continue; + } + + seen.add(id); + themes.push(normalized); + } catch (error) { + logger.warn(`[themes] Failed to read ${entry.name}:`, error); + } + } + + return themes; + } catch (error) { + // Missing dir is fine. + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return []; + } + logger.warn('[themes] Failed to list custom themes dir:', error); + return []; + } + }; + + return { + normalizeThemeJson, + readCustomThemesFromDisk, + }; +}; diff --git a/packages/web/server/lib/opencode/tunnel-wiring-runtime.js b/packages/web/server/lib/opencode/tunnel-wiring-runtime.js new file mode 100644 index 00000000..30f43161 --- /dev/null +++ b/packages/web/server/lib/opencode/tunnel-wiring-runtime.js @@ -0,0 +1,94 @@ +import { printTunnelWarning } from '../cloudflare-tunnel.js'; +import { createTunnelService } from '../tunnels/index.js'; +import { createTunnelRoutesRuntime } from '../tunnels/routes.js'; + +export const createTunnelWiringRuntime = (dependencies) => { + const { + crypto, + URL, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActiveTunnelController, + setActiveTunnelController, + getRuntimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname, + getRuntimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken, + } = dependencies; + + const initialize = (app, initialPort) => { + let activePort = initialPort; + + const tunnelService = createTunnelService({ + registry: tunnelProviderRegistry, + getController: getActiveTunnelController, + setController: setActiveTunnelController, + getActivePort: () => activePort, + onQuickTunnelWarning: () => { + printTunnelWarning(); + }, + }); + + const tunnelRoutesRuntime = createTunnelRoutesRuntime({ + crypto, + URL, + tunnelService, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActivePort: () => activePort, + getRuntimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname, + getRuntimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken, + getActiveTunnelController, + setActiveTunnelController, + }); + + tunnelRoutesRuntime.registerRoutes(app); + + return { + tunnelService, + startTunnelWithNormalizedRequest: (...args) => tunnelRoutesRuntime.startTunnelWithNormalizedRequest(...args), + getActivePort: () => activePort, + setActivePort: (value) => { + activePort = value; + }, + }; + }; + + return { + initialize, + }; +}; diff --git a/packages/web/server/lib/opencode/watcher.js b/packages/web/server/lib/opencode/watcher.js new file mode 100644 index 00000000..1523c4e9 --- /dev/null +++ b/packages/web/server/lib/opencode/watcher.js @@ -0,0 +1,107 @@ +export const createOpenCodeWatcherRuntime = (deps) => { + const { + waitForOpenCodePort, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + parseSseDataPayload, + onPayload, + } = deps; + + let abortController = null; + + const start = async () => { + if (abortController) { + return; + } + + await waitForOpenCodePort(); + + abortController = new AbortController(); + const signal = abortController.signal; + + let attempt = 0; + const run = async () => { + while (!signal.aborted) { + attempt += 1; + let upstream; + let reader; + try { + const url = buildOpenCodeUrl('/global/event', ''); + upstream = await fetch(url, { + headers: { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + ...getOpenCodeAuthHeaders(), + }, + signal, + }); + + if (!upstream.ok || !upstream.body) { + throw new Error(`bad status ${upstream.status}`); + } + + console.log('[PushWatcher] connected'); + + const decoder = new TextDecoder(); + reader = upstream.body.getReader(); + let buffer = ''; + + while (!signal.aborted) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); + + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex !== -1) { + const block = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + separatorIndex = buffer.indexOf('\n\n'); + const payload = parseSseDataPayload(block); + onPayload(payload); + } + } + } catch (error) { + if (signal.aborted) { + return; + } + console.warn('[PushWatcher] disconnected', error?.message ?? error); + } finally { + try { + if (reader) { + await reader.cancel(); + reader.releaseLock(); + } else if (upstream?.body && !upstream.body.locked) { + await upstream.body.cancel(); + } + } catch { + } + } + + const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + }; + + void run(); + }; + + const stop = () => { + if (!abortController) { + return; + } + try { + abortController.abort(); + } catch { + } + abortController = null; + }; + + return { + start, + stop, + }; +}; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index c9dc10f9..228bfa8c 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -5,6 +5,7 @@ This module fetches quota and usage signals for supported providers in the web s ## Entrypoints and structure - `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`. +- `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints. - `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher. - `packages/web/server/lib/quota/providers/interface.js`: JSDoc provider contract used as implementation reference. - `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules. diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js new file mode 100644 index 00000000..9f3641ee --- /dev/null +++ b/packages/web/server/lib/quota/routes.js @@ -0,0 +1,27 @@ +export function registerQuotaRoutes(app, { getQuotaProviders }) { + app.get('/api/quota/providers', async (_req, res) => { + try { + const { listConfiguredQuotaProviders } = await getQuotaProviders(); + const providers = listConfiguredQuotaProviders(); + res.json({ providers }); + } catch (error) { + console.error('Failed to list quota providers:', error); + res.status(500).json({ error: error.message || 'Failed to list quota providers' }); + } + }); + + app.get('/api/quota/:providerId', async (req, res) => { + try { + const { providerId } = req.params; + if (!providerId) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + const { fetchQuotaForProvider } = await getQuotaProviders(); + const result = await fetchQuotaForProvider(providerId); + res.json(result); + } catch (error) { + console.error('Failed to fetch quota:', error); + res.status(500).json({ error: error.message || 'Failed to fetch quota' }); + } + }); +} diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js new file mode 100644 index 00000000..6420c54a --- /dev/null +++ b/packages/web/server/lib/security/request-security.js @@ -0,0 +1,115 @@ +export const createRequestSecurityRuntime = (deps) => { + const { readSettingsFromDiskMigrated } = deps; + + const getUiSessionTokenFromRequest = (req) => { + const cookieHeader = req?.headers?.cookie; + if (!cookieHeader || typeof cookieHeader !== 'string') { + return null; + } + const segments = cookieHeader.split(';'); + for (const segment of segments) { + const [rawName, ...rest] = segment.split('='); + const name = rawName?.trim(); + if (!name) continue; + if (name !== 'oc_ui_session') continue; + const value = rest.join('=').trim(); + try { + return decodeURIComponent(value || ''); + } catch { + return value || null; + } + } + return null; + }; + + const rejectWebSocketUpgrade = (socket, statusCode, reason) => { + if (!socket || socket.destroyed) { + return; + } + + const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request'; + const body = Buffer.from(message, 'utf8'); + const statusText = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + }[statusCode] || 'Bad Request'; + + try { + socket.write( + `HTTP/1.1 ${statusCode} ${statusText}\r\n` + + 'Connection: close\r\n' + + 'Content-Type: text/plain; charset=utf-8\r\n' + + `Content-Length: ${body.length}\r\n\r\n` + ); + socket.write(body); + } catch { + } + + try { + socket.destroy(); + } catch { + } + }; + + const getRequestOriginCandidates = async (req) => { + const origins = new Set(); + const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string' + ? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase() + : ''; + const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http'); + + const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string' + ? req.headers['x-forwarded-host'].split(',')[0].trim() + : ''; + const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : ''); + + if (host) { + origins.add(`${protocol}://${host}`); + const [hostname, port] = host.split(':'); + const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : ''; + const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : ''; + if (normalizedHost === 'localhost') { + origins.add(`${protocol}://127.0.0.1${portSuffix}`); + origins.add(`${protocol}://[::1]${portSuffix}`); + } else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') { + origins.add(`${protocol}://localhost${portSuffix}`); + } + } + + try { + const settings = await readSettingsFromDiskMigrated(); + if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) { + origins.add(new URL(settings.publicOrigin.trim()).origin); + } + } catch { + } + + return origins; + }; + + const isRequestOriginAllowed = async (req) => { + const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : ''; + if (!originHeader) { + return false; + } + + let normalizedOrigin = ''; + try { + normalizedOrigin = new URL(originHeader).origin; + } catch { + return false; + } + + const allowedOrigins = await getRequestOriginCandidates(req); + return allowedOrigins.has(normalizedOrigin); + }; + + return { + getUiSessionTokenFromRequest, + rejectWebSocketUpgrade, + isRequestOriginAllowed, + }; +}; diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index 809f8361..2c477f7d 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -6,6 +6,7 @@ This module provides WebSocket protocol utilities for terminal input handling in ## Entrypoints and structure - `packages/web/server/lib/terminal/`: Terminal module directory. - `index.js`: Stable module entrypoint that re-exports protocol helpers/constants. + - `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration. - `input-ws-protocol.js`: Single-file module containing all terminal input WebSocket protocol utilities. - `packages/web/server/lib/terminal/input-ws-protocol.test.js`: Test file for protocol utilities. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js new file mode 100644 index 00000000..9453761a --- /dev/null +++ b/packages/web/server/lib/terminal/runtime.js @@ -0,0 +1,708 @@ +import { WebSocketServer } from 'ws'; +import { + TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, + TERMINAL_INPUT_WS_PATH, + createTerminalInputWsControlFrame, + isRebindRateLimited, + normalizeTerminalInputWsMessageToText, + parseRequestPathname, + pruneRebindTimestamps, + readTerminalInputWsControlFrame, +} from './index.js'; + +export function createTerminalRuntime({ + app, + server, + express, + fs, + path, + uiAuthController, + buildAugmentedPath, + searchPathFor, + isExecutable, + isRequestOriginAllowed, + rejectWebSocketUpgrade, + TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS, + TERMINAL_INPUT_WS_REBIND_WINDOW_MS, + TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW, +}) { + let ptyProviderPromise = null; + const getPtyProvider = async () => { + if (ptyProviderPromise) { + return ptyProviderPromise; + } + + ptyProviderPromise = (async () => { + const isBunRuntime = typeof globalThis.Bun !== 'undefined'; + + if (isBunRuntime) { + try { + const bunPty = await import('bun-pty'); + console.log('Using bun-pty for terminal sessions'); + return { spawn: bunPty.spawn, backend: 'bun-pty' }; + } catch (error) { + console.warn('bun-pty unavailable, falling back to node-pty'); + } + } + + try { + const nodePty = await import('node-pty'); + console.log('Using node-pty for terminal sessions'); + return { spawn: nodePty.spawn, backend: 'node-pty' }; + } catch (error) { + console.error('Failed to load node-pty:', error && error.message ? error.message : error); + if (isBunRuntime) { + throw new Error('No PTY backend available. Install bun-pty or node-pty.'); + } + throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)'); + } + })(); + + return ptyProviderPromise; + }; + + const getTerminalShellCandidates = () => { + if (process.platform === 'win32') { + const windowsCandidates = [ + process.env.OPENCHAMBER_TERMINAL_SHELL, + process.env.SHELL, + process.env.ComSpec, + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + 'pwsh.exe', + 'powershell.exe', + 'cmd.exe', + ].filter(Boolean); + + const resolved = []; + const seen = new Set(); + for (const candidateRaw of windowsCandidates) { + const candidate = String(candidateRaw).trim(); + if (!candidate) continue; + + const lookedUp = candidate.includes('\\') || candidate.includes('/') + ? candidate + : searchPathFor(candidate); + const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); + if (!executable || seen.has(executable)) continue; + seen.add(executable); + resolved.push(executable); + } + return resolved; + } + + const unixCandidates = [ + process.env.OPENCHAMBER_TERMINAL_SHELL, + process.env.SHELL, + '/bin/zsh', + '/bin/bash', + '/bin/sh', + 'zsh', + 'bash', + 'sh', + ].filter(Boolean); + + const resolved = []; + const seen = new Set(); + for (const candidateRaw of unixCandidates) { + const candidate = String(candidateRaw).trim(); + if (!candidate) continue; + + const lookedUp = candidate.includes('/') ? candidate : searchPathFor(candidate); + const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); + if (!executable || seen.has(executable)) continue; + seen.add(executable); + resolved.push(executable); + } + + return resolved; + }; + + const spawnTerminalPtyWithFallback = (pty, { cols, rows, cwd, env }) => { + const shellCandidates = getTerminalShellCandidates(); + if (shellCandidates.length === 0) { + throw new Error('No executable shell found for terminal session'); + } + + let lastError = null; + for (const shell of shellCandidates) { + try { + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: cols || 80, + rows: rows || 24, + cwd, + env: { + ...env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }, + }); + + return { ptyProcess, shell }; + } catch (error) { + lastError = error; + console.warn(`Failed to spawn PTY using shell ${shell}:`, error && error.message ? error.message : error); + } + } + + const baseMessage = lastError && lastError.message ? lastError.message : 'PTY spawn failed'; + throw new Error(`Failed to spawn terminal PTY with available shells (${shellCandidates.join(', ')}): ${baseMessage}`); + }; + + const terminalSessions = new Map(); + const MAX_TERMINAL_SESSIONS = 20; + const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000; + const sanitizeTerminalEnv = (env) => { + const next = { ...env }; + delete next.BASH_XTRACEFD; + delete next.BASH_ENV; + delete next.ENV; + return next; + }; + const terminalInputCapabilities = { + input: { + preferred: 'ws', + transports: ['http', 'ws'], + ws: { + path: TERMINAL_INPUT_WS_PATH, + v: 1, + enc: 'text+json-bin-control', + }, + }, + }; + + const sendTerminalInputWsControl = (socket, payload) => { + if (!socket || socket.readyState !== 1) { + return; + } + + try { + socket.send(createTerminalInputWsControlFrame(payload), { binary: true }); + } catch { + } + }; + + let terminalInputWsServer = new WebSocketServer({ + noServer: true, + maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, + }); + + terminalInputWsServer.on('connection', (socket) => { + const connectionState = { + boundSessionId: null, + invalidFrames: 0, + rebindTimestamps: [], + lastActivityAt: Date.now(), + }; + + sendTerminalInputWsControl(socket, { t: 'ok', v: 1 }); + + const heartbeatInterval = setInterval(() => { + if (socket.readyState !== 1) { + return; + } + + try { + socket.ping(); + } catch { + } + }, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS); + + socket.on('pong', () => { + connectionState.lastActivityAt = Date.now(); + }); + + socket.on('message', (message, isBinary) => { + connectionState.lastActivityAt = Date.now(); + + if (isBinary) { + const controlMessage = readTerminalInputWsControlFrame(message); + if (!controlMessage || typeof controlMessage.t !== 'string') { + connectionState.invalidFrames += 1; + sendTerminalInputWsControl(socket, { + t: 'e', + c: 'BAD_FRAME', + f: connectionState.invalidFrames >= 10, + }); + if (connectionState.invalidFrames >= 10) { + socket.close(1008, 'protocol violation'); + } + return; + } + + if (controlMessage.t === 'p') { + sendTerminalInputWsControl(socket, { t: 'po', v: 1 }); + return; + } + + if (controlMessage.t !== 'b' || typeof controlMessage.s !== 'string') { + connectionState.invalidFrames += 1; + sendTerminalInputWsControl(socket, { + t: 'e', + c: 'BAD_FRAME', + f: connectionState.invalidFrames >= 10, + }); + if (connectionState.invalidFrames >= 10) { + socket.close(1008, 'protocol violation'); + } + return; + } + + const now = Date.now(); + connectionState.rebindTimestamps = pruneRebindTimestamps( + connectionState.rebindTimestamps, + now, + TERMINAL_INPUT_WS_REBIND_WINDOW_MS + ); + + if (isRebindRateLimited(connectionState.rebindTimestamps, TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW)) { + sendTerminalInputWsControl(socket, { t: 'e', c: 'RATE_LIMIT', f: false }); + return; + } + + const nextSessionId = controlMessage.s.trim(); + const targetSession = terminalSessions.get(nextSessionId); + if (!targetSession) { + connectionState.boundSessionId = null; + sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); + return; + } + + connectionState.rebindTimestamps.push(now); + connectionState.boundSessionId = nextSessionId; + sendTerminalInputWsControl(socket, { t: 'bok', v: 1 }); + return; + } + + const payload = normalizeTerminalInputWsMessageToText(message); + if (payload.length === 0) { + return; + } + + if (!connectionState.boundSessionId) { + sendTerminalInputWsControl(socket, { t: 'e', c: 'NOT_BOUND', f: false }); + return; + } + + const session = terminalSessions.get(connectionState.boundSessionId); + if (!session) { + connectionState.boundSessionId = null; + sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false }); + return; + } + + try { + session.ptyProcess.write(payload); + session.lastActivity = Date.now(); + } catch { + sendTerminalInputWsControl(socket, { t: 'e', c: 'WRITE_FAIL', f: false }); + } + }); + + socket.on('close', () => { + clearInterval(heartbeatInterval); + }); + + socket.on('error', (error) => { + void error; + }); + }); + + server.on('upgrade', (req, socket, head) => { + const pathname = parseRequestPathname(req.url); + if (pathname !== TERMINAL_INPUT_WS_PATH) { + return; + } + + const handleUpgrade = async () => { + try { + if (uiAuthController?.enabled) { + // Must be awaited: this call performs async token verification. + const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null); + if (!sessionToken) { + rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); + return; + } + + const originAllowed = await isRequestOriginAllowed(req); + if (!originAllowed) { + rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); + return; + } + } + + if (!terminalInputWsServer) { + rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); + return; + } + + terminalInputWsServer.handleUpgrade(req, socket, head, (ws) => { + terminalInputWsServer.emit('connection', ws, req); + }); + } catch { + rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); + } + }; + + void handleUpgrade(); + }); + + const idleSweepInterval = setInterval(() => { + const now = Date.now(); + for (const [sessionId, session] of terminalSessions.entries()) { + if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) { + console.log(`Cleaning up idle terminal session: ${sessionId}`); + try { + session.ptyProcess.kill(); + } catch (error) { + + } + terminalSessions.delete(sessionId); + } + } + }, 5 * 60 * 1000); + + app.post('/api/terminal/create', async (req, res) => { + try { + if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) { + return res.status(429).json({ error: 'Maximum terminal sessions reached' }); + } + + const { cwd, cols, rows } = req.body; + if (!cwd) { + return res.status(400).json({ error: 'cwd is required' }); + } + + try { + await fs.promises.access(cwd); + } catch { + return res.status(400).json({ error: 'Invalid working directory' }); + } + + const sessionId = Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + const envPath = buildAugmentedPath(); + const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); + + const pty = await getPtyProvider(); + const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { + cols, + rows, + cwd, + env: resolvedEnv, + }); + + const session = { + ptyProcess, + ptyBackend: pty.backend, + cwd, + lastActivity: Date.now(), + clients: new Set(), + }; + + terminalSessions.set(sessionId, session); + + ptyProcess.onExit(({ exitCode, signal }) => { + console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`); + terminalSessions.delete(sessionId); + }); + + console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`); + res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); + } catch (error) { + console.error('Failed to create terminal session:', error); + res.status(500).json({ error: error.message || 'Failed to create terminal session' }); + } + }); + + app.get('/api/terminal/:sessionId/stream', (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + + const clientId = Math.random().toString(36).substring(7); + session.clients.add(clientId); + session.lastActivity = Date.now(); + + const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun'; + const ptyBackend = session.ptyBackend || 'unknown'; + res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`); + + const heartbeatInterval = setInterval(() => { + try { + + res.write(': heartbeat\n\n'); + } catch (error) { + console.error(`Heartbeat failed for client ${clientId}:`, error); + clearInterval(heartbeatInterval); + } + }, 15000); + + const dataHandler = (data) => { + try { + session.lastActivity = Date.now(); + const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`); + if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') { + session.ptyProcess.pause(); + res.once('drain', () => { + if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') { + session.ptyProcess.resume(); + } + }); + } + } catch (error) { + console.error(`Error sending data to client ${clientId}:`, error); + cleanup(); + } + }; + + const exitHandler = ({ exitCode, signal }) => { + try { + res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`); + res.end(); + } catch (error) { + + } + cleanup(); + }; + + const dataDisposable = session.ptyProcess.onData(dataHandler); + const exitDisposable = session.ptyProcess.onExit(exitHandler); + + const cleanup = () => { + clearInterval(heartbeatInterval); + session.clients.delete(clientId); + + if (dataDisposable && typeof dataDisposable.dispose === 'function') { + dataDisposable.dispose(); + } + if (exitDisposable && typeof exitDisposable.dispose === 'function') { + exitDisposable.dispose(); + } + + try { + res.end(); + } catch (error) { + + } + + console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`); + }; + + req.on('close', cleanup); + req.on('error', cleanup); + + console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`); + }); + + app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + const data = typeof req.body === 'string' ? req.body : ''; + + try { + session.ptyProcess.write(data); + session.lastActivity = Date.now(); + res.json({ success: true }); + } catch (error) { + console.error('Failed to write to terminal:', error); + res.status(500).json({ error: error.message || 'Failed to write to terminal' }); + } + }); + + app.post('/api/terminal/:sessionId/resize', (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + const { cols, rows } = req.body; + if (!cols || !rows) { + return res.status(400).json({ error: 'cols and rows are required' }); + } + + try { + session.ptyProcess.resize(cols, rows); + session.lastActivity = Date.now(); + res.json({ success: true, cols, rows }); + } catch (error) { + console.error('Failed to resize terminal:', error); + res.status(500).json({ error: error.message || 'Failed to resize terminal' }); + } + }); + + app.delete('/api/terminal/:sessionId', (req, res) => { + const { sessionId } = req.params; + const session = terminalSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Terminal session not found' }); + } + + try { + session.ptyProcess.kill(); + terminalSessions.delete(sessionId); + console.log(`Closed terminal session: ${sessionId}`); + res.json({ success: true }); + } catch (error) { + console.error('Failed to close terminal:', error); + res.status(500).json({ error: error.message || 'Failed to close terminal' }); + } + }); + + app.post('/api/terminal/:sessionId/restart', async (req, res) => { + const { sessionId } = req.params; + const { cwd, cols, rows } = req.body; + + if (!cwd) { + return res.status(400).json({ error: 'cwd is required' }); + } + + const existingSession = terminalSessions.get(sessionId); + if (existingSession) { + try { + existingSession.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(sessionId); + } + + try { + try { + const stats = await fs.promises.stat(cwd); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Invalid working directory: not a directory' }); + } + } catch (error) { + return res.status(400).json({ error: 'Invalid working directory: not accessible' }); + } + + const newSessionId = Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + const envPath = buildAugmentedPath(); + const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath }); + + const pty = await getPtyProvider(); + const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { + cols, + rows, + cwd, + env: resolvedEnv, + }); + + const session = { + ptyProcess, + ptyBackend: pty.backend, + cwd, + lastActivity: Date.now(), + clients: new Set(), + }; + + terminalSessions.set(newSessionId, session); + + ptyProcess.onExit(({ exitCode, signal }) => { + console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`); + terminalSessions.delete(newSessionId); + }); + + console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`); + res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); + } catch (error) { + console.error('Failed to restart terminal session:', error); + res.status(500).json({ error: error.message || 'Failed to restart terminal session' }); + } + }); + + app.post('/api/terminal/force-kill', (req, res) => { + const { sessionId, cwd } = req.body; + let killedCount = 0; + + if (sessionId) { + const session = terminalSessions.get(sessionId); + if (session) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(sessionId); + killedCount++; + } + } else if (cwd) { + for (const [id, session] of terminalSessions) { + if (session.cwd === cwd) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(id); + killedCount++; + } + } + } else { + for (const [id, session] of terminalSessions) { + try { + session.ptyProcess.kill(); + } catch (error) { + } + terminalSessions.delete(id); + killedCount++; + } + } + + console.log(`Force killed ${killedCount} terminal session(s)`); + res.json({ success: true, killedCount }); + }); + + const shutdown = async () => { + if (idleSweepInterval) { + clearInterval(idleSweepInterval); + } + + for (const [sessionId, session] of terminalSessions.entries()) { + try { + session.ptyProcess.kill(); + } catch { + } + terminalSessions.delete(sessionId); + } + + if (!terminalInputWsServer) { + return; + } + + try { + for (const client of terminalInputWsServer.clients) { + try { + client.terminate(); + } catch { + } + } + + await new Promise((resolve) => { + terminalInputWsServer.close(() => resolve()); + }); + } catch { + } finally { + terminalInputWsServer = null; + } + }; + + return { shutdown }; +} diff --git a/packages/web/server/lib/tts/DOCUMENTATION.md b/packages/web/server/lib/tts/DOCUMENTATION.md index 73d5ab84..e80dc746 100644 --- a/packages/web/server/lib/tts/DOCUMENTATION.md +++ b/packages/web/server/lib/tts/DOCUMENTATION.md @@ -5,6 +5,8 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API, ## Entrypoints and structure - `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`. +- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*` and `/api/tts/*` endpoints. +- `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability. - `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration. - `packages/web/server/lib/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API. @@ -19,6 +21,9 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API, - `summarizeText({ text, threshold, maxLength, zenModel })`: Summarizes text for TTS output using opencode.ai zen API. - `sanitizeForTTS(text)`: Sanitizes text by removing markdown, URLs, file paths, and other non-speakable content. +### Capability runtime (capability-runtime.js) +- `detectSayTtsCapability(processLike)`: probes local `say -v "?"` support and returns `{ available, voices, reason }`. + ## Constants ### Voice identifiers diff --git a/packages/web/server/lib/tts/capability-runtime.js b/packages/web/server/lib/tts/capability-runtime.js new file mode 100644 index 00000000..7dc1dc3a --- /dev/null +++ b/packages/web/server/lib/tts/capability-runtime.js @@ -0,0 +1,31 @@ +export const detectSayTtsCapability = async (processLike) => { + let sayTTSCapability = { available: false, voices: [], reason: 'Not checked' }; + + if (processLike.platform === 'darwin') { + try { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + const { stdout } = await execAsync('say -v "?"'); + const voices = stdout.split('\n') + .filter((line) => line.trim()) + .map((line) => { + const match = line.match(/^(.+?)\s+([a-zA-Z]{2}_[a-zA-Z]{2,3})\s+#/); + if (match) { + return { name: match[1].trim(), locale: match[2] }; + } + return null; + }) + .filter(Boolean); + sayTTSCapability = { available: true, voices }; + console.log(`macOS Say TTS available with ${voices.length} voices`); + } catch (error) { + sayTTSCapability = { available: false, voices: [], reason: 'say command not available' }; + console.log('macOS Say TTS not available:', error.message); + } + } else { + sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' }; + } + + return sayTTSCapability; +}; diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js new file mode 100644 index 00000000..170c80ac --- /dev/null +++ b/packages/web/server/lib/tts/routes.js @@ -0,0 +1,225 @@ +export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { + let ttsModulePromise = null; + const getTtsModule = async () => { + if (!ttsModulePromise) { + ttsModulePromise = import('./index.js'); + } + return ttsModulePromise; + }; + + app.post('/api/voice/token', async (req, res) => { + console.log('[Voice] Token request received:', { + contentType: req.headers['content-type'] || null, + }); + try { + const openaiApiKey = process.env.OPENAI_API_KEY; + console.log('[Voice] OpenAI API Key present:', !!openaiApiKey); + + if (!openaiApiKey) { + return res.status(503).json({ + allowed: false, + error: 'OpenAI voice service not configured. Set OPENAI_API_KEY environment variable.' + }); + } + + // Return success - OpenAI TTS is available + res.json({ + allowed: true, + provider: 'openai', + message: 'OpenAI TTS is available' + }); + } catch (error) { + console.error('[Voice] Token generation error:', error); + res.status(500).json({ + allowed: false, + error: 'Voice service error' + }); + } + }); + + // Server-side TTS endpoint - streams audio from OpenAI TTS API + app.post('/api/tts/speak', async (req, res) => { + try { + const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey } = req.body || {}; + + console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey }); + + if (!text || typeof text !== 'string' || !text.trim()) { + return res.status(400).json({ error: 'Text is required' }); + } + + // Dynamically import the TTS service (ESM) + const { ttsService } = await getTtsModule(); + + // Check availability - either server-configured or client-provided API key + const hasServerKey = ttsService.isAvailable(); + const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0; + + if (!hasServerKey && !hasClientKey) { + return res.status(503).json({ + error: 'TTS service not available. Please configure OpenAI in OpenCode or provide an API key in settings.' + }); + } + + let textToSpeak = text.trim(); + + // Optionally summarize long text before speaking using zen API + if (summarize && textToSpeak.length > threshold) { + try { + const { summarizeText } = await getTtsModule(); + const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel }); + + if (result.summarized && result.summary) { + textToSpeak = result.summary; + } + } catch (summarizeError) { + console.error('[TTS/speak] Summarization failed:', summarizeError); + // Continue with original text if summarization fails + } + } + + const result = await ttsService.generateSpeechStream({ + text: textToSpeak, + voice, + model, + speed, + instructions, + apiKey: hasClientKey ? apiKey.trim() : undefined + }); + + // Set headers for audio streaming + // Note: Don't set Transfer-Encoding manually - Express handles it automatically + res.setHeader('Content-Type', result.contentType); + res.setHeader('Cache-Control', 'no-cache'); + + // Collect the full audio buffer and send it + // This avoids chunked encoding issues with proxies + const reader = result.stream.getReader(); + const chunks = []; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(Buffer.from(value)); + } + const audioBuffer = Buffer.concat(chunks); + res.setHeader('Content-Length', audioBuffer.length); + res.send(audioBuffer); + } catch (streamError) { + console.error('[TTS] Stream error:', streamError); + if (!res.headersSent) { + res.status(500).json({ error: 'Stream error' }); + } else { + res.end(); + } + } + } catch (error) { + console.error('[TTS] Error:', error); + if (!res.headersSent) { + res.status(500).json({ + error: error instanceof Error ? error.message : 'TTS generation failed' + }); + } + } + }); + + app.post('/api/tts/summarize', async (req, res) => { + try { + const { summarizeText } = await getTtsModule(); + const { text, threshold = 200, maxLength = 500 } = req.body || {}; + + if (!text || typeof text !== 'string' || !text.trim()) { + return res.status(400).json({ error: 'Text is required' }); + } + + const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel }); + + return res.json(result); + } catch (error) { + console.error('[Summarize] Error:', error); + const { sanitizeForTTS } = await getTtsModule(); + const sanitized = sanitizeForTTS(req.body?.text || ''); + return res.json({ summary: sanitized, summarized: false, reason: error.message }); + } + }); + + + // TTS status endpoint + app.get('/api/tts/status', async (_req, res) => { + try { + const { ttsService } = await getTtsModule(); + res.json({ + available: ttsService.isAvailable(), + voices: [ + 'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', + 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar' + ] + }); + } catch (error) { + res.status(500).json({ error: 'Failed to check TTS status' }); + } + }); + + // macOS 'say' command TTS status endpoint - returns cached capability from startup + app.get('/api/tts/say/status', (_req, res) => { + res.json(sayTTSCapability); + }); + + // macOS 'say' command TTS speak endpoint + app.post('/api/tts/say/speak', async (req, res) => { + try { + const { text, voice = 'Samantha', rate = 200 } = req.body || {}; + + if (!text || typeof text !== 'string' || !text.trim()) { + return res.status(400).json({ error: 'Text is required' }); + } + + // Check if we're on macOS + if (process.platform !== 'darwin') { + return res.status(503).json({ error: 'macOS say command not available on this platform' }); + } + + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const fs = await import('fs'); + const os = await import('os'); + const path = await import('path'); + const execAsync = promisify(exec); + + // Create temp file for audio output (use m4a for browser compatibility) + const tempDir = os.tmpdir(); + const tempFile = path.join(tempDir, `say-${Date.now()}.m4a`); + + // Escape text for shell - escape both single quotes and double quotes + const escapedText = text.trim().replace(/'/g, "'\\''").replace(/"/g, '\\"'); + + // Generate audio file using 'say' command + // -o outputs to file, -r sets rate (words per minute) + // --data-format=aac outputs as m4a which browsers can decode + const cmd = `say -v "${voice}" -r ${rate} -o "${tempFile}" --data-format=aac '${escapedText}'`; + console.log('[TTS-Say] Generating speech:', { textLength: text.length, voice, rate }); + + await execAsync(cmd); + + // Read the generated audio file + const audioBuffer = await fs.promises.readFile(tempFile); + + // Clean up temp file + fs.promises.unlink(tempFile).catch(() => {}); + + // Send audio response + res.setHeader('Content-Type', 'audio/mp4'); + res.setHeader('Content-Length', audioBuffer.length); + res.send(audioBuffer); + + } catch (error) { + console.error('[TTS-Say] Error:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Say command failed' + }); + } + }); +} diff --git a/packages/web/server/lib/tunnels/DOCUMENTATION.md b/packages/web/server/lib/tunnels/DOCUMENTATION.md new file mode 100644 index 00000000..53f859dd --- /dev/null +++ b/packages/web/server/lib/tunnels/DOCUMENTATION.md @@ -0,0 +1,18 @@ +# Tunnels Module Documentation + +## Purpose +This module contains tunnel provider orchestration for OpenChamber, including provider registry/service wiring, managed remote token config lifecycle, and tunnel HTTP route registration. + +## Entrypoints and structure +- `packages/web/server/lib/tunnels/index.js`: tunnel service orchestration. +- `packages/web/server/lib/tunnels/registry.js`: provider registry. +- `packages/web/server/lib/tunnels/managed-config.js`: managed remote tunnel token/preset persistence runtime. +- `packages/web/server/lib/tunnels/routes.js`: tunnel API route registration and request orchestration runtime. +- `packages/web/server/lib/tunnels/types.js`: tunnel constants, normalization, and shared type helpers. +- `packages/web/server/lib/tunnels/providers/cloudflare.js`: Cloudflare tunnel provider implementation. + +## Public exports (routes.js) +- `createTunnelRoutesRuntime(dependencies)`: creates tunnel routes runtime and helpers. +- Returned API: + - `registerRoutes(app)` + - `startTunnelWithNormalizedRequest(request)` diff --git a/packages/web/server/lib/tunnels/managed-config.js b/packages/web/server/lib/tunnels/managed-config.js new file mode 100644 index 00000000..cb4c6da0 --- /dev/null +++ b/packages/web/server/lib/tunnels/managed-config.js @@ -0,0 +1,201 @@ +export const createManagedTunnelConfigRuntime = (deps) => { + const { + fsPromises, + path, + normalizeManagedRemoteTunnelHostname, + normalizeManagedRemoteTunnelPresets, + constants, + } = deps; + + const { + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, + CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, + CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + } = constants; + + let persistManagedRemoteTunnelConfigLock = Promise.resolve(); + + const sanitizeManagedRemoteTunnelConfigEntries = (value) => { + if (!Array.isArray(value)) { + return []; + } + + const result = []; + const seenIds = new Set(); + const seenHostnames = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + + const id = typeof entry.id === 'string' ? entry.id.trim() : ''; + const name = typeof entry.name === 'string' ? entry.name.trim() : ''; + const hostname = normalizeManagedRemoteTunnelHostname(entry.hostname); + const token = typeof entry.token === 'string' ? entry.token.trim() : ''; + const updatedAt = Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(); + + if (!id || !name || !hostname || !token) { + continue; + } + if (seenIds.has(id) || seenHostnames.has(hostname)) { + continue; + } + + seenIds.add(id); + seenHostnames.add(hostname); + result.push({ id, name, hostname, token, updatedAt }); + } + + return result; + }; + + const writeManagedRemoteTunnelConfigToDisk = async (data) => { + await fsPromises.mkdir(path.dirname(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); + }; + + const migrateManagedRemoteTunnelConfigFromLegacyFile = async () => { + try { + const legacyRaw = await fsPromises.readFile(CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(legacyRaw); + const tunnels = sanitizeManagedRemoteTunnelConfigEntries(parsed?.tunnels); + const migrated = { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels, + }; + await writeManagedRemoteTunnelConfigToDisk(migrated); + return migrated; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + console.warn('Failed to migrate legacy named tunnel config file:', error); + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + }; + + const readManagedRemoteTunnelConfigFromDisk = async () => { + try { + const raw = await fsPromises.readFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + + return { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: sanitizeManagedRemoteTunnelConfigEntries(parsed.tunnels), + }; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return migrateManagedRemoteTunnelConfigFromLegacyFile(); + } + console.warn('Failed to read managed remote tunnel config file:', error); + return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] }; + } + }; + + const updateManagedRemoteTunnelConfig = async (mutate) => { + persistManagedRemoteTunnelConfigLock = persistManagedRemoteTunnelConfigLock.then(async () => { + const current = await readManagedRemoteTunnelConfigFromDisk(); + const next = mutate({ + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: sanitizeManagedRemoteTunnelConfigEntries(current.tunnels), + }); + + await writeManagedRemoteTunnelConfigToDisk({ + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: sanitizeManagedRemoteTunnelConfigEntries(next?.tunnels), + }); + }); + + return persistManagedRemoteTunnelConfigLock; + }; + + const syncManagedRemoteTunnelConfigWithPresets = async (presets) => { + const sanitizedPresets = normalizeManagedRemoteTunnelPresets(presets) || []; + + await updateManagedRemoteTunnelConfig((current) => { + const byId = new Map(current.tunnels.map((entry) => [entry.id, entry])); + const byHostname = new Map(current.tunnels.map((entry) => [entry.hostname, entry])); + + const nextTunnels = []; + for (const preset of sanitizedPresets) { + const existing = byId.get(preset.id) || byHostname.get(preset.hostname) || null; + if (!existing) { + continue; + } + + nextTunnels.push({ + ...existing, + id: preset.id, + name: preset.name, + hostname: preset.hostname, + }); + } + + return { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: nextTunnels, + }; + }); + }; + + const upsertManagedRemoteTunnelToken = async ({ id, name, hostname, token }) => { + if (typeof id !== 'string' || typeof name !== 'string' || typeof hostname !== 'string' || typeof token !== 'string') { + return; + } + const normalizedId = id.trim(); + const normalizedName = name.trim(); + const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); + const normalizedToken = token.trim(); + if (!normalizedId || !normalizedName || !normalizedHostname || !normalizedToken) { + return; + } + + await updateManagedRemoteTunnelConfig((current) => { + const withoutConflicts = current.tunnels.filter((entry) => entry.id !== normalizedId && entry.hostname !== normalizedHostname); + withoutConflicts.push({ + id: normalizedId, + name: normalizedName, + hostname: normalizedHostname, + token: normalizedToken, + updatedAt: Date.now(), + }); + + return { + version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, + tunnels: withoutConflicts, + }; + }); + }; + + const resolveManagedRemoteTunnelToken = async ({ presetId, hostname }) => { + const normalizedPresetId = typeof presetId === 'string' ? presetId.trim() : ''; + const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname); + const config = await readManagedRemoteTunnelConfigFromDisk(); + + if (normalizedPresetId) { + const byId = config.tunnels.find((entry) => entry.id === normalizedPresetId); + if (byId?.token) { + return byId.token; + } + } + + if (normalizedHostname) { + const byHostname = config.tunnels.find((entry) => entry.hostname === normalizedHostname); + if (byHostname?.token) { + return byHostname.token; + } + } + + return ''; + }; + + return { + readManagedRemoteTunnelConfigFromDisk, + syncManagedRemoteTunnelConfigWithPresets, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + }; +}; diff --git a/packages/web/server/lib/tunnels/routes.js b/packages/web/server/lib/tunnels/routes.js new file mode 100644 index 00000000..fa97cece --- /dev/null +++ b/packages/web/server/lib/tunnels/routes.js @@ -0,0 +1,605 @@ +export const createTunnelRoutesRuntime = (dependencies) => { + const { + crypto, + URL, + tunnelService, + tunnelProviderRegistry, + tunnelAuthController, + readSettingsFromDiskMigrated, + readManagedRemoteTunnelConfigFromDisk, + normalizeTunnelProvider, + normalizeTunnelMode, + normalizeOptionalPath, + normalizeManagedRemoteTunnelHostname, + normalizeTunnelBootstrapTtlMs, + normalizeTunnelSessionTtlMs, + isSupportedTunnelMode, + upsertManagedRemoteTunnelToken, + resolveManagedRemoteTunnelToken, + TUNNEL_MODE_QUICK, + TUNNEL_MODE_MANAGED_LOCAL, + TUNNEL_MODE_MANAGED_REMOTE, + TUNNEL_PROVIDER_CLOUDFLARE, + TunnelServiceError, + getActivePort, + getRuntimeManagedRemoteTunnelHostname, + setRuntimeManagedRemoteTunnelHostname, + getRuntimeManagedRemoteTunnelToken, + setRuntimeManagedRemoteTunnelToken, + getActiveTunnelController, + setActiveTunnelController, + } = dependencies; + + const resolveActiveNormalizedTunnelMode = () => { + const mode = tunnelService.resolveActiveMode(); + if (mode === TUNNEL_MODE_MANAGED_LOCAL) { + return TUNNEL_MODE_MANAGED_LOCAL; + } + if (mode === TUNNEL_MODE_MANAGED_REMOTE) { + return TUNNEL_MODE_MANAGED_REMOTE; + } + return TUNNEL_MODE_QUICK; + }; + + const resolveNormalizedTunnelHost = (publicUrl) => { + if (typeof publicUrl !== 'string' || publicUrl.trim().length === 0) { + return null; + } + try { + return new URL(publicUrl).hostname.toLowerCase(); + } catch { + return null; + } + }; + + const resolvePreferredTunnelProvider = async (reqBody = null) => { + if (typeof reqBody?.provider === 'string' && reqBody.provider.trim().length > 0) { + return normalizeTunnelProvider(reqBody.provider); + } + const activeProvider = tunnelService.resolveActiveProvider(); + if (activeProvider) { + return normalizeTunnelProvider(activeProvider); + } + const settings = await readSettingsFromDiskMigrated(); + return normalizeTunnelProvider(settings?.tunnelProvider); + }; + + const startTunnelWithNormalizedRequest = async ({ + provider, + mode, + intent, + hostname, + token, + configPath, + selectedPresetId, + selectedPresetName, + }) => { + if (provider === TUNNEL_PROVIDER_CLOUDFLARE && mode === TUNNEL_MODE_MANAGED_REMOTE) { + setRuntimeManagedRemoteTunnelHostname(hostname); + setRuntimeManagedRemoteTunnelToken(token); + + if (token && hostname) { + await upsertManagedRemoteTunnelToken({ + id: selectedPresetId || hostname, + name: selectedPresetName || hostname, + hostname, + token, + }); + } + } + + const result = await tunnelService.start({ + provider, + mode, + intent, + configPath, + token, + hostname, + }); + + console.log(`Tunnel active (${result.provider}): ${result.publicUrl}`); + return { + publicUrl: result.publicUrl, + mode: result.activeMode, + provider: result.provider, + providerMetadata: result.providerMetadata, + }; + }; + + const createGenericModeChecks = ({ modeKey, requiredFields, doctorRequest, startupReady }) => { + const checks = [ + { + id: 'startup_readiness', + label: 'Provider startup readiness', + status: startupReady ? 'pass' : 'fail', + detail: startupReady + ? 'Provider dependency checks passed.' + : 'Resolve provider checks before starting tunnels.', + }, + ]; + + for (const field of requiredFields) { + const value = doctorRequest?.[field]; + const present = typeof value === 'string' ? value.trim().length > 0 : Boolean(value); + checks.push({ + id: `requirement_${field}`, + label: `Required: ${field}`, + status: present ? 'pass' : 'fail', + detail: present + ? `${field} is configured.` + : `${field} is required for ${modeKey}.`, + }); + } + + const failures = checks.filter((entry) => entry.status === 'fail').length; + const warnings = checks.filter((entry) => entry.status === 'warn').length; + return { + mode: modeKey, + checks, + summary: { + ready: failures === 0, + failures, + warnings, + }, + ready: failures === 0, + blockers: checks + .filter((entry) => entry.status === 'fail' && entry.id !== 'startup_readiness') + .map((entry) => entry.detail || entry.label || entry.id), + }; + }; + + const runTunnelDoctor = async ({ providerId, modeFilter, doctorRequest }) => { + const provider = tunnelProviderRegistry.get(providerId); + if (!provider) { + throw new TunnelServiceError('provider_unsupported', `Unsupported tunnel provider: ${providerId}`); + } + + const capabilities = provider.capabilities || {}; + const modeKeys = Array.isArray(capabilities.modes) + ? capabilities.modes.map((entry) => entry?.key).filter((key) => typeof key === 'string' && key.length > 0) + : []; + + if (modeFilter && !modeKeys.includes(modeFilter)) { + throw new TunnelServiceError('mode_unsupported', `Provider '${providerId}' does not support mode '${modeFilter}'`); + } + + if (typeof provider.diagnose === 'function') { + const diagnosed = await provider.diagnose({ + ...doctorRequest, + mode: modeFilter || doctorRequest?.mode, + }, { + capabilities, + }); + const providerChecks = Array.isArray(diagnosed?.providerChecks) ? diagnosed.providerChecks : []; + const allModes = Array.isArray(diagnosed?.modes) ? diagnosed.modes : []; + const modes = modeFilter ? allModes.filter((entry) => entry?.mode === modeFilter) : allModes; + return { + ok: true, + provider: providerId, + providerChecks, + modes, + }; + } + + const availability = await tunnelService.checkAvailability(providerId); + const dependencyAvailable = Boolean(availability?.available); + const providerChecks = [{ + id: 'dependency', + label: 'Provider dependency', + status: dependencyAvailable ? 'pass' : 'fail', + detail: dependencyAvailable + ? (availability?.version || 'available') + : (availability?.message || 'Required provider dependency is unavailable.'), + }]; + + const targetModes = (Array.isArray(capabilities.modes) ? capabilities.modes : []) + .filter((entry) => !modeFilter || entry?.key === modeFilter); + const modes = targetModes.map((entry) => createGenericModeChecks({ + modeKey: entry.key, + requiredFields: Array.isArray(entry?.requires) ? entry.requires : [], + doctorRequest, + startupReady: dependencyAvailable, + })); + + return { + ok: true, + provider: providerId, + providerChecks, + modes, + }; + }; + + const registerRoutes = (app) => { + app.get('/api/openchamber/tunnel/check', async (req, res) => { + try { + const requestedProvider = typeof req?.query?.provider === 'string' && req.query.provider.trim().length > 0 + ? normalizeTunnelProvider(req.query.provider) + : await resolvePreferredTunnelProvider(); + const result = await tunnelService.checkAvailability(requestedProvider); + res.json({ + available: result.available, + provider: requestedProvider, + version: result.version || null, + }); + } catch (error) { + console.warn('Tunnel dependency check failed:', error); + res.json({ available: false, provider: null, version: null }); + } + }); + + const handleTunnelDoctor = async (req, res) => { + try { + const params = req.query || {}; + const body = req.body || {}; + + const providerId = typeof params.provider === 'string' && params.provider.trim().length > 0 + ? normalizeTunnelProvider(params.provider) + : await resolvePreferredTunnelProvider(); + const modeFilter = typeof params.mode === 'string' && params.mode.trim().length > 0 + ? params.mode.trim().toLowerCase() + : null; + + const settings = await readSettingsFromDiskMigrated(); + const selectedPresetId = typeof params.managedRemoteTunnelPresetId === 'string' + ? params.managedRemoteTunnelPresetId.trim() + : ''; + const requestConfigPath = normalizeOptionalPath(params.configPath) + ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); + const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(params.managedRemoteTunnelHostname); + const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(params.tunnelHostname); + const requestHostname = normalizeManagedRemoteTunnelHostname(params.hostname); + const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); + const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; + + const requestManagedRemoteToken = typeof body.managedRemoteTunnelToken === 'string' + ? body.managedRemoteTunnelToken.trim() + : ''; + const requestTunnelToken = typeof body.tunnelToken === 'string' + ? body.tunnelToken.trim() + : ''; + const requestToken = typeof body.token === 'string' + ? body.token.trim() + : ''; + const requestTokenProvided = body.managedRemoteTunnelTokenProvided === true + || body.tunnelTokenProvided === true + || body.tokenProvided === true; + const requestHostnameProvided = body.managedRemoteTunnelHostnameProvided === true + || body.tunnelHostnameProvided === true + || body.hostnameProvided === true; + const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' + ? settings.managedRemoteTunnelToken.trim() + : ''; + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + const serverHasSavedManagedRemoteProfile = managedRemoteTunnelConfig.tunnels.some((entry) => { + const savedHostname = normalizeManagedRemoteTunnelHostname(entry?.hostname); + const savedToken = typeof entry?.token === 'string' ? entry.token.trim() : ''; + return Boolean(savedHostname && savedToken); + }); + const cliHasSavedManagedRemoteProfile = params.hasSavedManagedRemoteProfile === '1'; + const hasSavedManagedRemoteProfile = serverHasSavedManagedRemoteProfile || cliHasSavedManagedRemoteProfile; + const configManagedRemoteToken = providerId === TUNNEL_PROVIDER_CLOUDFLARE + ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) + : ''; + const runtimeHostname = getRuntimeManagedRemoteTunnelHostname(); + const runtimeToken = getRuntimeManagedRemoteTunnelToken(); + const token = requestToken + || requestTunnelToken + || requestManagedRemoteToken + || ((runtimeHostname && hostname && runtimeHostname === hostname) ? runtimeToken : '') + || configManagedRemoteToken + || storedManagedRemoteToken; + + const doctorRequest = { + mode: modeFilter, + hostname, + token, + tokenProvided: requestTokenProvided, + hostnameProvided: requestHostnameProvided, + configPath: requestConfigPath, + hasSavedManagedRemoteProfile, + }; + + const result = await runTunnelDoctor({ + providerId, + modeFilter, + doctorRequest, + }); + return res.json(result); + } catch (error) { + if (error instanceof TunnelServiceError) { + return res.status(400).json({ ok: false, error: error.message, code: error.code }); + } + console.warn('Tunnel doctor failed:', error); + return res.status(500).json({ ok: false, error: 'Failed to run tunnel doctor' }); + } + }; + app.post('/api/openchamber/tunnel/doctor', handleTunnelDoctor); + app.get('/api/openchamber/tunnel/doctor', handleTunnelDoctor); + + app.get('/api/openchamber/tunnel/providers', (_req, res) => { + const providers = tunnelProviderRegistry.listCapabilities(); + return res.json({ providers }); + }); + + app.get('/api/openchamber/tunnel/status', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + const normalizedMode = normalizeTunnelMode(settings?.tunnelMode); + const managedRemoteHostname = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + const managedRemoteTunnelPresetSummaries = managedRemoteTunnelConfig.tunnels.map((entry) => ({ + id: entry.id, + name: entry.name, + hostname: entry.hostname, + })); + const hasStoredManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; + const hasManagedRemoteTunnelToken = getRuntimeManagedRemoteTunnelToken().length > 0 || managedRemoteTunnelConfig.tunnels.length > 0 || hasStoredManagedRemoteToken; + const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null + ? null + : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs); + const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); + const activeSessions = tunnelAuthController.listTunnelSessions(); + const activeProvider = tunnelService.resolveActiveProvider(); + const provider = activeProvider || normalizeTunnelProvider(settings?.tunnelProvider); + + const publicUrl = tunnelService.getPublicUrl(); + if (!publicUrl) { + return res.json({ + active: false, + url: null, + mode: normalizedMode, + provider, + providerMetadata: null, + hasManagedRemoteTunnelToken, + managedRemoteTunnelHostname: managedRemoteHostname || null, + managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, + managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), + hasBootstrapToken: false, + bootstrapExpiresAt: null, + policy: 'tunnel-gated', + activeTunnelMode: tunnelAuthController.getActiveTunnelMode() || null, + activeSessions, + localPort: getActivePort(), + ttlConfig: { + bootstrapTtlMs, + sessionTtlMs, + }, + }); + } + + const activeNormalizedMode = resolveActiveNormalizedTunnelMode(); + const activeTunnelId = tunnelAuthController.getActiveTunnelId(); + const activeTunnelHost = tunnelAuthController.getActiveTunnelHost(); + const resolvedTunnelHost = resolveNormalizedTunnelHost(publicUrl); + const activeTunnelMode = tunnelAuthController.getActiveTunnelMode(); + const needsActiveTunnelSync = !activeTunnelId + || !activeTunnelHost + || !resolvedTunnelHost + || activeTunnelHost !== resolvedTunnelHost + || activeTunnelMode !== activeNormalizedMode; + if (needsActiveTunnelSync) { + tunnelAuthController.setActiveTunnel({ + tunnelId: activeTunnelId || crypto.randomUUID(), + publicUrl, + mode: activeNormalizedMode, + }); + } + + const bootstrapStatus = tunnelAuthController.getBootstrapStatus(); + const providerMetadata = tunnelService.getProviderMetadata(); + + return res.json({ + active: true, + url: publicUrl, + mode: activeNormalizedMode, + provider, + providerMetadata, + hasManagedRemoteTunnelToken, + managedRemoteTunnelHostname: managedRemoteHostname || null, + managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries, + managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id), + hasBootstrapToken: bootstrapStatus.hasBootstrapToken, + bootstrapExpiresAt: bootstrapStatus.bootstrapExpiresAt, + policy: 'tunnel-gated', + activeTunnelMode: activeNormalizedMode, + activeSessions: tunnelAuthController.listTunnelSessions(), + localPort: getActivePort(), + ttlConfig: { + bootstrapTtlMs, + sessionTtlMs, + }, + }); + } catch (error) { + return res.status(500).json({ error: 'Failed to get tunnel status' }); + } + }); + + app.put('/api/openchamber/tunnel/managed-remote-token', async (req, res) => { + try { + const presetId = typeof req?.body?.presetId === 'string' ? req.body.presetId.trim() : ''; + const presetName = typeof req?.body?.presetName === 'string' ? req.body.presetName.trim() : ''; + const managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(req?.body?.managedRemoteTunnelHostname); + const managedRemoteTunnelToken = typeof req?.body?.managedRemoteTunnelToken === 'string' ? req.body.managedRemoteTunnelToken.trim() : ''; + + if (!presetId || !presetName || !managedRemoteTunnelHostname || !managedRemoteTunnelToken) { + return res.status(400).json({ ok: false, error: 'presetId, presetName, managedRemoteTunnelHostname and managedRemoteTunnelToken are required' }); + } + + await upsertManagedRemoteTunnelToken({ + id: presetId, + name: presetName, + hostname: managedRemoteTunnelHostname, + token: managedRemoteTunnelToken, + }); + + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + return res.json({ ok: true, managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) }); + } catch (error) { + return res.status(500).json({ ok: false, error: 'Failed to save managed remote tunnel token' }); + } + }); + + app.post('/api/openchamber/tunnel/start', async (_req, res) => { + try { + const settings = await readSettingsFromDiskMigrated(); + if (typeof _req?.body?.provider === 'string' && _req.body.provider.trim().length > 0) { + const rawProvider = _req.body.provider.trim().toLowerCase(); + if (!tunnelProviderRegistry.get(rawProvider)) { + return res.status(422).json({ ok: false, error: `Unsupported tunnel provider: ${rawProvider}`, code: 'provider_unsupported' }); + } + } + const provider = normalizeTunnelProvider(_req?.body?.provider ?? settings?.tunnelProvider); + const modeInput = _req?.body?.mode ?? settings?.tunnelMode; + const intent = typeof _req?.body?.intent === 'string' ? _req.body.intent.trim().toLowerCase() : undefined; + const mode = typeof modeInput === 'string' + ? modeInput.trim().toLowerCase() + : normalizeTunnelMode(modeInput); + if (typeof _req?.body?.mode === 'string' && _req.body.mode.trim().length > 0 && !isSupportedTunnelMode(mode)) { + return res.status(422).json({ ok: false, error: `Unsupported tunnel mode: ${mode}`, code: 'mode_unsupported' }); + } + const selectedPresetId = typeof _req?.body?.managedRemoteTunnelPresetId === 'string' ? _req.body.managedRemoteTunnelPresetId.trim() : ''; + const selectedPresetName = typeof _req?.body?.managedRemoteTunnelPresetName === 'string' ? _req.body.managedRemoteTunnelPresetName.trim() : ''; + const requestConfigPath = normalizeOptionalPath(_req?.body?.configPath) + ?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath); + const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.managedRemoteTunnelHostname); + const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.tunnelHostname); + const requestHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.hostname); + const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname); + const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings; + const requestManagedRemoteToken = typeof _req?.body?.managedRemoteTunnelToken === 'string' ? _req.body.managedRemoteTunnelToken.trim() : ''; + const requestTunnelToken = typeof _req?.body?.tunnelToken === 'string' ? _req.body.tunnelToken.trim() : ''; + const requestToken = typeof _req?.body?.token === 'string' ? _req.body.token.trim() : ''; + const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' ? settings.managedRemoteTunnelToken.trim() : ''; + const configManagedRemoteToken = provider === TUNNEL_PROVIDER_CLOUDFLARE + ? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname }) + : ''; + const runtimeHostname = getRuntimeManagedRemoteTunnelHostname(); + const runtimeToken = getRuntimeManagedRemoteTunnelToken(); + const token = requestToken + || requestTunnelToken + || requestManagedRemoteToken + || ((runtimeHostname && hostname && runtimeHostname === hostname) ? runtimeToken : '') + || configManagedRemoteToken + || storedManagedRemoteToken; + const requestConnectTtlMs = typeof _req?.body?.connectTtlMs === 'number' && Number.isFinite(_req.body.connectTtlMs) + ? normalizeTunnelBootstrapTtlMs(_req.body.connectTtlMs) + : undefined; + const requestSessionTtlMs = typeof _req?.body?.sessionTtlMs === 'number' && Number.isFinite(_req.body.sessionTtlMs) + ? normalizeTunnelSessionTtlMs(_req.body.sessionTtlMs) + : undefined; + const bootstrapTtlMs = requestConnectTtlMs ?? (settings?.tunnelBootstrapTtlMs === null + ? null + : normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs)); + const sessionTtlMs = requestSessionTtlMs ?? normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs); + + const previousTunnelId = tunnelAuthController.getActiveTunnelId(); + const previousMode = tunnelAuthController.getActiveTunnelMode(); + const previousProvider = tunnelService.resolveActiveProvider(); + const previousUrl = tunnelService.getPublicUrl(); + + const { publicUrl, provider: activeProvider, providerMetadata } = await startTunnelWithNormalizedRequest({ + provider, + mode, + intent, + hostname, + token, + configPath: requestConfigPath, + selectedPresetId, + selectedPresetName, + }); + + const replacedTunnel = Boolean(previousTunnelId) && ( + previousMode !== mode + || previousProvider !== activeProvider + || previousUrl !== publicUrl + ); + let revokedBootstrapCount = 0; + let invalidatedSessionCount = 0; + if (replacedTunnel && previousTunnelId) { + const revoked = tunnelAuthController.revokeTunnelArtifacts(previousTunnelId); + revokedBootstrapCount = revoked.revokedBootstrapCount; + invalidatedSessionCount = revoked.invalidatedSessionCount; + } + + tunnelAuthController.setActiveTunnel({ + tunnelId: replacedTunnel || !previousTunnelId ? crypto.randomUUID() : previousTunnelId, + publicUrl, + mode, + }); + + const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs }); + const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`; + const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk(); + const isCloudflareProvider = activeProvider === TUNNEL_PROVIDER_CLOUDFLARE; + + return res.json({ + ok: true, + url: publicUrl, + mode, + provider: activeProvider, + providerMetadata, + managedRemoteTunnelHostname: isCloudflareProvider ? (hostname || null) : null, + managedRemoteTunnelTokenPresetIds: isCloudflareProvider ? managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) : [], + connectUrl, + bootstrapExpiresAt: bootstrapToken.expiresAt, + replacedTunnel, + replaced: replacedTunnel + ? { + mode: previousMode, + provider: previousProvider, + url: previousUrl, + } + : null, + revokedBootstrapCount, + invalidatedSessionCount, + policy: 'tunnel-gated', + activeTunnelMode: mode, + activeSessions: tunnelAuthController.listTunnelSessions(), + localPort: getActivePort(), + ttlConfig: { + bootstrapTtlMs, + sessionTtlMs, + }, + }); + } catch (error) { + console.error('Failed to start tunnel:', error); + setActiveTunnelController(null); + tunnelAuthController.clearActiveTunnel(); + if (error instanceof TunnelServiceError) { + const status = error.code === 'missing_dependency' + ? 400 + : (error.code === 'validation_error' || error.code === 'provider_unsupported' || error.code === 'mode_unsupported' + ? 422 + : 500); + return res.status(status).json({ ok: false, error: error.message, code: error.code }); + } + return res.status(500).json({ ok: false, error: 'Failed to start tunnel', code: 'startup_failed' }); + } + }); + + app.post('/api/openchamber/tunnel/stop', (_req, res) => { + let revokedBootstrapCount = 0; + let invalidatedSessionCount = 0; + const activeTunnelId = tunnelAuthController.getActiveTunnelId(); + + if (activeTunnelId) { + const revoked = tunnelAuthController.revokeTunnelArtifacts(activeTunnelId); + revokedBootstrapCount = revoked.revokedBootstrapCount; + invalidatedSessionCount = revoked.invalidatedSessionCount; + } + + if (getActiveTunnelController()) { + console.log('Stopping active tunnel (user requested)...'); + tunnelService.stop(); + } + + tunnelAuthController.clearActiveTunnel(); + res.json({ ok: true, revokedBootstrapCount, invalidatedSessionCount }); + }); + }; + + return { + registerRoutes, + startTunnelWithNormalizedRequest, + }; +}; diff --git a/scripts/dev-web-hmr.mjs b/scripts/dev-web-hmr.mjs index b079db5a..95e8332c 100644 --- a/scripts/dev-web-hmr.mjs +++ b/scripts/dev-web-hmr.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -7,6 +8,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const useDetachedChildren = process.platform === 'darwin'; +const webRoot = path.join(repoRoot, 'packages/web'); function run(label, command, args, env = {}, options = {}) { return spawn(command, args, { @@ -81,18 +83,32 @@ async function stopChildTree(child) { const uiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180'; const backendPort = process.env.OPENCHAMBER_HMR_API_PORT || '3902'; +function clearViteCache() { + const cacheDirs = [ + path.join(webRoot, 'node_modules/.vite'), + path.join(webRoot, 'node_modules/.vite-temp'), + ]; + + for (const cacheDir of cacheDirs) { + if (!existsSync(cacheDir)) continue; + rmSync(cacheDir, { recursive: true, force: true }); + } +} + +clearViteCache(); + const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { OPENCHAMBER_PORT: backendPort, }); const vite = run( 'vite', 'bun', - ['x', 'vite', '--host', '127.0.0.1', '--port', uiPort, '--strictPort'], + ['x', 'vite', '--force', '--host', '127.0.0.1', '--port', uiPort, '--strictPort'], { OPENCHAMBER_PORT: backendPort, OPENCHAMBER_DISABLE_PWA_DEV: '1', }, - { cwd: path.join(repoRoot, 'packages/web') }, + { cwd: webRoot }, ); console.log(`[dev:web:hmr] UI with HMR: http://127.0.0.1:${uiPort}`);