From 31414521f6e65a34ffe40588c596410751093d43 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 21 Jun 2026 20:09:49 +0100 Subject: [PATCH 1/9] Min server 0.18.0 --- packages/cloudflare/release-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index bb49f85b..50efd5fa 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.17.0", + "minServerVersion": "0.18.0", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } From e8623d31324b2ceac90b7d66aae5e5dcfb8497fd Mon Sep 17 00:00:00 2001 From: Dark <107168337+JDis03@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:39:13 -0400 Subject: [PATCH 2/9] fix(ui): SSE connection status indicators for mobile network drops (#549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Mobile users experience SSE disconnections (wifi drops, Cloudflare idle timeouts) but get no visual feedback. The per-instance status dots remain green even when the SSE transport is down, because instance status updates travel over the broken SSE pipe. ## Solution This PR implements **immediate connection status feedback** by propagating SSE transport state to all instance status indicators: ### Changes 1. **server-events.ts**: Add `onDisconnect()` handler - Mirrors existing `onOpen()` pattern - Fire `disconnectHandlers` in `scheduleReconnect()` when connection drops 2. **sse-manager.ts**: Register SSE lifecycle handlers - `onDisconnect`: set ALL instances to `connecting` (amber dot) - `onOpen`: clear `connecting` status (green dot restored by subsequent events) - Log transitions for debug visibility 3. **AGENTS.md**: Add PR Review Principles - Check regressions first, look for better implementations - Be the PR gatekeeper, ruthless code quality - Test before responding, UI/server version parity ## Verification - ✅ TypeScript compilation clean (pre-existing SDK errors unrelated) - ✅ Vite build successful - ✅ Mobile testing: SSE disconnect → immediate amber dots - ✅ Reconnect → green dots restored ## Edge Cases Handled - **Transient drop**: amber → green on reconnect (no false disconnect modal) - **Instance dies during outage**: amber → green → `disconnected` event → red - **0 instances**: Map empty, loop is no-op - **Rapid reconnect cycles**: idempotent (setting `connecting` on `connecting` is no-op) ## Related - Complements PR #519 (pong retry with timeout) - merged upstream - Addresses mobile UX gap: workspace-level SSE state now visible to all users (not just debug overlay) --------- Co-authored-by: Shantur Rathore Co-authored-by: Pascal André --- packages/ui/src/lib/connection-status.test.ts | 25 ++++++ packages/ui/src/lib/connection-status.ts | 19 +++++ packages/ui/src/lib/event-transport.ts | 14 +++- .../ui/src/lib/native/desktop-events.test.ts | 81 ++++++++++++++++++- packages/ui/src/lib/native/desktop-events.ts | 37 +++++++-- packages/ui/src/lib/server-events.ts | 24 +++++- packages/ui/src/lib/sse-manager.ts | 15 +++- 7 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 packages/ui/src/lib/connection-status.test.ts create mode 100644 packages/ui/src/lib/connection-status.ts diff --git a/packages/ui/src/lib/connection-status.test.ts b/packages/ui/src/lib/connection-status.test.ts new file mode 100644 index 00000000..b61da207 --- /dev/null +++ b/packages/ui/src/lib/connection-status.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { deriveDisplayConnectionStatus } from "./connection-status.ts" + +describe("deriveDisplayConnectionStatus", () => { + it("overlays connecting while transport is down for connected instances", () => { + assert.equal(deriveDisplayConnectionStatus("connected", "disconnected"), "connecting") + }) + + it("restores previous connected status when transport reconnects", () => { + assert.equal(deriveDisplayConnectionStatus("connected", "connected"), "connected") + }) + + it("preserves disconnected instance status while transport is down", () => { + assert.equal(deriveDisplayConnectionStatus("disconnected", "disconnected"), "disconnected") + }) + + it("preserves error instance status while transport is down", () => { + assert.equal(deriveDisplayConnectionStatus("error", "disconnected"), "error") + }) + + it("does not clear legitimate instance connecting status after transport opens", () => { + assert.equal(deriveDisplayConnectionStatus("connecting", "connected"), "connecting") + }) +}) diff --git a/packages/ui/src/lib/connection-status.ts b/packages/ui/src/lib/connection-status.ts new file mode 100644 index 00000000..c162e550 --- /dev/null +++ b/packages/ui/src/lib/connection-status.ts @@ -0,0 +1,19 @@ +import type { InstanceStreamStatus } from "../../../server/src/api-types" +import type { WorkspaceEventTransportStatus } from "./event-transport" + +export type ConnectionStatus = InstanceStreamStatus + +export function deriveDisplayConnectionStatus( + instanceStatus: ConnectionStatus | null, + workspaceTransportStatus: WorkspaceEventTransportStatus, +): ConnectionStatus | null { + if (instanceStatus === "disconnected" || instanceStatus === "error") { + return instanceStatus + } + + if (workspaceTransportStatus !== "connected") { + return "connecting" + } + + return instanceStatus +} diff --git a/packages/ui/src/lib/event-transport.ts b/packages/ui/src/lib/event-transport.ts index 24b69591..5de633c3 100644 --- a/packages/ui/src/lib/event-transport.ts +++ b/packages/ui/src/lib/event-transport.ts @@ -15,9 +15,12 @@ export interface WorkspaceEventTransportCallbacks { onBatch: (events: WorkspaceEventPayload[]) => void onError?: () => void onOpen?: () => void + onStatus?: (status: WorkspaceEventTransportStatus) => void onPing?: (payload: { ts?: number }) => void } +export type WorkspaceEventTransportStatus = "connecting" | "connected" | "disconnected" + export interface WorkspaceEventConnection { disconnect: () => void } @@ -25,10 +28,17 @@ export interface WorkspaceEventConnection { async function connectBrowserWorkspaceEvents( callbacks: WorkspaceEventTransportCallbacks, ): Promise { + const notifyDisconnected = () => { + callbacks.onStatus?.("disconnected") + callbacks.onError?.() + } const source = serverApi.connectEvents((event) => { callbacks.onBatch([event]) - }, callbacks.onError, callbacks.onPing) - source.onopen = () => callbacks.onOpen?.() + }, notifyDisconnected, callbacks.onPing) + source.onopen = () => { + callbacks.onStatus?.("connected") + callbacks.onOpen?.() + } return { disconnect() { source.close() diff --git a/packages/ui/src/lib/native/desktop-events.test.ts b/packages/ui/src/lib/native/desktop-events.test.ts index c414b04b..c6d0e388 100644 --- a/packages/ui/src/lib/native/desktop-events.test.ts +++ b/packages/ui/src/lib/native/desktop-events.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { createTerminalErrorNotifier } from "./desktop-events.ts" +import { + connectTauriWorkspaceEvents, + createTerminalErrorNotifier, + mapDesktopEventTransportStatus, +} from "./desktop-events.ts" describe("createTerminalErrorNotifier", () => { it("calls onError once for repeated terminal notifications", () => { @@ -17,3 +21,78 @@ describe("createTerminalErrorNotifier", () => { assert.equal(errors, 1) }) }) + +describe("mapDesktopEventTransportStatus", () => { + it("maps native connected state to shared connected state", () => { + assert.equal(mapDesktopEventTransportStatus("connected"), "connected") + }) + + it("maps native connecting state to shared connecting state", () => { + assert.equal(mapDesktopEventTransportStatus("connecting"), "connecting") + }) + + it("maps native transient failures to shared disconnected state", () => { + assert.equal(mapDesktopEventTransportStatus("disconnected"), "disconnected") + assert.equal(mapDesktopEventTransportStatus("error"), "disconnected") + assert.equal(mapDesktopEventTransportStatus("unauthorized"), "disconnected") + }) +}) + +describe("connectTauriWorkspaceEvents", () => { + it("marks the transport connected when a batch opens the native stream", async () => { + let batchHandler: ((event: { payload: any }) => void) | undefined + const unlistened: string[] = [] + const bridge = { + invoke: async (command: string) => { + if (command === "desktop_events_start") { + return { started: true, generation: 1 } + } + if (command === "desktop_events_stop") { + return undefined + } + throw new Error(`Unexpected command: ${command}`) + }, + listen: async (eventName: string, handler: (event: { payload: any }) => void) => { + if (eventName === "desktop:event-batch") { + batchHandler = handler + } + return () => { + unlistened.push(eventName) + } + }, + } as any + + const statuses: string[] = [] + const batches: unknown[] = [] + let opens = 0 + + const connection = await connectTauriWorkspaceEvents( + { + onBatch: (events) => batches.push(events), + onOpen: () => { + opens += 1 + }, + onStatus: (status) => statuses.push(status), + }, + { reconnect: {} }, + bridge, + ) + + assert.ok(batchHandler) + batchHandler({ + payload: { + generation: 1, + sequence: 1, + emittedAt: Date.now(), + events: [{ type: "server.heartbeat" }], + }, + }) + + assert.deepEqual(statuses, ["connected"]) + assert.equal(opens, 1) + assert.equal(batches.length, 1) + + connection.disconnect() + assert.deepEqual(unlistened, ["desktop:event-batch", "desktop:event-stream-status"]) + }) +}) diff --git a/packages/ui/src/lib/native/desktop-events.ts b/packages/ui/src/lib/native/desktop-events.ts index 01c9af6e..2ee51d4c 100644 --- a/packages/ui/src/lib/native/desktop-events.ts +++ b/packages/ui/src/lib/native/desktop-events.ts @@ -4,9 +4,14 @@ import type { WorkspaceEventPayload } from "../../../../server/src/api-types" import type { DesktopEventsStartResult, DesktopEventTransportStartOptions, + DesktopEventTransportState, DesktopEventTransportStatusPayload, } from "../event-transport-contract" -import type { WorkspaceEventConnection, WorkspaceEventTransportCallbacks } from "../event-transport" +import type { + WorkspaceEventConnection, + WorkspaceEventTransportCallbacks, + WorkspaceEventTransportStatus, +} from "../event-transport" import { getLogger } from "../logger" const log = getLogger("sse") @@ -18,6 +23,16 @@ interface WorkspaceEventBatchPayload { events: WorkspaceEventPayload[] } +interface DesktopEventTransportBridge { + invoke: typeof invoke + listen: typeof listen +} + +const defaultDesktopEventTransportBridge: DesktopEventTransportBridge = { + invoke, + listen, +} + export function createTerminalErrorNotifier(callbacks: Pick) { let raised = false return () => { @@ -27,9 +42,18 @@ export function createTerminalErrorNotifier(callbacks: Pick { let closed = false let opened = false @@ -45,6 +69,7 @@ export async function connectTauriWorkspaceEvents( if (!opened) { opened = true + callbacks.onStatus?.("connected") callbacks.onOpen?.() } @@ -59,6 +84,8 @@ export async function connectTauriWorkspaceEvents( const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => { if (!payload || !matchesGeneration(payload.generation)) return + callbacks.onStatus?.(mapDesktopEventTransportStatus(payload.state)) + if (payload.state === "connected" && !opened) { opened = true callbacks.onOpen?.() @@ -107,7 +134,7 @@ export async function connectTauriWorkspaceEvents( } } - const unlistenBatch = await listen("desktop:event-batch", (event) => { + const unlistenBatch = await bridge.listen("desktop:event-batch", (event) => { if (closed) return const payload = event.payload if (!payload) return @@ -118,7 +145,7 @@ export async function connectTauriWorkspaceEvents( handleBatchPayload(payload) }) - const unlistenStatus = await listen("desktop:event-stream-status", (event) => { + const unlistenStatus = await bridge.listen("desktop:event-stream-status", (event) => { if (closed) return const payload = event.payload if (!payload) return @@ -130,7 +157,7 @@ export async function connectTauriWorkspaceEvents( }) try { - const result = await invoke("desktop_events_start", { request: options }) + const result = await bridge.invoke("desktop_events_start", { request: options }) if (!result?.started) { throw new Error(result?.reason ?? "desktop event transport unavailable") } @@ -151,7 +178,7 @@ export async function connectTauriWorkspaceEvents( closed = true unlistenBatch() unlistenStatus() - void invoke("desktop_events_stop").catch((error) => { + void bridge.invoke("desktop_events_stop").catch((error) => { log.warn("Failed to stop native desktop event transport", error) }) }, diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 4145367d..bb747f1a 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -2,7 +2,11 @@ import { batch as solidBatch } from "solid-js" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types" import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" -import { connectWorkspaceEvents, type WorkspaceEventConnection } from "./event-transport" +import { + connectWorkspaceEvents, + type WorkspaceEventConnection, + type WorkspaceEventTransportStatus, +} from "./event-transport" import { getLogger } from "./logger" import { retryWithBackoff, isRetryableError } from "./retry-utils" @@ -21,6 +25,7 @@ function logSse(message: string, context?: Record) { class ServerEvents { private handlers = new Map void>>() private openHandlers = new Set<() => void>() + private statusHandlers = new Set<(status: WorkspaceEventTransportStatus) => void>() private connection: WorkspaceEventConnection | null = null private connectGeneration = 0 private retryDelay = RETRY_BASE_DELAY @@ -50,6 +55,12 @@ class ServerEvents { } this.scheduleReconnect() }, + onStatus: (status) => { + if (generation !== this.connectGeneration) { + return + } + this.emitTransportStatus(status) + }, onOpen: () => { if (generation !== this.connectGeneration) { return @@ -105,6 +116,8 @@ class ServerEvents { this.connection = null } + this.emitTransportStatus("disconnected") + logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay }) this.retryTimer = setTimeout(() => { this.retryTimer = null @@ -140,6 +153,10 @@ class ServerEvents { }) } + private emitTransportStatus(status: WorkspaceEventTransportStatus) { + this.statusHandlers.forEach((handler) => handler(status)) + } + on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()) @@ -154,6 +171,11 @@ class ServerEvents { return () => this.openHandlers.delete(handler) } + onTransportStatus(handler: (status: WorkspaceEventTransportStatus) => void): () => void { + this.statusHandlers.add(handler) + return () => this.statusHandlers.delete(handler) + } + restart(reason = "manual restart"): void { this.retryDelay = RETRY_BASE_DELAY this.clearReconnectTimer() diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 809cf039..94393851 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -24,13 +24,14 @@ import type { } from "@opencode-ai/sdk/v2" import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent } from "../types/permission" import { serverEvents } from "./server-events" +import type { WorkspaceEventTransportStatus } from "./event-transport" import type { BackgroundProcess, InstanceStreamEvent, - InstanceStreamStatus, WorkspaceEventPayload, } from "../../../server/src/api-types" import { getLogger } from "./logger" +import { deriveDisplayConnectionStatus, type ConnectionStatus } from "./connection-status" const log = getLogger("sse") @@ -98,12 +99,13 @@ type SSEEvent = | ServerInstanceDisposedEvent | { type: string; properties?: Record } -type ConnectionStatus = InstanceStreamStatus - const [connectionStatus, setConnectionStatus] = createSignal>(new Map()) +const [transportStatus, setTransportStatus] = createSignal("connecting") class SSEManager { constructor() { + log.info("sseManager initialized: listening for SSE disconnect and reconnect") + serverEvents.on("instance.eventStatus", (event) => { const payload = event as InstanceStatusPayload this.updateConnectionStatus(payload.instanceId, payload.status) @@ -121,6 +123,11 @@ class SSEManager { this.updateConnectionStatus(payload.instanceId, "connected") this.handleEvent(payload.instanceId, payload.event as SSEEvent) }) + + serverEvents.onTransportStatus((status) => { + log.info("SSE transport status changed", { status }) + setTransportStatus(status) + }) } seedStatus(instanceId: string, status: ConnectionStatus) { @@ -246,7 +253,7 @@ class SSEManager { onConnectionLost?: (instanceId: string, reason: string) => void | Promise getStatus(instanceId: string): ConnectionStatus | null { - return connectionStatus().get(instanceId) ?? null + return deriveDisplayConnectionStatus(connectionStatus().get(instanceId) ?? null, transportStatus()) } getStatuses() { From 275770b51b7b6433bc94624f72830f2ac11e8e42 Mon Sep 17 00:00:00 2001 From: Dark <107168337+JDis03@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:56:12 -0400 Subject: [PATCH 3/9] fix(ui): remove scope=project from session list requests (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem PR #565 (commit e29c3a08) added `scope=project` to session list requests so worktree/project sessions remain visible. With OpenCode 1.17.x, the `/session` endpoint can ignore the `directory` filter when `scope=project` is present and return sessions from unrelated folders that share the same project/global bucket. This can make sessions appear in the wrong project tab. Deleting one of those sessions still deletes it from the shared OpenCode database, so the UI must avoid showing unrelated sessions in the wrong tab. ## Reproduction ```bash PORT=4096 curl -s -u "codenomad:$PASSWORD" \ "http://127.0.0.1:$PORT/session?directory=/home/user/project-a&scope=project" \ | jq 'length' # Can include sessions from other folders too. curl -s -u "codenomad:$PASSWORD" \ "http://127.0.0.1:$PORT/session?directory=/home/user/project-a" \ | jq 'length' # Filters to the requested folder, but may miss project/worktree sessions. Related: anomalyco/opencode#33113 Fix Keep scope: "project" so OpenCode still returns project/worktree sessions, then filter the returned sessions client-side to the current root directory plus known CodeNomad worktree directories. This preserves the worktree-session visibility behavior from #565 while avoiding unrelated cross-directory leakage when OpenCode ignores directory alongside scope=project. Validation Focused session-pagination tests pass. UI typecheck passes. UI build passes. Tauri release build passes. Manual exe tested for validation. --------- Co-authored-by: Claude Co-authored-by: Pascal André --- packages/ui/src/stores/session-api.ts | 17 ++++++++-- .../ui/src/stores/session-list-options.ts | 28 +++++++++++++++++ .../ui/src/stores/session-pagination.test.ts | 31 ++++++++++++++++++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 98ea2065..ef716b85 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -53,10 +53,17 @@ import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { getRootClient } from "./opencode-client" -import { getWorktreeSlugForSession, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession } from "./worktrees" +import { + getWorktreeSlugForSession, + getWorktrees, + migrateLegacyWorktreeMapToSessionMetadata, + pruneStaleLegacyWorktreeMapEntries, + removeLegacyParentSessionMapping, + setWorktreeSlugForParentSession, +} from "./worktrees" import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { hydrateSessionMetadataWithClient } from "./session-metadata" -import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions } from "./session-list-options" +import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProjectScopedSessions } from "./session-list-options" const log = getLogger("api") @@ -142,7 +149,11 @@ async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions const client = getRootClient(instanceId) const listOptions = buildProjectSessionListOptions(options) const data = await requestData(client.session.list(listOptions), "session.list") - return { data } + const allowedDirectories = [options.directory, ...getWorktrees(instanceId).map((worktree) => worktree.directory)] + + return { + data: filterProjectScopedSessions(data, allowedDirectories), + } } function getV2SessionItems(response: ProjectSessionListResponse): SDKSession[] { diff --git a/packages/ui/src/stores/session-list-options.ts b/packages/ui/src/stores/session-list-options.ts index 91bc27c3..7e499e88 100644 --- a/packages/ui/src/stores/session-list-options.ts +++ b/packages/ui/src/stores/session-list-options.ts @@ -10,6 +10,21 @@ export type ProjectSessionListOptions = ProjectSessionListInput & { scope: "project" } +type SessionDirectorySource = { + directory?: string | null +} + +function normalizeSessionDirectory(directory: string | null | undefined): string { + const trimmed = directory?.trim() + if (!trimmed) return "" + + const slashNormalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "") + const comparable = slashNormalized || "/" + const isWindowsPath = /^[A-Za-z]:\//.test(comparable) || comparable.startsWith("//") || trimmed.includes("\\") + + return isWindowsPath ? comparable.toLowerCase() : comparable +} + export function buildProjectSessionListOptions(options: ProjectSessionListInput): ProjectSessionListOptions { return { ...(options.directory ? { directory: options.directory } : {}), @@ -18,3 +33,16 @@ export function buildProjectSessionListOptions(options: ProjectSessionListInput) scope: "project", } } + +export function filterProjectScopedSessions( + sessions: T[], + allowedDirectories: Array, +): T[] { + const allowed = new Set(allowedDirectories.map(normalizeSessionDirectory).filter(Boolean)) + if (allowed.size === 0) return sessions + + return sessions.filter((session) => { + const directory = normalizeSessionDirectory(session.directory) + return !directory || allowed.has(directory) + }) +} diff --git a/packages/ui/src/stores/session-pagination.test.ts b/packages/ui/src/stores/session-pagination.test.ts index 60580742..50369a57 100644 --- a/packages/ui/src/stores/session-pagination.test.ts +++ b/packages/ui/src/stores/session-pagination.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { applySessionPage, getDefaultSessionPaginationState } from "./session-pagination-model.ts" -import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions } from "./session-list-options.ts" +import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProjectScopedSessions } from "./session-list-options.ts" describe("project session list loading", () => { it("builds a one-shot project-scoped request without pagination params", () => { @@ -18,6 +18,35 @@ describe("project session list loading", () => { assert.equal("cursor" in options, false) }) + it("filters project-scoped results to the root and known worktree directories", () => { + const sessions = [ + { id: "root", directory: "/repo" }, + { id: "worktree", directory: "/repo/.codenomad/worktrees/feature" }, + { id: "sibling", directory: "/other" }, + { id: "unknown" }, + ] + + assert.deepEqual( + filterProjectScopedSessions(sessions, ["/repo", "/repo/.codenomad/worktrees/feature"]).map((session) => session.id), + ["root", "worktree", "unknown"], + ) + }) + + it("normalizes Windows paths when filtering project-scoped results", () => { + const sessions = [ + { id: "root", directory: String.raw`C:\Repo` }, + { id: "worktree", directory: "c:/repo/.codenomad/worktrees/feature/" }, + { id: "other", directory: String.raw`C:\Other` }, + ] + + assert.deepEqual( + filterProjectScopedSessions(sessions, ["c:/repo/", String.raw`C:\Repo\.codenomad\worktrees\feature`]).map( + (session) => session.id, + ), + ["root", "worktree"], + ) + }) + it("marks the loaded session list complete because the API does not paginate", () => { const state = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], false, true) From bce975d94057c99877a29e3697e6a4b8f6f7b82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 8 Jul 2026 18:46:55 +0200 Subject: [PATCH 4/9] fix(ui): stabilize strict follow autoscroll (#576) ## Summary - replace message stream follow behavior with a strict following/escaped state model - keep explicit submit bottom pinning deterministic while preserving user scroll-up escape intent - prevent repeated pending question/permission polling from rewriting identical store entries and snapping back to bottom - isolate pending request dedupe logic in a raw-node-testable helper Fixes #574 ## Validation - node --test "packages/ui/src/components/virtual-follow-behavior.test.ts" "packages/ui/src/components/session/session-bottom-pin-intent.test.ts" "packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts" - npm run typecheck --workspace @codenomad/ui - npm run build --workspace @codenomad/ui - git diff --check --- packages/ui/src/components/message-block.tsx | 2 - .../ui/src/components/message-section.tsx | 33 +- .../session-bottom-follow-intent.test.ts | 56 - .../session/session-bottom-follow-intent.ts | 22 - .../session/session-bottom-pin-intent.test.ts | 74 + .../session/session-bottom-pin-intent.ts | 30 + .../src/components/session/session-view.tsx | 83 +- packages/ui/src/components/tool-call.tsx | 2 - .../virtual-follow-behavior.test.ts | 333 +--- .../src/components/virtual-follow-behavior.ts | 201 +-- .../ui/src/components/virtual-follow-list.tsx | 1333 +++++------------ packages/ui/src/lib/follow-scroll.tsx | 107 +- .../stores/message-v2/instance-store.test.ts | 1 + .../src/stores/message-v2/instance-store.ts | 54 +- .../message-v2/pending-request-dedupe.test.ts | 57 + .../message-v2/pending-request-dedupe.ts | 50 + packages/ui/src/stores/message-v2/types.ts | 4 +- 17 files changed, 890 insertions(+), 1552 deletions(-) delete mode 100644 packages/ui/src/components/session/session-bottom-follow-intent.test.ts delete mode 100644 packages/ui/src/components/session/session-bottom-follow-intent.ts create mode 100644 packages/ui/src/components/session/session-bottom-pin-intent.test.ts create mode 100644 packages/ui/src/components/session/session-bottom-pin-intent.ts create mode 100644 packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts create mode 100644 packages/ui/src/stores/message-v2/pending-request-dedupe.ts diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index fa705335..b54c8678 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -33,7 +33,6 @@ function DeleteUpToIcon() { const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const NO_STEP_BORDER = "none" -const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48 const LazyToolCall = lazy(() => import("./tool-call")) @@ -1419,7 +1418,6 @@ function ReasoningStreamOutput(props: { const followScroll = createFollowScroll({ getScrollTopSnapshot: props.scrollTopSnapshot, setScrollTopSnapshot: props.setScrollTopSnapshot, - sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX, sentinelClassName: "reasoning-scroll-sentinel", }) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 9492a919..e56e7b34 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -5,7 +5,7 @@ import BrandedEmptyState from "./branded-empty-state" import MessageBlock from "./message-block" import { getMessageAnchorId } from "./message-anchors" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" -import VirtualFollowList, { type VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" +import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" import { isSnapshotAutoFollowing } from "./virtual-follow-behavior" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" @@ -24,7 +24,6 @@ import { buildSessionSearchMatches } from "../lib/session-search" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry" -const SCROLL_SENTINEL_MARGIN_PX = 8 const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const QUOTE_SELECTION_MAX_LENGTH = 2000 const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8 @@ -49,7 +48,8 @@ export interface MessageSectionProps { onReloadMessages?: () => void isActive?: boolean sessionStreamingActive?: boolean - bottomFollowIntent?: VirtualFollowBottomIntent | null + explicitBottomPinIntent?: VirtualExplicitBottomPinIntent | null + onExplicitBottomPinCancelled?: () => void } export default function MessageSection(props: MessageSectionProps) { @@ -735,16 +735,6 @@ export default function MessageSection(props: MessageSectionProps) { return } - const element = streamElement() - if (!allowCapture || !canCapture) return - if (!element) return - const scrollTop = element.scrollTop - const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0) - const scrollRatio = maxScrollTop > 0 ? scrollTop / maxScrollTop : 0 - const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48 - const snapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom } - setLastGoodScrollSnapshot(sessionId, snapshot) - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) } // Persist scroll position when switching sessions. This effect's cleanup runs @@ -783,7 +773,7 @@ export default function MessageSection(props: MessageSectionProps) { setScrollControlsOpen(false) } - function openScrollControlsFromTrigger(event: PointerEvent) { + function openScrollControlsFromTrigger(event: MouseEvent) { event.preventDefault() event.stopPropagation() if (scrollControlsOpen()) return @@ -795,7 +785,7 @@ export default function MessageSection(props: MessageSectionProps) { event.preventDefault() event.stopPropagation() action() - setScrollControlsHoverSuppressed(event.pointerType !== "mouse") + setScrollControlsHoverSuppressed(false) closeScrollControls() } @@ -836,7 +826,7 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!api) return if (props.registerScrollToBottom) { - props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true })) + props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true })) onCleanup(() => props.registerScrollToBottom?.(null)) } }) @@ -1264,7 +1254,7 @@ export default function MessageSection(props: MessageSectionProps) { if (!match || !isSearchOpen()) return if (match.id === lastScrolledSearchMatchId) return lastScrolledSearchMatchId = match.id - listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start", setAutoScroll: false }) + listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start" }) }) @@ -1367,8 +1357,6 @@ export default function MessageSection(props: MessageSectionProps) { getKey={(messageId) => messageId} getAnchorId={getMessageAnchorId} overscanPx={800} - scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} - suspendMeasurements={() => !isActive()} streamingActive={streamingActive} isActive={isActive} scrollToBottomOnActivate={() => false} @@ -1376,7 +1364,8 @@ export default function MessageSection(props: MessageSectionProps) { initialAutoScroll={initialAutoScroll} resetKey={() => props.sessionId} followToken={followToken} - forceBottomFollowIntent={() => props.bottomFollowIntent ?? null} + explicitBottomPinIntent={() => props.explicitBottomPinIntent ?? null} + onExplicitBottomPinCancelled={props.onExplicitBottomPinCancelled} autoPinHoldEnabled={holdLongAssistantRepliesEnabled} autoPinHoldTargetKey={autoPinHoldTargetKey} autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX} @@ -1429,7 +1418,7 @@ export default function MessageSection(props: MessageSectionProps) { - diff --git a/packages/ui/src/lib/follow-scroll.tsx b/packages/ui/src/lib/follow-scroll.tsx index 89b489a9..34c40724 100644 --- a/packages/ui/src/lib/follow-scroll.tsx +++ b/packages/ui/src/lib/follow-scroll.tsx @@ -1,4 +1,5 @@ import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js" +import { BOTTOM_FOLLOW_EPSILON_PX, VirtualScrollController, isAutoFollowing, type ScrollControllerMetrics } from "../components/virtual-follow-behavior" const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600 const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) @@ -6,7 +7,6 @@ const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "P interface FollowScrollOptions { getScrollTopSnapshot: Accessor setScrollTopSnapshot: (next: number) => void - sentinelMarginPx: number sentinelClassName: string intentWindowMs?: number intentKeys?: ReadonlySet @@ -24,16 +24,14 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe const [scrollContainer, setScrollContainer] = createSignal() const [bottomSentinel, setBottomSentinel] = createSignal(null) const [autoScroll, setAutoScroll] = createSignal(true) - const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) + const scrollController = new VirtualScrollController(true) let scrollContainerRef: HTMLDivElement | undefined let detachScrollIntentListeners: (() => void) | undefined let pendingScrollFrame: number | null = null let pendingAnchorScroll: number | null = null - let userScrollIntentUntil = 0 let lastKnownScrollTop = options.getScrollTopSnapshot() - let pointerInteractionActive = false let suppressNextScrollHandling = false function restoreScrollPosition(forceBottom = false) { @@ -44,8 +42,10 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe container.scrollTop = container.scrollHeight lastKnownScrollTop = container.scrollTop options.setScrollTopSnapshot(lastKnownScrollTop) + scrollController.recordProgrammaticOffset(lastKnownScrollTop, true) } else { container.scrollTop = lastKnownScrollTop + scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container)) } } @@ -55,17 +55,9 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe options.setScrollTopSnapshot(lastKnownScrollTop) } - function markUserScrollIntent() { + function markUserScrollIntent(direction: "up" | "down" | null) { const now = typeof performance !== "undefined" ? performance.now() : Date.now() - userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS) - } - - function hasUserScrollIntent() { - if (pointerInteractionActive) { - return true - } - const now = typeof performance !== "undefined" ? performance.now() : Date.now() - return now <= userScrollIntentUntil + scrollController.setUserIntent(direction, now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)) } function attachScrollIntentListeners(element: HTMLDivElement) { @@ -74,42 +66,27 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe detachScrollIntentListeners = undefined } const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS - const handlePointerIntent = () => { - pointerInteractionActive = true - markUserScrollIntent() - } - const clearPointerIntent = () => { - pointerInteractionActive = false + const handlePointerIntent = (event: WheelEvent | PointerEvent | TouchEvent) => { + markUserScrollIntent(event instanceof WheelEvent ? (event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null) : null) } const handleKeyIntent = (event: KeyboardEvent) => { if (intentKeys.has(event.key)) { - markUserScrollIntent() + const direction = + event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home" || (event.shiftKey && (event.key === " " || event.key === "Spacebar")) + ? "up" + : "down" + markUserScrollIntent(direction) } } element.addEventListener("wheel", handlePointerIntent, { passive: true }) element.addEventListener("pointerdown", handlePointerIntent) element.addEventListener("touchstart", handlePointerIntent, { passive: true }) element.addEventListener("keydown", handleKeyIntent) - if (typeof window !== "undefined") { - window.addEventListener("pointerup", clearPointerIntent) - window.addEventListener("pointercancel", clearPointerIntent) - window.addEventListener("mouseup", clearPointerIntent) - window.addEventListener("touchend", clearPointerIntent) - window.addEventListener("touchcancel", clearPointerIntent) - } detachScrollIntentListeners = () => { element.removeEventListener("wheel", handlePointerIntent) element.removeEventListener("pointerdown", handlePointerIntent) element.removeEventListener("touchstart", handlePointerIntent) element.removeEventListener("keydown", handleKeyIntent) - if (typeof window !== "undefined") { - window.removeEventListener("pointerup", clearPointerIntent) - window.removeEventListener("pointercancel", clearPointerIntent) - window.removeEventListener("mouseup", clearPointerIntent) - window.removeEventListener("touchend", clearPointerIntent) - window.removeEventListener("touchcancel", clearPointerIntent) - } - pointerInteractionActive = false } } @@ -126,18 +103,28 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe pendingAnchorScroll = null const containerRect = container.getBoundingClientRect() const sentinelRect = sentinel.getBoundingClientRect() - const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx + const delta = sentinelRect.bottom - containerRect.bottom if (Math.abs(delta) > 1) { suppressNextScrollHandling = true container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" }) } lastKnownScrollTop = container.scrollTop options.setScrollTopSnapshot(lastKnownScrollTop) + scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container)) }) } + function getMetrics(container: HTMLDivElement): ScrollControllerMetrics { + return { + offset: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + sentinelMarginPx: BOTTOM_FOLLOW_EPSILON_PX, + } + } + function isAtBottom(container: HTMLDivElement) { - return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx + return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_EPSILON_PX } function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) { @@ -147,17 +134,8 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe suppressNextScrollHandling = false return } - const isUserScroll = hasUserScrollIntent() - const atBottomFromScroll = isAtBottom(container) - const atBottom = atBottomFromScroll || bottomSentinelVisible() - - if (isUserScroll || !atBottom) { - if (atBottom) { - if (!autoScroll()) setAutoScroll(true) - } else if (autoScroll()) { - setAutoScroll(false) - } - } + const result = scrollController.observeViewport(getMetrics(container), typeof performance !== "undefined" ? performance.now() : Date.now(), false) + setAutoScroll(isAutoFollowing(result.state.mode)) } const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { @@ -166,7 +144,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe } const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => { - const next = element || undefined + const next = config?.disableTracking ? undefined : element || undefined if (next === scrollContainerRef) { return } @@ -185,10 +163,13 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe const restoreAfterRender = () => { const container = scrollContainerRef - if (container && hasUserScrollIntent() && !isAtBottom(container)) { - if (autoScroll()) { - setAutoScroll(false) - } + if (!container) return + + const now = typeof performance !== "undefined" ? performance.now() : Date.now() + const result = scrollController.observeViewport(getMetrics(container), now, false) + setAutoScroll(isAutoFollowing(result.state.mode)) + const hasFreshUpwardEscape = now <= result.state.userIntentUntil && result.state.userIntentDirection === "up" && result.state.mode.type === "escaped" + if (hasFreshUpwardEscape) { requestAnimationFrame(() => { restoreScrollPosition(false) }) @@ -219,24 +200,6 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe }) }) - createEffect(() => { - const container = scrollContainer() - const sentinel = bottomSentinel() - if (!container || !sentinel) return - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.target === sentinel) { - setBottomSentinelVisible(entry.isIntersecting) - } - }) - }, - { root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` }, - ) - observer.observe(sentinel) - onCleanup(() => observer.disconnect()) - }) - onCleanup(() => { if (pendingScrollFrame !== null) { cancelAnimationFrame(pendingScrollFrame) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 66a19516..975b4b9f 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -40,4 +40,5 @@ describe("message-v2 permission state", () => { assert.equal(store.state.permissions.active?.permission.id, "permission-2") assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true) }) + }) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 3ae5af56..cb8052a1 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -13,6 +13,7 @@ import { import type { ClientPart, MessageInfo } from "../../types/message" import { mergePermissionRequest } from "../../types/permission" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" +import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe" import type { InstanceMessageState, LatestTodoSnapshot, @@ -958,6 +959,23 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const entry = mergePermissionEntry(input) const messageKey = entry.messageId ?? "__global__" const partKey = entry.partId ?? entry.permission?.id ?? "__global__" + const existing = state.permissions.queue.find((item) => item.permission.id === entry.permission.id) + const existingAtLocation = state.permissions.byMessage[messageKey]?.[partKey] + const expectedActiveId = state.permissions.queue[0]?.permission.id + if (shouldSkipPendingRequestUpsert({ + existing, + existingAtLocationId: existingAtLocation?.permission.id, + expectedActiveId, + activeId: state.permissions.active?.permission.id, + incomingId: entry.permission.id, + incomingMessageId: entry.messageId, + incomingPartId: entry.partId, + incomingEnqueuedAt: entry.enqueuedAt, + existingValue: existing?.permission, + incomingValue: entry.permission, + })) { + return + } setState( "permissions", @@ -1019,13 +1037,47 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return { entry, active } } - function upsertQuestion(entry: QuestionEntry) { + function mergeQuestionEntry(entry: QuestionEntry): QuestionEntry { + const existing = state.questions.queue.find((item) => item.request.id === entry.request.id) + return mergePendingRequestEntry(entry, existing) + } + + function upsertQuestion(input: QuestionEntry) { + const entry = mergeQuestionEntry(input) const messageKey = entry.messageId ?? "__global__" const partKey = entry.partId ?? entry.request?.id ?? "__global__" + const existing = state.questions.queue.find((item) => item.request.id === entry.request.id) + const existingAtLocation = state.questions.byMessage[messageKey]?.[partKey] + const expectedActiveId = state.questions.queue[0]?.request.id + if (shouldSkipPendingRequestUpsert({ + existing, + existingAtLocationId: existingAtLocation?.request.id, + expectedActiveId, + activeId: state.questions.active?.request.id, + incomingId: entry.request.id, + incomingMessageId: entry.messageId, + incomingPartId: entry.partId, + incomingEnqueuedAt: entry.enqueuedAt, + existingValue: existing?.request, + incomingValue: entry.request, + })) { + return + } setState( "questions", produce((draft) => { + Object.keys(draft.byMessage).forEach((existingMessageKey) => { + const partEntries = draft.byMessage[existingMessageKey] + Object.keys(partEntries).forEach((existingPartKey) => { + if (partEntries[existingPartKey].request.id === entry.request.id) { + delete partEntries[existingPartKey] + } + }) + if (Object.keys(partEntries).length === 0) { + delete draft.byMessage[existingMessageKey] + } + }) draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {} draft.byMessage[messageKey][partKey] = entry const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id) diff --git a/packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts b/packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts new file mode 100644 index 00000000..37c27235 --- /dev/null +++ b/packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe.ts" + +describe("pending request dedupe", () => { + it("skips unchanged polling updates at the same attachment location", () => { + const existing = { + messageId: "message-1", + partId: "part-1", + enqueuedAt: 1_000, + } + const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] } + + assert.equal(shouldSkipPendingRequestUpsert({ + existing, + existingAtLocationId: "question-1", + expectedActiveId: "question-1", + activeId: "question-1", + incomingId: "question-1", + incomingMessageId: "message-1", + incomingPartId: "part-1", + incomingEnqueuedAt: 1_000, + existingValue: request, + incomingValue: { ...request, questions: [...request.questions] }, + }), true) + }) + + it("does not skip when polling resolves a request from global state to a tool part", () => { + const existing = { + enqueuedAt: 1_000, + } + const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] } + + assert.equal(shouldSkipPendingRequestUpsert({ + existing, + existingAtLocationId: undefined, + expectedActiveId: "question-1", + activeId: "question-1", + incomingId: "question-1", + incomingMessageId: "message-1", + incomingPartId: "part-1", + incomingEnqueuedAt: 1_000, + existingValue: request, + incomingValue: request, + }), false) + }) + + it("keeps the earliest queue time while preserving resolved attachment ids", () => { + const merged = mergePendingRequestEntry( + { messageId: "message-1", partId: "part-1", enqueuedAt: 3_000 }, + { enqueuedAt: 1_000 }, + ) + + assert.deepEqual(merged, { messageId: "message-1", partId: "part-1", enqueuedAt: 1_000 }) + }) +}) diff --git a/packages/ui/src/stores/message-v2/pending-request-dedupe.ts b/packages/ui/src/stores/message-v2/pending-request-dedupe.ts new file mode 100644 index 00000000..fa96e8d7 --- /dev/null +++ b/packages/ui/src/stores/message-v2/pending-request-dedupe.ts @@ -0,0 +1,50 @@ +export interface PendingRequestEntryLike { + messageId?: string + partId?: string + enqueuedAt: number +} + +export interface PendingRequestSkipInput { + existing: PendingRequestEntryLike | undefined + existingAtLocationId: string | undefined + expectedActiveId: string | undefined + activeId: string | undefined + incomingId: string + incomingMessageId: string | undefined + incomingPartId: string | undefined + incomingEnqueuedAt: number + existingValue: unknown + incomingValue: unknown +} + +export function areStructuredValuesEqual(left: unknown, right: unknown): boolean { + if (left === right) return true + try { + return JSON.stringify(left) === JSON.stringify(right) + } catch { + return false + } +} + +export function mergePendingRequestEntry(entry: T, existing: T | undefined): T { + if (!existing) return entry + return { + ...entry, + messageId: entry.messageId ?? existing.messageId, + partId: entry.partId ?? existing.partId, + enqueuedAt: Math.min(existing.enqueuedAt, entry.enqueuedAt), + } +} + +export function shouldSkipPendingRequestUpsert(input: PendingRequestSkipInput): boolean { + const existing = input.existing + return Boolean( + existing && + input.existingAtLocationId === input.incomingId && + input.activeId === input.expectedActiveId && + existing.messageId === input.incomingMessageId && + existing.partId === input.incomingPartId && + existing.enqueuedAt === input.incomingEnqueuedAt && + areStructuredValuesEqual(input.existingValue, input.incomingValue), + ) +} diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 6e2279c9..b49fbf00 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -82,9 +82,7 @@ export interface ScrollSnapshot { anchorKey?: string anchorOffset?: number atBottom: boolean - followModeType?: "following" | "escaped" | "holding" - heldKey?: string - holdAnchorSuspended?: boolean + followModeType?: "following" | "escaped" updatedAt: number } From ca06bd99d733d7e4f0e8f1480e623c01a04d7485 Mon Sep 17 00:00:00 2001 From: heunghingwan Date: Thu, 9 Jul 2026 03:13:44 +0800 Subject: [PATCH 5/9] feat(yolo): move permission auto-accept (Yolo) to the server (#561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Yolo (permission auto-accept) currently lives entirely in the UI. Each browser keeps its own toggle in `localStorage` and auto-replies to permission requests over a 4-hop path (`OpenCode → server SSE → UI → server proxy → OpenCode`). The server — which already sits on the event stream that carries every `permission.asked` — does none of the work. This PR makes the **server authoritative**: it owns the toggle state, resolves family-root inheritance, and performs the auto-reply in-process via loopback using the same `"once"` semantics the UI used to send. The UI becomes a pure view: it toggles via REST and mirrors state from a `yolo.stateChanged` SSE event. ## Why - **Correctness**: the server already consumes the instance SSE stream (`InstanceEventBridge`); auto-accepting there is the natural choke point instead of bouncing to the UI and back. - **Multi-client**: previously each browser had independent `localStorage` state and never synced. Toggles now broadcast to all connected clients in real time. - **Headless**: Yolo keeps auto-accepting even when no UI is connected (useful for long autonomous runs). - **Latency**: drops from 4 hops to a single in-process loopback call. ## What changed **Server (new, authoritative)** - `permissions/auto-accept-store.ts` — in-memory state keyed by family root. Faithful port of `resolvePermissionAutoAcceptFamilyRoot`: fork/`revert` sessions root at themselves; enabling any member enables the whole family. - `permissions/auto-accept-manager.ts` — subscribes to `instance.event`, builds the session tree from `session.created/updated/deleted` (`properties.info`), intercepts `permission.v2.asked` / `permission.asked`, dedupes in-flight replies, emits `yolo.stateChanged` / `yolo.autoAccepted`, clears per-instance state on `workspace.stopped/error`. - `permissions/opencode-replier.ts` — default replier calling OpenCode directly (`getInstancePort` + auth header), mirroring `background-processes/manager.ts`. - `server/routes/yolo.ts` — `GET/POST /workspaces/:id/yolo/sessions/:sid[/toggle]`, following existing route conventions. - `api-types.ts` / `events/bus.ts` — `YoloStateResponse` + the two new event types registered in `onEvent` so they flow over `/api/events`. **UI (pure view)** - `permission-auto-accept.ts` — removed `localStorage`, persistence, and drain logic; now a runtime (non-persisted) projection. `resolvePermissionAutoAcceptFamilyRoot` is **retained as a display aid** so the badge still lights up for child/sub-sessions of an enabled family (preserving the inheritance UX). - `instances.ts` — toggle calls REST (optimistic, reconciled on success, reverted on failure); subscribes to `yolo.stateChanged`; `ensureYoloStateSynced` backfills the active session's state on first connect (deduped per session, reset on SSE reconnect so it re-syncs after a server restart). - `session-events.ts` — removed the three `drainAutoAcceptPermissionsForInstance` hooks (the server drains now). - `api-client.ts` — `getYoloState` / `toggleYolo`. ## Behavior parity | Aspect | Before | After | |---|---|---| | Reply semantics | `"once"` | `"once"` (unchanged) | | Inheritance | whole family (root + non-fork descendants) | **same** | | Fork isolation | `revert` session is its own root | **same** | | Persistence | UI `localStorage` | none (intentional, see Notes) | | Multi-client sync | ❌ independent per browser | ✅ real-time via SSE | | Works with UI closed | ❌ | ✅ | | Auto-reply path | 4 hops | 1 in-process hop | ## Testing 32 unit tests added (`node:test`), all passing. Server + UI typecheck clean. - **Store (16)**: inheritance (parent/child/sibling), fork isolation, cyclic parent chains, late parent discovery, `revert` re-rooting, per-instance independence, tree maintenance. - **Manager (13)**: real `properties.info` event shapes, v2 vs legacy reply, in-flight dedup + retry-after-resolve, `yolo.stateChanged` emission, `session.deleted` keeps toggle, `workspace.stopped` clears state, `stop()` unsubscribes. - **UI (3)**: retained `resolvePermissionAutoAcceptFamilyRoot` display-projection tests. ## Notes for reviewers - **No persistence is intentional** for this milestone — server restart resets all Yolo state (matches the agreed scope). The UI mirror self-heals via SSE reconnect + `ensureYoloStateSynced`. Persistence can be layered on later (e.g. into `~/.config/codenomad/config.json`) without touching the manager. - **Family-root resolution lives in two places on purpose**: the server resolves it to decide whether to auto-reply; the UI resolves the same pure function to render the badge instantly (synchronous memo). Both are faithful ports; no network round-trip is added for display. - **`properties.info` nesting**: OpenCode wraps session records under `properties.info` for `session.*` events (permission events are flat). The manager handles both and the tests use the real nested shape to guard against regressions. - `getYoloState` / `yolo.autoAccepted` are wired but `yolo.autoAccepted` is not yet consumed by the UI — it's available on the wire for future observability (e.g. an audit log / toast). ## Risk / rollback The change is additive on the server and the UI gracefully degrades to the SSE mirror. If the server lacks the new routes (mixed-version), the UI's optimistic toggle still flips locally and `toggleYolo` failures are logged + reverted, so no hard breakage. --------- Co-authored-by: Pascal André --- .../references/feature-traces.md | 11 +- .../references/sdk-integration-patterns.md | 26 +- package-lock.json | 1 + packages/server/package.json | 1 + packages/server/src/api-types.ts | 8 + packages/server/src/events/bus.ts | 4 + .../permissions/auto-accept-manager.test.ts | 668 ++++++++++++++++++ .../src/permissions/auto-accept-manager.ts | 296 ++++++++ .../src/permissions/auto-accept-store.test.ts | 182 +++++ .../src/permissions/auto-accept-store.ts | 128 ++++ .../src/permissions/opencode-replier.ts | 47 ++ packages/server/src/server/http-server.ts | 15 + packages/server/src/server/routes/yolo.ts | 26 + .../server/src/workspaces/instance-client.ts | 49 ++ packages/server/tsconfig.json | 2 +- packages/ui/src/lib/api-client.ts | 12 + packages/ui/src/stores/instances.ts | 97 ++- .../ui/src/stores/permission-auto-accept.ts | 137 ++-- packages/ui/src/stores/session-events.ts | 14 +- packages/ui/src/stores/session-state.ts | 5 +- 20 files changed, 1583 insertions(+), 146 deletions(-) create mode 100644 packages/server/src/permissions/auto-accept-manager.test.ts create mode 100644 packages/server/src/permissions/auto-accept-manager.ts create mode 100644 packages/server/src/permissions/auto-accept-store.test.ts create mode 100644 packages/server/src/permissions/auto-accept-store.ts create mode 100644 packages/server/src/permissions/opencode-replier.ts create mode 100644 packages/server/src/server/routes/yolo.ts create mode 100644 packages/server/src/workspaces/instance-client.ts diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 286d32d8..2aa5a42d 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references. 1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated` - Events are pushed through the instance event stream -2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler - - **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true - - **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - **Action:** Automatically calls `Permission.reply()`, skips modal display - - **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()` +2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled) + - **File:** `packages/server/src/permissions/auto-accept-manager.ts` + - **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup + - **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes +3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` + - **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately - **Branch:** ELSE (normal flow) - **Mechanism:** Permission queued in `permissionQueues` signal - **Action:** Display approval modal diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index eda86515..3f5d4321 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync: 1. **Server emits** `permission.asked` or `permission.updated` SSE event - Pushed through instance event stream -2. **UI Store receives** via `serverEvents` +2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled) + - File: `packages/server/src/permissions/auto-accept-manager.ts` + - Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change + - Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI +3. **UI Store receives** via `serverEvents` - File: `packages/ui/src/stores/instances.ts` - - **Branch:** IF `isPermissionAutoAcceptEnabled()` - - Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - Action: Calls reply immediately, skips modal - - **Branch:** ELSE - - Mechanism: Queued in `permissionQueues` - - Action: Display modal -3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` -4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays -5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` -6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` -7. **Optimistic Update:** `removePermissionV2()` in bridge -8. **SSE Confirmation:** `permission.replied` event + - **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue + - **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal +4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` +5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays +6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` +7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` +8. **Optimistic Update:** `removePermissionV2()` in bridge +9. **SSE Confirmation:** `permission.replied` event - **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect ## Session Event Handling diff --git a/package-lock.json b/package-lock.json index 5d08c142..6777e1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13454,6 +13454,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/package.json b/packages/server/package.json index 15228764..3ca35925 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d2349cde..32eea774 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -365,6 +365,10 @@ export interface VoiceModeStateResponse { enabled: boolean } +export interface YoloStateResponse { + enabled: boolean +} + export interface RemoteServerProfile { id: string name: string @@ -414,6 +418,8 @@ export type WorkspaceEventType = | "instance.dataChanged" | "instance.event" | "instance.eventStatus" + | "yolo.stateChanged" + | "yolo.autoAccepted" export type WorkspaceEventPayload = | { type: "workspace.created"; workspace: WorkspaceDescriptor } @@ -428,6 +434,8 @@ export type WorkspaceEventPayload = | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } + | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } export interface NetworkAddress { ip: string diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index fd1e3ce6..7929c7e2 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -31,6 +31,8 @@ export class EventBus extends EventEmitter { this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) + this.on("yolo.stateChanged", handler) + this.on("yolo.autoAccepted", handler) return () => { this.off("workspace.created", handler) this.off("workspace.started", handler) @@ -44,6 +46,8 @@ export class EventBus extends EventEmitter { this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) + this.off("yolo.stateChanged", handler) + this.off("yolo.autoAccepted", handler) } } } diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts new file mode 100644 index 00000000..629ccf99 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -0,0 +1,668 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { EventBus } from "../events/bus" +import { AutoAcceptManager, type PermissionReplier, type AutoAcceptReply } from "./auto-accept-manager" +import type { InstanceStreamEvent } from "../api-types" +import type { Logger } from "../logger" + +const noopLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + trace() {}, + isLevelEnabled() { + return false + }, + child() { + return noopLogger + }, +} as unknown as Logger + +function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record) { + bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent }) +} + +/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */ +function publishSession( + bus: EventBus, + instanceId: string, + eventType: "session.updated" | "session.created" | "session.deleted", + info: Record, +) { + publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } }) +} + +describe("AutoAcceptManager session tree", () => { + it("ingests session.updated to build the parent chain", () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.toggle("inst", "child") + assert.equal(manager.isEnabled("inst", "child"), true) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) + + it("treats a session with revert as a fork root", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { + id: "fork", + parentID: "master", + revert: { messageID: "m", partID: "p" }, + }) + + manager.toggle("inst", "fork") + assert.equal(manager.isEnabled("inst", "fork"), true) + assert.equal(manager.isEnabled("inst", "master"), false) + + manager.stop() + }) + + it("session.deleted removes the tree entry but keeps the toggle", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + publishSession(bus, "inst", "session.deleted", { id: "master" }) + + // toggle is independent of the tree (survives deletion) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) +}) + +describe("AutoAcceptManager permission interception", () => { + it("auto-replies to a v2 permission on an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const accepted: Record[] = [] + bus.on("yolo.autoAccepted", (e) => accepted.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + manager.toggle("inst", "child") // enable the whole family root + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + const call = replier.calls[0] + assert.equal(call.instanceId, "inst") + assert.equal(call.permissionId, "perm-1") + assert.equal(call.sessionId, "child") + assert.equal(call.source, "v2") + assert.equal(call.reply, "once") + assert.equal(accepted.length, 1) + assert.equal((accepted[0] as any).permissionId, "perm-1") + + manager.stop() + }) + + it("auto-replies to a legacy permission.asked event", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-2", sessionID: "solo", type: "bash" }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "legacy") + assert.equal(replier.calls[0].permissionId, "perm-2") + + manager.stop() + }) + + it("does not reply when the family is disabled", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-3", sessionID: "solo" }, + }) + + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.stop() + }) + + it("ignores permission events without an id or sessionID", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } }) + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("deduplicates repeated emission of the same permission", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + for (let i = 0; i < 3; i++) { + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-dup", sessionID: "solo" }, + }) + } + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + manager.stop() + }) + + it("clears in-flight tracking after the reply resolves so it can retry", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 2) + manager.stop() + }) +}) + +describe("AutoAcceptManager state events", () => { + it("publishes yolo.stateChanged with the new enabled value on toggle", () => { + const bus = new EventBus(noopLogger) + const changes: Record[] = [] + bus.on("yolo.stateChanged", (e) => changes.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + + manager.toggle("inst", "master") + manager.toggle("inst", "master") + + assert.equal(changes.length, 2) + assert.equal((changes[0] as any).enabled, true) + assert.equal((changes[1] as any).enabled, false) + assert.equal((changes[0] as any).sessionId, "master") + assert.equal((changes[0] as any).instanceId, "inst") + + manager.stop() + }) +}) + +describe("AutoAcceptManager lifecycle", () => { + it("clearInstance drops tree and enabled state for the instance", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + manager.clearInstance("inst") + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("clears state when the workspace stops", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + bus.publish({ type: "workspace.stopped", workspaceId: "inst" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("stop() unsubscribes so no further events are processed", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + manager.stop() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "p", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + }) +}) + +describe("AutoAcceptManager pending permissions drain", () => { + it("drains a pending permission that arrived before enable", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-pending", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // enabling yolo should drain the pending permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-pending") + + manager.stop() + }) + + it("drains pending permissions for the same family only", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-a", sessionID: "root-a" }, + }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-b", sessionID: "root-b" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.toggle("inst", "root-a") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-a") + + manager.stop() + }) + + it("does not re-drain already-auto-accepted permissions", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 1) + + // toggling off then on should not re-drain the already-replied permission + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain runs but pending set is empty + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + + manager.stop() + }) + + it("re-drains pending when late session ancestry joins an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + // master exists, yolo enabled on master + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + + // child permission arrives BEFORE child's session ancestry is known. + // At this point "child" is unknown to the tree, so it resolves as its + // own root and the permission is NOT auto-accepted. + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-late", sessionID: "child" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // child session ancestry arrives — child.parentID = "master". + // ingestSession → upsertSession → migrateEnabledRoots makes "child" + // resolve to "master" (enabled). drainPending should fire and accept + // the previously-pending permission. + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-late") + assert.equal(replier.calls[0].sessionId, "child") + + manager.stop() + }) +}) + +describe("AutoAcceptManager permission replied cleanup", () => { + it("removes a pending permission on permission.v2.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-x", sessionID: "solo" }, + }) + await flushMicrotasks() + + // user manually replies → replied event cleans up pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.replied", + properties: { id: "perm-x" }, + }) + + // enabling yolo now should NOT drain the already-replied permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("removes a pending permission on legacy permission.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-y", sessionID: "solo" }, + }) + await flushMicrotasks() + + publishInstanceEvent(bus, "inst", { + type: "permission.replied", + properties: { requestID: "perm-y" }, + }) + + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager clearInstance clears pending", () => { + it("drops pending permissions on clearInstance", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-z", sessionID: "solo" }, + }) + await flushMicrotasks() + + manager.clearInstance("inst") + + // re-create session and enable — pending set should be empty + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager permission.updated source inference", () => { + it("preserves the original v2 source when permission.updated arrives", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + // yolo is OFF — permission goes to pending with source "v2" + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + // enable yolo, then send permission.updated — should keep source "v2" + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "v2") + + manager.stop() + }) + + it("skips permission.updated for a permission not in pending", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + // permission.updated for a permission that was never asked (not in pending) + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-unknown", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager replier failure handling", () => { + it("keeps permission pending and does not emit autoAccepted on failure", async () => { + const bus = new EventBus(noopLogger) + const autoAccepted: unknown[] = [] + bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e)) + const failingReplier: PermissionReplier = async () => { + throw new Error("connection refused") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-fail", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + // permission should still be pending (enabling again would try again) + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain retries + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + + manager.stop() + }) + + it("stops retrying after MAX_REPLY_ATTEMPTS", async () => { + const bus = new EventBus(noopLogger) + let callCount = 0 + const failingReplier: PermissionReplier = async () => { + callCount++ + throw new Error("timeout") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-stuck", sessionID: "solo" }, + }) + await flushMicrotasks() + const attemptsAfterFirst = callCount + + // trigger drains via session events + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + + // should have attempted at most MAX_REPLY_ATTEMPTS times total + assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`) + assert.equal(callCount, attemptsAfterFirst + 2) // 2 more retries (total 3), then stops + + manager.stop() + }) +}) + +describe("AutoAcceptManager workspace.error cleanup", () => { + it("clears state when the workspace errors", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + assert.equal(manager.isEnabled("inst", "solo"), true) + + bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any }) + + assert.equal(manager.isEnabled("inst", "solo"), false) + manager.stop() + }) +}) + +describe("AutoAcceptManager session.deleted clears pending", () => { + it("removes pending permissions for a deleted session", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF → goes to pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-doomed", sessionID: "solo" }, + }) + await flushMicrotasks() + + // session deleted → pending should be cleared + publishSession(bus, "inst", "session.deleted", { id: "solo" }) + + // enabling yolo should not drain the deleted session's permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +function makeRecordingReplier() { + const calls: AutoAcceptReply[] = [] + const replier: PermissionReplier = async (reply) => { + calls.push(reply) + } + return Object.assign(replier, { calls }) as PermissionReplier & { calls: AutoAcceptReply[] } +} + +function flushMicrotasks() { + return new Promise((resolve) => setImmediate(resolve)) +} diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts new file mode 100644 index 00000000..07b79d15 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -0,0 +1,296 @@ +import type { EventBus } from "../events/bus" +import type { Logger } from "../logger" +import { AutoAcceptStore } from "./auto-accept-store" + +/** + * Server-side owner of Yolo (permission auto-accept). + * + * Subscribes to the instance SSE stream that the server already consumes + * (`InstanceEventBridge` -> EventBus `instance.event`) and: + * - maintains a per-instance session tree so family-root inheritance can + * be resolved identically to the previous frontend implementation + * - when a permission request arrives for an enabled family, auto-replies + * via the injected {@link PermissionReplier} (same `"once"` semantics the + * UI used to send) + * - emits `yolo.stateChanged` / `yolo.autoAccepted` events on the EventBus + * so the UI stays a pure view + */ + +export type PermissionSource = "v2" | "legacy" +export type PermissionReplyValue = "once" + +export interface AutoAcceptReply { + instanceId: string + permissionId: string + sessionId: string + source: PermissionSource + reply: PermissionReplyValue +} + +export type PermissionReplier = (reply: AutoAcceptReply) => Promise + +interface PendingPermission { + permissionId: string + sessionId: string + source: PermissionSource +} + +interface AutoAcceptManagerDeps { + eventBus: EventBus + logger: Logger + replier: PermissionReplier +} + +const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) +const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) +const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) +const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) + +export class AutoAcceptManager { + private static readonly MAX_REPLY_ATTEMPTS = 3 + private readonly store = new AutoAcceptStore() + /** instanceId:permissionId entries currently being replied, to dedupe re-emissions */ + private readonly inFlight = new Set() + /** instanceId -> (permissionId -> pending permission) awaiting a reply */ + private readonly pending = new Map>() + /** instanceId:permissionId -> failure count, to stop retrying stuck permissions */ + private readonly replyAttempts = new Map() + private unsubscribe?: () => void + + constructor(private readonly deps: AutoAcceptManagerDeps) {} + + start(): void { + if (this.unsubscribe) return + const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => { + if (!payload || !payload.instanceId || !payload.event) return + this.handleInstanceEvent(payload.instanceId, payload.event) + } + const onStopped = (event: { workspaceId?: string }) => { + if (event?.workspaceId) this.clearInstance(event.workspaceId) + } + const onError = (event: { workspace?: { id?: string } }) => { + if (event?.workspace?.id) this.clearInstance(event.workspace.id) + } + this.deps.eventBus.on("instance.event", handler) + this.deps.eventBus.on("workspace.stopped", onStopped) + this.deps.eventBus.on("workspace.error", onError) + this.unsubscribe = () => { + this.deps.eventBus.off("instance.event", handler) + this.deps.eventBus.off("workspace.stopped", onStopped) + this.deps.eventBus.off("workspace.error", onError) + } + } + + stop(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + isEnabled(instanceId: string, sessionId: string): boolean { + return this.store.isEnabled(instanceId, sessionId) + } + + toggle(instanceId: string, sessionId: string): boolean { + const enabled = this.store.toggle(instanceId, sessionId) + this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled }) + if (enabled) { + this.drainPending(instanceId, sessionId) + } + return enabled + } + + clearInstance(instanceId: string): void { + this.store.clearInstance(instanceId) + this.pending.delete(instanceId) + const prefix = `${instanceId}:` + for (const key of Array.from(this.inFlight.keys())) { + if (key.startsWith(prefix)) this.inFlight.delete(key) + } + for (const key of Array.from(this.replyAttempts.keys())) { + if (key.startsWith(prefix)) this.replyAttempts.delete(key) + } + } + + handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void { + if (!event || typeof event.type !== "string") return + + if (SESSION_UPSERT_TYPES.has(event.type)) { + this.ingestSession(instanceId, event.properties) + return + } + if (SESSION_REMOVE_TYPES.has(event.type)) { + const info = (event.properties as { info?: SessionProperties } | undefined)?.info + const id = readString(info?.id) ?? readString(event.properties?.id) + if (id) { + this.store.removeSession(instanceId, id) + this.removePendingForSession(instanceId, id) + } + return + } + if (PERMISSION_REPLIED_TYPES.has(event.type)) { + this.handlePermissionReplied(instanceId, event.properties) + return + } + if (PERMISSION_ASK_TYPES.has(event.type)) { + this.handlePermissionRequest(instanceId, event.type, event.properties) + } + } + + private ingestSession(instanceId: string, properties: unknown): void { + // OpenCode wraps session records under `properties.info` for + // session.created/updated/deleted (see SDK EventSessionUpdated). Accept a + // flat fallback only for defensive compatibility. + const info = (properties as { info?: SessionProperties } | SessionProperties | undefined) + const session = (info && typeof info === "object" && "info" in info ? info.info : info) as + | SessionProperties + | undefined + if (!session || typeof session.id !== "string") return + const parentId = session.parentID ?? session.parentId ?? null + const revert = session.revert ?? undefined + this.store.upsertSession(instanceId, { id: session.id, parentId, revert }) + // Session ancestry may have changed (parent discovered, revert toggled). + // Re-drain pending permissions whose family root may have migrated into + // an enabled family — mirrors the old UI's drainAutoAcceptPermissions- + // ForInstance trigger on session.updated (#497). + this.drainPending(instanceId, session.id) + } + + private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void { + const request = permission as PermissionProperties | undefined + if (!request) return + const permissionId = readString(request.id) + const sessionId = readString(request.sessionID) ?? readString(request.sessionId) + if (!permissionId || !sessionId) return + + // Infer source from the event type, but prefer the already-tracked source + // for permission.updated (which may belong to a v2 permission). + const existing = this.pending.get(instanceId)?.get(permissionId) + const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy") + + // `permission.updated` represents a detail change for a permission that + // is *already* pending. If it is no longer in our pending set it was + // already replied to (by us or the user) — skip to avoid a duplicate reply. + if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) { + return + } + + this.addPending(instanceId, { permissionId, sessionId, source }) + + if (!this.store.isEnabled(instanceId, sessionId)) return + this.tryAutoAccept(instanceId, permissionId, sessionId, source) + } + + private handlePermissionReplied(instanceId: string, properties: unknown): void { + const request = (properties as PermissionRepliedProperties | undefined) ?? {} + const permissionId = + readString(request.id) ?? + readString(request.requestID) ?? + readString(request.permissionID) ?? + readString(request.requestId) ?? + readString(request.permissionId) + if (permissionId) this.removePending(instanceId, permissionId) + } + + private tryAutoAccept( + instanceId: string, + permissionId: string, + sessionId: string, + source: PermissionSource, + ): void { + const key = `${instanceId}:${permissionId}` + if (this.inFlight.has(key)) return + const attempts = this.replyAttempts.get(key) ?? 0 + if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return + this.inFlight.add(key) + this.replyAttempts.set(key, attempts + 1) + + const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" } + + void this.deps.replier(reply) + .then(() => { + this.replyAttempts.delete(key) + this.removePending(instanceId, permissionId) + this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId }) + }) + .catch((error) => { + this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed") + if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) { + this.removePending(instanceId, permissionId) + } + }) + .finally(() => { + this.inFlight.delete(key) + }) + } + + /** Auto-accept all pending permissions belonging to the same family root. */ + private drainPending(instanceId: string, sessionId: string): void { + const instancePending = this.pending.get(instanceId) + if (!instancePending || instancePending.size === 0) return + const root = this.store.familyRoot(instanceId, sessionId) + for (const entry of Array.from(instancePending.values())) { + if (this.store.familyRoot(instanceId, entry.sessionId) === root) { + this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source) + } + } + } + + private addPending(instanceId: string, entry: PendingPermission): void { + let instancePending = this.pending.get(instanceId) + if (!instancePending) { + instancePending = new Map() + this.pending.set(instanceId, instancePending) + } + instancePending.set(entry.permissionId, entry) + } + + private removePending(instanceId: string, permissionId: string): void { + const instancePending = this.pending.get(instanceId) + if (instancePending?.delete(permissionId)) { + this.replyAttempts.delete(`${instanceId}:${permissionId}`) + if (instancePending.size === 0) this.pending.delete(instanceId) + } + } + + private removePendingForSession(instanceId: string, sessionId: string): void { + const instancePending = this.pending.get(instanceId) + if (!instancePending) return + for (const [permId, entry] of Array.from(instancePending)) { + if (entry.sessionId === sessionId) { + instancePending.delete(permId) + this.replyAttempts.delete(`${instanceId}:${permId}`) + } + } + if (instancePending.size === 0) this.pending.delete(instanceId) + } +} + +interface InstanceStreamPayload { + type?: string + properties?: Record +} + +interface SessionProperties { + id?: string + parentID?: string | null + parentId?: string | null + revert?: unknown +} + +interface PermissionProperties { + id?: string + sessionID?: string + sessionId?: string +} + +interface PermissionRepliedProperties { + id?: string + requestID?: string + permissionID?: string + requestId?: string + permissionId?: string +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts new file mode 100644 index 00000000..5ccfd540 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store" + +describe("resolveFamilyRoot", () => { + it("returns the session id itself when no info is known", () => { + assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan") + }) + + it("keeps a loaded child as root when its parent is missing", () => { + const root = resolveFamilyRoot("child", (id) => + id === "child" ? { id: "child", parentId: "parent" } : undefined, + ) + assert.equal(root, "child") + }) + + it("resolves to the master session when the full parent chain is loaded", () => { + const root = resolveFamilyRoot("grandchild", (id) => { + if (id === "grandchild") return { id: "grandchild", parentId: "child" } + if (id === "child") return { id: "child", parentId: "master" } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "master") + }) + + it("keeps a fork session (with revert) as its own root", () => { + const root = resolveFamilyRoot("fork", (id) => { + if (id === "fork") + return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "fork") + }) + + it("terminates on cyclic parent chains without looping forever", () => { + const root = resolveFamilyRoot("a", (id) => { + if (id === "a") return { id: "a", parentId: "b" } + if (id === "b") return { id: "b", parentId: "a" } + return undefined + }) + // cycle: last known id before re-entering the cycle is returned + assert.ok(root === "a" || root === "b") + }) +}) + +describe("AutoAcceptStore inheritance", () => { + it("is disabled by default for an unknown session", () => { + const store = new AutoAcceptStore() + assert.equal(store.isEnabled("inst", "s1"), false) + }) + + it("enabling a parent enables every descendant that resolves to it", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.upsertSession("inst", { id: "grandchild", parentId: "child" }) + + store.setEnabled("inst", "master", true) + + assert.equal(store.isEnabled("inst", "master"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.isEnabled("inst", "grandchild"), true) + }) + + it("enabling a child also covers the parent family root and siblings", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child-a", parentId: "master" }) + store.upsertSession("inst", { id: "child-b", parentId: "master" }) + + store.setEnabled("inst", "child-a", true) + + assert.equal(store.isEnabled("inst", "child-a"), true) + assert.equal(store.isEnabled("inst", "child-b"), true) + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("a fork session is isolated: enabling it does not enable its parent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { + id: "fork", + parentId: "master", + revert: { messageID: "msg", partID: "part" }, + }) + + store.setEnabled("inst", "fork", true) + + assert.equal(store.isEnabled("inst", "fork"), true) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("disabling the family root clears the setting for all descendants", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + store.setEnabled("inst", "child", true) + assert.equal(store.isEnabled("inst", "child"), true) + + store.setEnabled("inst", "master", false) + assert.equal(store.isEnabled("inst", "child"), false) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("toggle flips the resolved family-root state and reports the new value", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + assert.equal(store.toggle("inst", "child"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.toggle("inst", "master"), false) + assert.equal(store.isEnabled("inst", "child"), false) + }) + + it("keeps per-instance state independent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst-a", { id: "root", parentId: null }) + store.upsertSession("inst-b", { id: "root", parentId: null }) + + store.setEnabled("inst-a", "root", true) + assert.equal(store.isEnabled("inst-a", "root"), true) + assert.equal(store.isEnabled("inst-b", "root"), false) + }) +}) + +describe("AutoAcceptStore session tree maintenance", () => { + it("migrates enabled root when a parent is discovered later", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "child", parentId: "parent" }) + // parent unknown -> child is its own root + store.setEnabled("inst", "child", true) + + // later the parent shows up — root should migrate from "child" to "parent" + store.upsertSession("inst", { id: "parent", parentId: null }) + assert.equal(store.isEnabled("inst", "parent"), true) + assert.equal(store.isEnabled("inst", "child"), true) + }) + + it("removing a session does not clear an enabled family root", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.removeSession("inst", "master") + // the toggle is independent of the session tree: it survives session deletion + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("clearInstance drops both tree and enabled state", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.clearInstance("inst") + assert.equal(store.isEnabled("inst", "master"), false) + store.upsertSession("inst", { id: "master", parentId: null }) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("changing revert status re-roots a session as a fork", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.setEnabled("inst", "child", true) + // parent family enabled + assert.equal(store.isEnabled("inst", "master"), true) + + // child becomes a fork + store.upsertSession("inst", { + id: "child", + parentId: "master", + revert: { messageID: "m", partID: "p" }, + }) + // now child resolves to itself; the family setting was on "master" so still on for master + assert.equal(store.isEnabled("inst", "master"), true) + // child is its own root now, not enabled unless toggled + assert.equal(store.isEnabled("inst", "child"), false) + }) +}) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts new file mode 100644 index 00000000..0e34a24e --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -0,0 +1,128 @@ +/** + * In-memory permission auto-accept (Yolo) state, owned by the server. + * + * This is a faithful port of the previous frontend implementation + * (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance + * semantics are preserved exactly: + * - state is keyed by the resolved *family root* session id + * - a session with a `revert` snapshot is treated as its own root (fork) + * - enabling any session enables its whole family root and vice-versa + * + * No persistence: state is lost on server restart, matching the "no + * persistence for now" milestone. + */ + +export interface AutoAcceptSessionInfo { + id: string + parentId?: string | null + /** Truthy value marks the session as a fork that roots at itself. */ + revert?: unknown +} + +type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined + +/** + * Resolve the family-root session id for `sessionId` by walking the parent + * chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so + * inheritance behaviour does not change. + */ +export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): string { + let currentId = sessionId + let lastKnownId = sessionId + const seen = new Set() + + while (currentId && !seen.has(currentId)) { + seen.add(currentId) + const session = getSession(currentId) + if (!session) return lastKnownId + lastKnownId = session.id + if (session.revert) return session.id + if (!session.parentId) return session.id + currentId = session.parentId + } + + return currentId || sessionId +} + +export class AutoAcceptStore { + /** instanceId -> set of enabled family-root session ids */ + private readonly enabled = new Map>() + /** instanceId -> (sessionId -> info) */ + private readonly sessions = new Map>() + + isEnabled(instanceId: string, sessionId: string): boolean { + const root = this.familyRoot(instanceId, sessionId) + return this.enabled.get(instanceId)?.has(root) ?? false + } + + setEnabled(instanceId: string, sessionId: string, enabled: boolean): void { + const root = this.familyRoot(instanceId, sessionId) + let roots = this.enabled.get(instanceId) + if (!roots) { + if (!enabled) return + roots = new Set() + this.enabled.set(instanceId, roots) + } + if (enabled) { + roots.add(root) + } else { + roots.delete(root) + if (roots.size === 0) { + this.enabled.delete(instanceId) + } + } + } + + toggle(instanceId: string, sessionId: string): boolean { + const next = !this.isEnabled(instanceId, sessionId) + this.setEnabled(instanceId, sessionId, next) + return next + } + + upsertSession(instanceId: string, info: AutoAcceptSessionInfo): void { + let tree = this.sessions.get(instanceId) + if (!tree) { + tree = new Map() + this.sessions.set(instanceId, tree) + } + tree.set(info.id, { + id: info.id, + parentId: info.parentId ?? null, + revert: info.revert, + }) + this.migrateEnabledRoots(instanceId) + } + + removeSession(instanceId: string, sessionId: string): void { + this.sessions.get(instanceId)?.delete(sessionId) + } + + clearInstance(instanceId: string): void { + this.sessions.delete(instanceId) + this.enabled.delete(instanceId) + } + + /** Resolves the family-root session id for the given session. */ + familyRoot(instanceId: string, sessionId: string): string { + const tree = this.sessions.get(instanceId) + return resolveFamilyRoot(sessionId, (id) => tree?.get(id)) + } + + /** + * Re-resolves every enabled family root for an instance after the session + * tree changes (new session, updated parent/revert). If a root now resolves + * to a different id, the enabled entry is migrated so toggles survive late + * ancestry discovery. + */ + private migrateEnabledRoots(instanceId: string): void { + const roots = this.enabled.get(instanceId) + if (!roots || roots.size === 0) return + for (const oldRoot of Array.from(roots)) { + const newRoot = this.familyRoot(instanceId, oldRoot) + if (newRoot !== oldRoot) { + roots.delete(oldRoot) + roots.add(newRoot) + } + } + } +} diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts new file mode 100644 index 00000000..2ed9bfc9 --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.ts @@ -0,0 +1,47 @@ +import type { WorkspaceManager } from "../workspaces/manager" +import type { Logger } from "../logger" +import { createInstanceClient } from "../workspaces/instance-client" +import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" + +interface OpencodeReplierDeps { + workspaceManager: WorkspaceManager + logger: Logger +} + +/** + * Default {@link PermissionReplier} that calls the OpenCode instance via the + * generated SDK client over loopback, using the same `"once"` reply the UI + * previously sent. + * + * Uses `createInstanceClient` so routes and body shapes are always correct + * for the installed SDK version — no hand-assembled URLs. + */ +export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { + return async (reply: AutoAcceptReply) => { + const client = createInstanceClient(deps.workspaceManager, reply.instanceId) + if (!client) { + throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) + } + + const opts = { throwOnError: true } as const + + if (reply.source === "v2") { + await client.v2.session.permission.reply( + { + sessionID: reply.sessionId, + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } else { + await client.permission.reply( + { + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 5dd85468..a5b13b37 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -22,6 +22,7 @@ import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" +import { registerYoloRoutes } from "./routes/yolo" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerRemoteServerRoutes } from "./routes/remote-servers" @@ -31,6 +32,8 @@ import { registerPreviewRoutes } from "./routes/previews" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" +import { AutoAcceptManager } from "../permissions/auto-accept-manager" +import { createOpencodePermissionReplier } from "../permissions/opencode-replier" import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" @@ -192,6 +195,16 @@ export function createHttpServer(deps: HttpServerDeps) { logger: deps.logger.child({ component: "background-processes" }), }) + const yoloManager = new AutoAcceptManager({ + eventBus: deps.eventBus, + logger: deps.logger.child({ component: "yolo" }), + replier: createOpencodePermissionReplier({ + workspaceManager: deps.workspaceManager, + logger: deps.logger.child({ component: "yolo" }), + }), + }) + yoloManager.start() + registerAuthRoutes(app, { authManager: deps.authManager }) app.addHook("preHandler", (request, reply, done) => { @@ -309,6 +322,7 @@ export function createHttpServer(deps: HttpServerDeps) { voiceModeManager: deps.voiceModeManager, }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) + registerYoloRoutes(app, { yoloManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) @@ -371,6 +385,7 @@ export function createHttpServer(deps: HttpServerDeps) { return { port: actualPort, url: serverUrl, displayHost } }, stop: () => { + yoloManager.stop() closeSseClients() return app.close() }, diff --git a/packages/server/src/server/routes/yolo.ts b/packages/server/src/server/routes/yolo.ts new file mode 100644 index 00000000..ddd66985 --- /dev/null +++ b/packages/server/src/server/routes/yolo.ts @@ -0,0 +1,26 @@ +import { FastifyInstance } from "fastify" +import type { AutoAcceptManager } from "../../permissions/auto-accept-manager" + +interface RouteDeps { + yoloManager: AutoAcceptManager +} + +export function registerYoloRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId", + async (request) => { + const { id, sessionId } = request.params + return { enabled: deps.yoloManager.isEnabled(id, sessionId) } + }, + ) + + app.post<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId/toggle", + async (request, reply) => { + const { id, sessionId } = request.params + const enabled = deps.yoloManager.toggle(id, sessionId) + reply.code(200) + return { enabled } + }, + ) +} diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts new file mode 100644 index 00000000..76be830c --- /dev/null +++ b/packages/server/src/workspaces/instance-client.ts @@ -0,0 +1,49 @@ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { WorkspaceManager } from "./manager" + +const INSTANCE_HOST = "127.0.0.1" +const LOOPBACK_TIMEOUT_MS = 10_000 + +/** + * Creates an OpenCode SDK client for direct loopback communication with a + * running workspace instance. + * + * Routes and body shapes come from the auto-generated SDK contract + * (`@opencode-ai/sdk`), eliminating handwritten URL construction that can + * drift between SDK versions. Other server modules that need to call the + * OpenCode instance directly should use this factory rather than building + * `http://127.0.0.1:{port}/...` URLs by hand. + * + * All requests carry a 10-second timeout — loopback calls should be + * near-instant; a hang indicates a stuck instance. + * + * The client is cheap to create (object only, no connection); create one per + * call or cache per instance as needed. Returns `null` when the instance has + * no open port yet. + */ +export function createInstanceClient( + workspaceManager: WorkspaceManager, + instanceId: string, +): OpencodeClient | null { + const port = workspaceManager.getInstancePort(instanceId) + if (!port) return null + + const headers: Record = {} + const authorization = workspaceManager.getInstanceAuthorizationHeader(instanceId) + if (authorization) { + headers.authorization = authorization + } + + const workspace = workspaceManager.get(instanceId) + + return createOpencodeClient({ + baseUrl: `http://${INSTANCE_HOST}:${port}/`, + headers, + fetch: (url, init) => + fetch(url, { + ...(init as RequestInit), + signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(LOOPBACK_TIMEOUT_MS), + }), + ...(workspace?.path ? { directory: workspace.path } : {}), + }) +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 5f9cd234..e6f63b22 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", - "moduleResolution": "Node", + "moduleResolution": "Bundler", "strict": true, "esModuleInterop": true, "resolveJsonModule": true, diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 353360ea..9cd6b02a 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -22,6 +22,7 @@ import type { RemoteServerProbeRequest, RemoteServerProbeResponse, VoiceModeStateResponse, + YoloStateResponse, WorkspaceCloneRequest, WorkspaceCloneResponse, WorktreeGitCommitRequest, @@ -520,6 +521,17 @@ export const serverApi = { body: JSON.stringify({ ...identity, enabled }), }) }, + getYoloState(instanceId: string, sessionId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}`, + ) + }, + toggleYolo(instanceId: string, sessionId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}/toggle`, + { method: "POST" }, + ) + }, sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise { const init: RequestInit = { method: "POST", diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 96fddf6d..056ebbea 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -41,10 +41,10 @@ import { pruneRepliedPermissions, } from "./permission-replies" import { - clearAutoAcceptPermission, - drainAutoAcceptPermissions, + clearPermissionAutoAcceptForInstance, isPermissionAutoAcceptEnabled, resolvePermissionAutoAcceptFamilyRoot, + setPermissionAutoAcceptEnabled, setPermissionAutoAcceptFamilyRootResolver, togglePermissionAutoAccept, } from "./permission-auto-accept" @@ -65,6 +65,31 @@ setPermissionAutoAcceptFamilyRootResolver((instanceId, sessionId) => { return resolvePermissionAutoAcceptFamilyRoot(sessionId, (id) => instanceSessions.get(id)) }) +// Server is authoritative for Yolo state; mirror toggles (incl. from other +// clients) arriving over the CodeNomad server event stream into the local +// projection so the badge/switch stay in sync. +serverEvents.on("yolo.stateChanged", (event) => { + if (event.type !== "yolo.stateChanged") return + const { instanceId, sessionId, enabled } = event + if (typeof instanceId !== "string" || typeof sessionId !== "string" || typeof enabled !== "boolean") return + log.info(`[SSE] Yolo state changed: ${instanceId}:${sessionId} -> ${enabled}`) + setPermissionAutoAcceptEnabled(instanceId, sessionId, enabled) +}) + +// When the server auto-accepts a permission, clean up the UI queue immediately +// instead of waiting for the OpenCode permission.replied SSE event (which may +// be delayed or missed on a flaky connection). This preserves the #424 +// invariant: auto-accept cleanup happens at the queue level, not tied to +// external event timing. +serverEvents.on("yolo.autoAccepted", (event) => { + if (event.type !== "yolo.autoAccepted") return + const { instanceId, permissionId } = event + if (typeof instanceId !== "string" || typeof permissionId !== "string") return + markPermissionReplied(instanceId, permissionId) + removePermissionFromQueue(instanceId, permissionId) + removePermissionV2(instanceId, permissionId) +}) + const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) @@ -326,7 +351,6 @@ async function syncPendingPermissions(instanceId: string): Promise { const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission upsertPermissionV2(instanceId, queuedPermission) } - drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) } catch (error) { log.warn("Failed to sync pending permissions", { instanceId, error }) } @@ -519,6 +543,8 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { case "workspace.error": upsertWorkspace(event.workspace) showWorkspaceLaunchError(event.workspace) + clearPermissionAutoAcceptForInstance(event.workspace.id) + clearSyncedYoloSessionsForInstance(event.workspace.id) break case "workspace.stopped": releaseInstanceResources(event.workspaceId) @@ -657,6 +683,8 @@ function removeInstance(id: string) { clearRepliedPermissions(id) clearQuestionQueue(id) clearInstanceMetadata(id) + clearPermissionAutoAcceptForInstance(id) + clearSyncedYoloSessionsForInstance(id) if (activeInstanceId() === id) { setActiveInstanceId(nextActiveId) @@ -1001,7 +1029,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest, } - drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission) return queuedPermission } @@ -1037,7 +1064,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo if (removed) { const removedSessionId = getPermissionSessionId(removed) if (removedSessionId) { - clearAutoAcceptPermission(instanceId, removedSessionId, permissionId) const remaining = decrementSessionPendingCount(instanceId, removedSessionId) setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) } @@ -1045,23 +1071,61 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo } function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void { - const willEnable = !isPermissionAutoAcceptEnabled(instanceId, sessionId) + const wasEnabled = isPermissionAutoAcceptEnabled(instanceId, sessionId) togglePermissionAutoAccept(instanceId, sessionId) - if (!willEnable) return - drainAutoAcceptPermissionsForInstance(instanceId) + void serverApi + .toggleYolo(instanceId, sessionId) + .then((state) => { + setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled) + }) + .catch((error) => { + log.warn("Failed to toggle Yolo on server", { instanceId, sessionId, error }) + // revert to the pre-toggle state (not a naive flip, which can be wrong + // if an SSE yolo.stateChanged arrived between toggle and catch) + setPermissionAutoAcceptEnabled(instanceId, sessionId, wasEnabled) + }) } -function drainAutoAcceptPermissionsForInstance(instanceId: string): void { - drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) +/** + * Sessions whose Yolo state has been backfilled from the server. The server is + * authoritative but only pushes changes (`yolo.stateChanged`); a freshly + * connected client must fetch the effective state for a session so the badge + * matches reality from the start. De-duped per session and reset on SSE + * reconnect so state re-syncs after a server restart. + */ +const syncedYoloSessions = new Set() + +export function ensureYoloStateSynced(instanceId: string, sessionId: string): void { + if (!instanceId || !sessionId || sessionId === "info") return + const key = `${instanceId}:${sessionId}` + if (syncedYoloSessions.has(key)) return + syncedYoloSessions.add(key) + void serverApi + .getYoloState(instanceId, sessionId) + .then((state) => { + setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled) + }) + .catch((error) => { + // allow retry on next activation (e.g. instance not ready yet) + syncedYoloSessions.delete(key) + log.warn("Failed to sync Yolo state", { instanceId, sessionId, error }) + }) +} + +serverEvents.onOpen(() => { + syncedYoloSessions.clear() +}) + +function clearSyncedYoloSessionsForInstance(instanceId: string): void { + const prefix = `${instanceId}:` + for (const key of Array.from(syncedYoloSessions)) { + if (key.startsWith(prefix)) { + syncedYoloSessions.delete(key) + } + } } function clearPermissionQueue(instanceId: string): void { - for (const permission of getPermissionQueue(instanceId)) { - const sessionId = getPermissionSessionId(permission) - if (sessionId) { - clearAutoAcceptPermission(instanceId, sessionId, permission.id) - } - } for (const permission of getPermissionQueue(instanceId)) { permissionEnqueuedAt.delete(permission.id) } @@ -1391,7 +1455,6 @@ export { markPermissionReplied, hasRepliedPermission, togglePermissionAutoAcceptForSession, - drainAutoAcceptPermissionsForInstance, clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index aca94793..f991186b 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -1,15 +1,28 @@ import { createSignal } from "solid-js" -import type { PermissionReply, PermissionRequest } from "../types/permission" -import { getPermissionSessionId } from "../types/permission" -import { getLogger } from "../lib/logger" -const STORAGE_KEY = "codenomad:permission-auto-accept:v1" +/** + * UI-side mirror of the server-owned Yolo (permission auto-accept) state. + * + * The server is authoritative: it holds the toggle state, resolves + * family-root inheritance, and performs the actual `"once"` replies. This + * module only keeps a runtime (NON-persisted) projection so the UI can render + * the badge / switch synchronously. + * + * State is populated from: + * - local toggles (optimistic, then confirmed via REST) + * - `yolo.stateChanged` SSE events (wired in the app bootstrap, see + * `stores/instances.ts`, so toggles from other clients reflect) + * + * `resolvePermissionAutoAcceptFamilyRoot` is retained as a display aid so the + * badge correctly lights up for child/sub-sessions of an enabled family root, + * preserving the previous inheritance UX exactly. The server performs the same + * resolution independently when deciding whether to auto-reply. + * + * NOTE: intentionally pure — no SSE/REST side effects at module load, so it + * stays unit-testable. + */ -const log = getLogger("api") - -type AutoAcceptResponder = (instanceId: string, sessionId: string, requestId: string, reply: PermissionReply) => Promise -type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean -type PermissionAutoAcceptSession = { +export type PermissionAutoAcceptSession = { id: string parentId?: string | null revert?: unknown @@ -45,112 +58,44 @@ function makeKey(instanceId: string, sessionId: string) { return `${instanceId}:${resolveFamilyRoot(instanceId, sessionId)}` } -function readInitialState() { - if (typeof window === "undefined" || !window.localStorage) { - return new Map() - } - - try { - const raw = window.localStorage.getItem(STORAGE_KEY) - if (!raw) return new Map() - const parsed = JSON.parse(raw) as Record - return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true)) - } catch { - return new Map() - } -} - -function persist(next: Map) { - if (typeof window === "undefined" || !window.localStorage) { - return - } - - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(next))) - } catch { - // ignore persistence failures - } -} - -const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState()) - -const inFlight = new Set() +const [autoAcceptState, setAutoAcceptState] = createSignal>(new Map()) export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) { return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false } export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: string, enabled: boolean) { - const key = makeKey(instanceId, sessionId) setAutoAcceptState((prev) => { + const key = makeKey(instanceId, sessionId) + if (prev.get(key) === enabled) return prev const next = new Map(prev) if (enabled) { next.set(key, true) } else { next.delete(key) } - persist(next) return next }) - if (!enabled) { - clearAutoAcceptSession(instanceId, sessionId) - } } export function togglePermissionAutoAccept(instanceId: string, sessionId: string) { - setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId)) + const next = !isPermissionAutoAcceptEnabled(instanceId, sessionId) + setPermissionAutoAcceptEnabled(instanceId, sessionId, next) + return next } -function makeRequestKey(instanceId: string, sessionId: string, requestId: string) { - return `${makeKey(instanceId, sessionId)}:${requestId}` -} - -export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) { - const requestKey = makeRequestKey(instanceId, sessionId, requestId) - inFlight.delete(requestKey) -} - -export function clearAutoAcceptSession(instanceId: string, sessionId: string) { - const prefix = `${makeKey(instanceId, sessionId)}:` - for (const requestKey of Array.from(inFlight)) { - if (requestKey.startsWith(prefix)) { - inFlight.delete(requestKey) +/** Remove all Yolo state entries for an instance (workspace stop / removal). */ +export function clearPermissionAutoAcceptForInstance(instanceId: string) { + setAutoAcceptState((prev) => { + const prefix = `${instanceId}:` + let changed = false + const next = new Map(prev) + for (const key of Array.from(next.keys())) { + if (key.startsWith(prefix)) { + next.delete(key) + changed = true + } } - } -} - -export function drainAutoAcceptPermission( - instanceId: string, - permission: PermissionRequest, - responder: AutoAcceptResponder, - isPending: PendingPermissionChecker, -) { - const sessionId = getPermissionSessionId(permission) - if (!sessionId || !permission?.id) return - if (!isPermissionAutoAcceptEnabled(instanceId, sessionId)) return - if (!isPending(instanceId, permission.id)) return - - const requestKey = makeRequestKey(instanceId, sessionId, permission.id) - if (inFlight.has(requestKey)) return - - inFlight.add(requestKey) - - void responder(instanceId, sessionId, permission.id, "once") - .catch((error) => { - log.error("Failed to auto-accept permission", error) - }) - .finally(() => { - inFlight.delete(requestKey) - }) -} - -export function drainAutoAcceptPermissions( - instanceId: string, - permissions: PermissionRequest[], - responder: AutoAcceptResponder, - isPending: PendingPermissionChecker, -) { - for (const permission of permissions) { - drainAutoAcceptPermission(instanceId, permission, responder, isPending) - } + return changed ? next : prev + }) } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 1742e325..36473700 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -50,7 +50,6 @@ import { hasRepliedPermission, addQuestionToQueue, removeQuestionFromQueue, - drainAutoAcceptPermissionsForInstance, } from "./instances" import { showAlertDialog } from "./alerts" import { @@ -235,7 +234,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory let updatedInstanceSessions: Map | undefined let shouldExpandParent: string | null = null - let shouldDrainAutoAcceptPermissions = false setSessions((prev) => { const next = new Map(prev) @@ -258,7 +256,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory instanceSessions.set(sessionId, merged) next.set(instanceId, instanceSessions) updatedInstanceSessions = instanceSessions - shouldDrainAutoAcceptPermissions = Boolean(merged.parentId) if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") { shouldExpandParent = merged.parentId @@ -268,10 +265,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - if (shouldDrainAutoAcceptPermissions) { - drainAutoAcceptPermissionsForInstance(instanceId) - } - if (shouldExpandParent) { ensureSessionParentExpanded(instanceId, shouldExpandParent) } @@ -542,9 +535,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) setSessionRevertV2(instanceId, info.id, info.revert ?? null) - if (newSession.parentId) { - drainAutoAcceptPermissionsForInstance(instanceId) - } else { + if (!newSession.parentId) { prependSessionListId(instanceId, newSession.id) } @@ -585,9 +576,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) setSessionRevertV2(instanceId, info.id, info.revert ?? null) - if (updatedSession.parentId) { - drainAutoAcceptPermissionsForInstance(instanceId) - } } } diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index eb9cf36a..634d9df7 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -4,7 +4,7 @@ import { getIdleSinceForStatusTransition, type Session, type SessionStatus, type import { deleteSession, loadMessages } from "./session-api" import { showToastNotification } from "../lib/notifications" import { messageStoreBus } from "./message-v2/bus" -import { instances } from "./instances" +import { instances, ensureYoloStateSynced } from "./instances" import { showConfirmDialog } from "./alerts" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" @@ -483,6 +483,9 @@ function setActiveSession(instanceId: string, sessionId: string): void { next.set(instanceId, sessionId) return next }) + // Backfill authoritative Yolo state for the now-active session so the badge + // matches the server even on first connect / multi-client scenarios. + ensureYoloStateSynced(instanceId, sessionId) } function setActiveParentSession(instanceId: string, parentSessionId: string): void { From 34706228bb772448ef057c0ce82b14c765ae49d0 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Sat, 11 Jul 2026 19:18:31 -0400 Subject: [PATCH 6/9] feat(ui): implement recursive nested session expansion (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change enables the UI to display nested sessions within nested sessions in a foldable display, recursively, up to 10 levels deep. ## What Changed ### Core Data Structure (session-state.ts) - Redefined `SessionThread` type to support true recursive nesting: - Old: `{ parent: Session, children: Session[], latestUpdated: number }` - New: `{ session: Session, children: SessionThread[], depth: number, hasChildren: boolean, latestUpdated: number }` - Renamed `expandedSessionParents` signal to `expandedSessions` to reflect that ANY session with children can now be expanded, not just top-level parents - Updated `getSessionThreads()` to build a recursive tree structure using new `buildSessionThreadTree()` and `computeThreadSignature()` helpers - Updated `getSessionFamily()` to recursively collect ALL descendants, not just direct children - Updated `getVisibleSessionIds()` with `collectVisibleSessionIds()` helper to recursively collect visible session IDs based on expansion state - Maintained backward compatibility aliases for renamed functions: `isSessionParentExpanded`, `setSessionParentExpanded`, etc. ### Session State Exports (sessions.ts) - Updated imports and exports to use the new function names: `ensureSessionExpanded`, `isSessionExpanded`, `setSessionExpanded`, `toggleSessionExpanded` ### Session Events (session-events.ts) - Updated `ensureSessionParentExpanded` → `ensureSessionExpanded` in auto-expand logic for child sessions that start working ### Permission Modal (permission-approval-modal.tsx) - Updated import and usage of `ensureSessionParentExpanded` → `ensureSessionExpanded` ### UI Rendering (session-list.tsx) - Updated `SessionRow` component to accept `session` object directly instead of `sessionId`, plus `depth` and `isLastChild` props - Derived `isChild` from `depth > 0` instead of explicit prop - Added `depthClass()` for CSS depth-based indentation - Created new `SessionThreadRow` recursive component that: - Renders the current session via SessionRow - If expanded and has children, recursively renders children with increased depth - Updated `filteredThreads` with `subtreeHasMatch()` and `filterThreadTree()` helpers for recursive filtering - Updated `allMatchingSessionIds` with `collectThreadIds()` helper for recursive ID collection - Removed child-specific `Bot` icon - all sessions now use `User` icon - Updated expander visibility to show for ANY session with children, regardless of depth ### Styling (session-layout.css) - Added depth-based CSS classes `.session-item-depth-{1-10}` with: - Progressive indentation: 2.25rem for depth 1, up to 13.5rem for depth 10 - Tree connector styling via `::before` and `::after` pseudo-elements - Proper vertical line handling for last-child at each depth level ### Tests (session-state.test.ts) - Added comprehensive test suite (683 lines) covering: - `getSessionThreads`: empty sessions, single sessions, single-level children, multi-level nested children, sorting, hasChildren computation - `getSessionFamily`: recursive descendant collection - Expansion state: toggle, explicit set, ensure logic - `getVisibleSessionIds`: visibility based on expansion state at multiple levels ## User-Facing Behavior - Nested sessions can now be collapsed/expanded at any depth level - A chevron expander appears on any session that has children - Children are indented based on their nesting depth - Tree lines connect parent-child relationships visually - Expanding a parent auto-expands ancestors when selecting a deeply nested child session ## Edge Cases Handled - Sessions with no children show no expander - Last child at each depth level has shortened vertical tree line - Thread sorting by latestUpdated works correctly with nested updates - Cache invalidation properly tracks thread changes at all depth levels ## Implementation Notes - Depth is limited to 10 levels to prevent excessive indentation - The `hasChildren` flag is computed once during tree building for performance - The Session type's `parentId` field already supported arbitrary nesting - only the UI rendering needed to be updated --------- Co-authored-by: Pascal André --- packages/ui/src/App.tsx | 9 +- .../shell/useInstanceSessionContext.ts | 21 +- packages/ui/src/components/message-block.tsx | 9 +- .../components/permission-approval-modal.tsx | 8 +- packages/ui/src/components/session-list.tsx | 235 ++++++------- .../src/components/session/session-view.tsx | 11 +- packages/ui/src/lib/keyboard.ts | 5 +- packages/ui/src/stores/session-events.ts | 18 +- packages/ui/src/stores/session-state.ts | 308 ++++++------------ packages/ui/src/stores/session-tree.test.ts | 109 +++++++ packages/ui/src/stores/session-tree.ts | 161 +++++++++ packages/ui/src/stores/sessions.ts | 18 +- packages/ui/src/stores/worktrees.ts | 6 +- .../ui/src/styles/panels/session-layout.css | 18 +- 14 files changed, 539 insertions(+), 397 deletions(-) create mode 100644 packages/ui/src/stores/session-tree.test.ts create mode 100644 packages/ui/src/stores/session-tree.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 866a896b..9fed3e3a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -42,6 +42,7 @@ import { } from "./stores/instances" import { getSessions, + getSessionRoot, activeSessionId, setActiveParentSession, clearActiveParentSession, @@ -408,12 +409,8 @@ const App: Component = () => { return } - const parentSessionId = session.parentId ?? session.id - const parentSession = sessions.find((s) => s.id === parentSessionId) - - if (!parentSession || parentSession.parentId !== null) { - return - } + const parentSession = getSessionRoot(instanceId, sessionId) + if (!parentSession) return clearActiveParentSession(instanceId) diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts index 9b84ba86..3544ac48 100644 --- a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -1,4 +1,4 @@ -import { batch, createMemo, type Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { Session } from "../../../types/session" import { @@ -8,8 +8,8 @@ import { getSessionInfo, getSessionThreads, sessions, - setActiveParentSession, setActiveSession, + setActiveSessionFromList, } from "../../../stores/sessions" import { messageStoreBus } from "../../../stores/message-v2/bus" import { getBackgroundProcesses } from "../../../stores/background-processes" @@ -131,21 +131,8 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions return } - const session = allInstanceSessions().get(sessionId) - if (!session) return - - if (session.parentId === null) { - setActiveParentSession(instanceId, sessionId) - return - } - - const parentId = session.parentId - if (!parentId) return - - batch(() => { - setActiveParentSession(instanceId, parentId) - setActiveSession(instanceId, sessionId) - }) + if (!allInstanceSessions().has(sessionId)) return + setActiveSessionFromList(instanceId, sessionId) } return { diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index b54c8678..c63449e4 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -8,7 +8,7 @@ import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../s import type { MessageRecord } from "../stores/message-v2/types" import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" -import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" +import { ensureSessionAncestorsExpanded, sessions, setActiveSessionFromList } from "../stores/sessions" import { selectInstanceTab } from "../stores/app-tabs" import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" @@ -135,11 +135,8 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string function navigateToTaskSession(location: TaskSessionLocation) { selectInstanceTab(location.instanceId) - const parentToActivate = location.parentId ?? location.sessionId - setActiveParentSession(location.instanceId, parentToActivate) - if (location.parentId) { - setActiveSession(location.instanceId, location.sessionId) - } + ensureSessionAncestorsExpanded(location.instanceId, location.sessionId) + setActiveSessionFromList(location.instanceId, location.sessionId) } interface CachedBlockEntry { diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index d9329286..b2de5f11 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -11,7 +11,7 @@ import { getQuestionEnqueuedAtForInstance, sendPermissionResponse, } from "../stores/instances" -import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" +import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" @@ -262,10 +262,8 @@ const PermissionApprovalModal: Component = (props) function handleGoToSession(sessionId: string) { if (!sessionId) return - const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) - const parentId = session?.parentId ?? session?.id - if (parentId) { - ensureSessionParentExpanded(props.instanceId, parentId) + if (sessionStateSessions().get(props.instanceId)?.has(sessionId)) { + ensureSessionAncestorsExpanded(props.instanceId, sessionId) } setActiveSessionFromList(props.instanceId, sessionId) diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index aad207a8..7f144076 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -11,15 +11,15 @@ import { useI18n } from "../lib/i18n" import { showConfirmDialog } from "../stores/alerts" import { deleteSession, - ensureSessionParentExpanded, + ensureSessionAncestorsExpanded, getVisibleSessionIds, - isSessionParentExpanded, + isSessionExpanded, loadMessages, loading, renameSession, sessions as sessionStateSessions, setActiveSessionFromList, - toggleSessionParentExpanded, + toggleSessionExpanded, loadMoreSessions, searchSessions, getSessionHasMore, @@ -29,6 +29,7 @@ import { isSessionSearchLoading, } from "../stores/sessions" import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" +import { collectSessionThreadIds, findSessionThread, sortSessionIdsDeepestFirst } from "../stores/session-tree" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -150,6 +151,16 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } + const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { + const matchingChildren: SessionThread[] = [] + for (const child of thread.children) { + const filteredChild = filterThreadTree(child, query) + if (filteredChild !== null) matchingChildren.push(filteredChild) + } + if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null + return { ...thread, children: matchingChildren } + } + const filteredThreads = createMemo(() => { const query = normalizedQuery() if (!query) return props.threads @@ -160,29 +171,23 @@ const SessionList: Component = (props) => { return getSessionSearchThreads(props.instanceId) } - const next: SessionThread[] = [] + const result: SessionThread[] = [] for (const thread of props.threads) { - const parentMatches = sessionMatchesQuery(thread.parent.id, query) - const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query)) - - if (!parentMatches && matchingChildren.length === 0) continue - - next.push({ - parent: thread.parent, - children: matchingChildren, - latestUpdated: thread.latestUpdated, - }) + const filtered = filterThreadTree(thread, query) + if (filtered !== null) result.push(filtered) } - - return next + return result }) const allMatchingSessionIds = createMemo(() => { const ids: string[] = [] - for (const thread of filteredThreads()) { - ids.push(thread.parent.id) - for (const child of thread.children) ids.push(child.id) + const collectIds = (threads: SessionThread[]) => { + for (const thread of threads) { + ids.push(thread.session.id) + collectIds(thread.children) + } } + collectIds(filteredThreads()) return ids }) @@ -206,14 +211,13 @@ const SessionList: Component = (props) => { const deleting = loading().deletingSession.get(props.instanceId) return deleting ? deleting.has(sessionId) : false } - const selectSession = (sessionId: string) => { const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) // If the user selects a child session, make sure its parent thread is expanded. // For parent sessions we don't force expansion; user can collapse/expand freely. if (session?.parentId) { - ensureSessionParentExpanded(props.instanceId, session.parentId) + ensureSessionAncestorsExpanded(props.instanceId, session.id) } props.onSelect(sessionId) @@ -360,21 +364,14 @@ const SessionList: Component = (props) => { }) } - const getSelectableThreadIds = (parentId: string): string[] => { - const query = normalizedQuery() - const source = query ? filteredThreads() : props.threads - const thread = source.find((t) => t.parent.id === parentId) - if (!thread) return [parentId] - return [thread.parent.id, ...thread.children.map((c) => c.id)] + const getSelectableThreadIds = (sessionId: string): string[] => { + const source = normalizedQuery() ? filteredThreads() : props.threads + const thread = findSessionThread(source, sessionId) + return thread ? collectSessionThreadIds([thread]) : [sessionId] } const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => { - const ids: string[] = [] - threads.forEach((thread) => { - ids.push(thread.parent.id) - thread.children.forEach((child) => ids.push(child.id)) - }) - return ids + return collectSessionThreadIds(threads) } const handleToggleSelectAll = (checked: boolean) => { @@ -433,8 +430,9 @@ const SessionList: Component = (props) => { } } + const deletionOrder = sortSessionIdsDeepestFirst(sessionStateSessions().get(props.instanceId) ?? new Map(), selected) let failed = 0 - for (const sessionId of selected) { + for (const sessionId of deletionOrder) { try { // eslint-disable-next-line no-await-in-loop await deleteSession(props.instanceId, sessionId) @@ -457,37 +455,34 @@ const SessionList: Component = (props) => { }) } } - const SessionRow: Component<{ - sessionId: string - isChild?: boolean - isLastChild?: boolean - hasChildren?: boolean + session: SessionThread["session"] + depth: number + isLastChild: boolean + hasChildren: boolean expanded?: boolean onToggleExpand?: () => void }> = (rowProps) => { - const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId)) - if (!session()) { - return <> - } + const sessionId = () => rowProps.session.id + const isChild = () => rowProps.depth > 0 const worktreeSlug = createMemo(() => { - if (rowProps.isChild) return "root" - return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId) + if (isChild()) return "root" + return getWorktreeSlugForParentSession(props.instanceId, sessionId()) }) const showWorktreeBadge = createMemo(() => { - if (rowProps.isChild) return false + if (isChild()) return false if (getGitRepoStatus(props.instanceId) === false) return false const slug = worktreeSlug() return Boolean(slug) && slug !== "root" }) - const isActive = () => props.activeSessionId === rowProps.sessionId - const title = () => session()?.title || t("sessionList.session.untitled") - const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) - const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId) + const isActive = () => props.activeSessionId === sessionId() + const title = () => rowProps.session.title || t("sessionList.session.untitled") + const status = () => getSessionStatus(props.instanceId, sessionId()) + const retry = () => getSessionRetry(props.instanceId, sessionId()) const statusLabel = () => { const retryState = retry() if (retryState) { @@ -503,20 +498,20 @@ const SessionList: Component = (props) => { return t("sessionList.status.idle") } } - const needsPermission = () => Boolean(session()?.pendingPermission) - const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) + const needsPermission = () => Boolean(rowProps.session.pendingPermission) + const needsQuestion = () => Boolean((rowProps.session as any)?.pendingQuestion) const needsInput = () => needsPermission() || needsQuestion() const statusClassName = () => { if (needsInput()) return "session-permission" const base = `session-${retry() ? "retrying" : status()}` - const fadeClass = getSessionIdleFadeClass(props.instanceId, rowProps.sessionId) + const fadeClass = getSessionIdleFadeClass(props.instanceId, sessionId()) return fadeClass ? `${base} ${fadeClass}` : base } const showStatus = () => needsInput() || shouldShowSessionStatus( props.instanceId, - rowProps.sessionId, + sessionId(), now(), preferences().keepUnseenSubagentIdleStatus, ) @@ -535,14 +530,10 @@ const SessionList: Component = (props) => { }) } - const isSelected = () => selectedSessionIds().has(rowProps.sessionId) + const isSelected = () => selectedSessionIds().has(sessionId()) const parentGroupState = createMemo(() => { - if (rowProps.isChild) { - return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] } - } - - const ids = getSelectableThreadIds(rowProps.sessionId) + const ids = rowProps.hasChildren ? getSelectableThreadIds(sessionId()) : [sessionId()] const selected = selectedSessionIds() const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0) return { @@ -558,12 +549,23 @@ const SessionList: Component = (props) => { rowCheckboxEl.indeterminate = parentGroupState().indeterminate }) + const nestedStyle = () => { + if (!isChild()) return undefined + const visualDepth = Math.min(rowProps.depth, 6) + const indent = 1.375 + visualDepth * 0.875 + return { + "--session-indent": `${indent}rem`, + "--session-connector-offset": `${indent - 0.875}rem`, + } + } + return (