From 83fb70f49d291a90e01fd76f4e1b96eb02ba5eb8 Mon Sep 17 00:00:00 2001 From: paul Date: Sat, 2 May 2026 16:37:29 -0700 Subject: [PATCH 1/9] fix(app): show all subagent sessions in sidebar, not just active path Previously child sessions (subagents) were only visible in the sidebar when the user navigated into them via childSessionOnPath. This meant subagent sessions appeared invisible until clicked in the main chat. Replace childSessionOnPath (single child on nav path) with childSessions (all direct children sorted newest-first). Sidebar now renders all children immediately under their parent session. --- packages/app/src/pages/layout/helpers.test.ts | 40 +++++++++++++++++++ packages/app/src/pages/layout/helpers.ts | 5 +++ .../app/src/pages/layout/sidebar-items.tsx | 12 +++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 9cf302482b76..be1ed194e1d6 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -8,6 +8,7 @@ import { } from "./deep-links" import { type Session } from "@opencode-ai/sdk/v2/client" import { + childSessions, childSessionOnPath, displayName, effectiveWorkspaceOrder, @@ -222,4 +223,43 @@ describe("layout workspace helpers", () => { expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") expect(errorMessage("unknown", "fallback")).toBe("fallback") }) + + test("returns all direct children sorted newest first", () => { + const root = session({ id: "root", directory: "/workspace" }) + const child1 = session({ id: "child1", directory: "/workspace", parentID: "root", time: { created: 1, updated: 1, archived: undefined } }) + const child2 = session({ id: "child2", directory: "/workspace", parentID: "root", time: { created: 2, updated: 3, archived: undefined } }) + const child3 = session({ id: "child3", directory: "/workspace", parentID: "root", time: { created: 3, updated: 2, archived: undefined } }) + + const result = childSessions([root, child1, child2, child3], "root", 120_000) + + expect(result.map((s) => s.id)).toEqual(["child2", "child3", "child1"]) + }) + + test("excludes archived children", () => { + const root = session({ id: "root", directory: "/workspace" }) + const active = session({ id: "active", directory: "/workspace", parentID: "root", time: { created: 1, updated: 1, archived: undefined } }) + const archived = session({ id: "archived", directory: "/workspace", parentID: "root", time: { created: 2, updated: 2, archived: 1 } }) + + const result = childSessions([root, active, archived], "root", 120_000) + + expect(result.map((s) => s.id)).toEqual(["active"]) + }) + + test("returns empty array when no children", () => { + const root = session({ id: "root", directory: "/workspace" }) + + const result = childSessions([root], "root", 120_000) + + expect(result).toEqual([]) + }) + + test("excludes non-direct descendants", () => { + const root = session({ id: "root", directory: "/workspace" }) + const child = session({ id: "child", directory: "/workspace", parentID: "root" }) + const grandchild = session({ id: "grandchild", directory: "/workspace", parentID: "child" }) + + const result = childSessions([root, child, grandchild], "root", 120_000) + + expect(result.map((s) => s.id)).toEqual(["child"]) + }) }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index d53381e40462..8cab7155e88f 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -52,6 +52,11 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri } } +export const childSessions = (sessions: Session[] | undefined, rootID: string, now: number) => + (sessions ?? []) + .filter((s) => s.parentID === rootID && !s.time?.archived) + .sort(sortSessions(now)) + export const displayName = (project: { name?: string; worktree: string }) => project.name || getFilename(project.worktree) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 296f035ce276..8e9ea8f55964 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -15,7 +15,7 @@ import { usePermission } from "@/context/permission" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/composer/session-request-tree" -import { childSessionOnPath, hasProjectPermissions } from "./helpers" +import { childSessions, hasProjectPermissions } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -172,9 +172,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) - const currentChild = createMemo(() => { - if (!props.showChild) return - return childSessionOnPath(sessionStore.session, props.session.id, params.id) + const children = createMemo(() => { + if (!props.showChild) return [] + return childSessions(sessionStore.session, props.session.id, Date.now()) }) const warm = (span: number, priority: "high" | "low") => { @@ -268,13 +268,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { - + {(child) => (
)} -
+ ) } From b9249feb573519e3d1bd886fbae578cd8f7667c2 Mon Sep 17 00:00:00 2001 From: paul Date: Sat, 2 May 2026 22:04:07 -0700 Subject: [PATCH 2/9] feat(app): collapsible subagent sessions with hover-reveal chevron Subagent sessions now render collapsed by default under their parent. A chevron toggle appears on hover (Notion-style) to expand/collapse. Only the 3 most recent children are shown initially, with a "Load more" button for the rest. Uses the existing Collapsible component and Icon ("chevron-right" / "chevron-down") already in the UI library. --- .../app/src/pages/layout/sidebar-items.tsx | 54 ++++++++++++++++--- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 8e9ea8f55964..3fe877c0b1d8 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -1,12 +1,14 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { Avatar } from "@opencode-ai/ui/avatar" +import { Button } from "@opencode-ai/ui/button" +import { Collapsible } from "@opencode-ai/ui/collapsible" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/core/util/path" import { A, useParams } from "@solidjs/router" -import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" +import { type Accessor, createMemo, createSignal, For, type JSX, Match, Show, Switch } from "solid-js" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" @@ -176,6 +178,11 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { if (!props.showChild) return [] return childSessions(sessionStore.session, props.session.id, Date.now()) }) + const hasChildren = createMemo(() => children().length > 0) + const [expanded, setExpanded] = createSignal(false) + const [fullyExpanded, setFullyExpanded] = createSignal(false) + const visible = createMemo(() => children().slice(0, 3)) + const hasMore = createMemo(() => children().length > 3 && !fullyExpanded()) const warm = (span: number, priority: "high" | "low") => { const nav = props.navList?.() @@ -223,6 +230,20 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} >
+ + setExpanded((v) => !v)} + /> +
{
- - {(child) => ( -
- -
- )} -
+ + + + + {(child) => ( +
+ +
+ )} +
+ +
+ +
+
+
+
+
) } From 6e45c0322d41f2cd3ee538ce57c7425e82532b45 Mon Sep 17 00:00:00 2001 From: paul Date: Sun, 3 May 2026 06:36:52 -0700 Subject: [PATCH 3/9] fix(app): reserve chevron layout slot so session title never shifts The chevron slot is always sized size-4 in the flex layout. Without children it's invisible (opacity-0), on hover it reveals (group-hover/session:opacity-100). The title never moves. --- .../app/src/pages/layout/sidebar-items.tsx | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 3fe877c0b1d8..9ea07a43804d 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -230,20 +230,24 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} >
- - setExpanded((v) => !v)} - /> - +
+ + setExpanded((v) => !v)} + /> + +
Date: Sun, 3 May 2026 07:04:08 -0700 Subject: [PATCH 4/9] fix(app): use same size-6 as status dot for chevron slot --- .../app/src/pages/layout/sidebar-items.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 9ea07a43804d..b026e476fbe9 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -230,24 +230,21 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} >
-
- + +
setExpanded((v) => !v)} /> - -
+
+
Date: Sun, 3 May 2026 07:30:28 -0700 Subject: [PATCH 5/9] fix(app): move size-6 slot outside Show so it always reserves layout space --- packages/app/src/pages/layout/sidebar-items.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index b026e476fbe9..49f1cfaded40 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -230,8 +230,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} >
- -
+
+ { aria-label={expanded() ? language.t("session.todo.collapse") : language.t("session.todo.expand")} onClick={() => setExpanded((v) => !v)} /> -
- + +
Date: Sun, 3 May 2026 07:46:54 -0700 Subject: [PATCH 6/9] fix(app): reuse existing size-6 spinner slot for chevron, no extra space --- .../app/src/pages/layout/sidebar-items.tsx | 82 +++++++++++-------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 49f1cfaded40..75f3a872cf0b 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -97,6 +97,9 @@ const SessionRow = (props: { hasPermissions: Accessor hasError: Accessor unseenCount: Accessor + hasChildren: Accessor + expanded: Accessor + onToggleChildren: () => void clearHoverProjectSoon: () => void sidebarOpened: Accessor warmPress: () => void @@ -115,27 +118,48 @@ const SessionRow = (props: { props.clearHoverProjectSoon() }} > - 0}> -
+ 0}> + + + + + +
+ + +
+ + 0}> +
+ + + + } > - - - - - -
- - -
- - 0}> -
- - -
- + { + event.preventDefault() + event.stopPropagation() + props.onToggleChildren() + }} + /> + +
{title()} ) @@ -215,6 +239,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { hasPermissions={hasPermissions} hasError={hasError} unseenCount={unseenCount} + hasChildren={hasChildren} + expanded={expanded} + onToggleChildren={() => setExpanded((v) => !v)} clearHoverProjectSoon={props.clearHoverProjectSoon} sidebarOpened={layout.sidebar.opened} warmPress={() => warm(2, "high")} @@ -230,21 +257,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }} >
-
- - setExpanded((v) => !v)} - /> - -
Date: Sun, 3 May 2026 08:36:13 -0700 Subject: [PATCH 7/9] =?UTF-8?q?fix(app):=20spinner=20stays=20visible,=20ch?= =?UTF-8?q?evron=20fades=20in=20on=20hover=20=E2=80=94=20both=20occupy=20s?= =?UTF-8?q?ame=20slot=20via=20absolute=20positioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/pages/layout/sidebar-items.tsx | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 75f3a872cf0b..bad7549bea73 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -119,35 +119,38 @@ const SessionRow = (props: { }} >
- 0}> - - - - - -
- - -
- - 0}> -
- - - - } - > + 0}> +
+ + + + + +
+ + +
+ + 0}> +
+ + +
+ + Date: Sun, 3 May 2026 09:03:32 -0700 Subject: [PATCH 8/9] fix(app): hide spinner when chevron is active on expanded subagent row --- packages/app/src/pages/layout/sidebar-items.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index bad7549bea73..2c594ae8ad6e 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -127,6 +127,7 @@ const SessionRow = (props: { class="absolute inset-0 flex items-center justify-center transition-opacity" classList={{ "group-hover/session:opacity-0": props.hasChildren(), + "opacity-0": props.expanded(), }} > From be9beff97d10d92cbcc1b5653417f652c0a58f2e Mon Sep 17 00:00:00 2001 From: paul Date: Sun, 3 May 2026 17:54:12 -0700 Subject: [PATCH 9/9] feat(server): SSE replay buffer with Last-Event-ID support on /global/event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-running tasks (e.g. an agent emitting hundreds of events) silently lose UI updates when the SSE stream drops — proxy idle, browser tab backgrounded, network blip. The agents keep working server-side but the client never catches up because reconnects subscribe fresh with no replay. Add a module-level ring buffer (1024 events) that: * assigns a monotonic id to every event published on GlobalBus * stores the last N events for replay * fans events out to all active SSE connections On reconnect, the client's `Last-Event-ID` header (sent automatically by EventSource, or settable via fetch) is honored — everything since that id is replayed before live events resume. Events queued during the replay window are deduped against `lastSentId` to prevent doubles. Heartbeats and the synthetic `server.connected` greeting are intentionally sent without ids: they're per-connection signals, not part of the recoverable event stream. The same pattern can be applied to /event (instance) in a follow-up; that endpoint subscribes to the per-instance Bus and would need a per-instance ring buffer. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/opencode/src/server/routes/global.ts | 70 +++++++++++++------ packages/opencode/src/server/sse-replay.ts | 63 +++++++++++++++++ 2 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/src/server/sse-replay.ts diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index f40a58453629..ae58a7757c7c 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -15,35 +15,52 @@ import * as Log from "@opencode-ai/core/util/log" import { lazy } from "../../util/lazy" import { Config } from "@/config/config" import { errors } from "../error" +import { SSEReplayBuffer, parseLastEventId, type StoredEvent } from "../sse-replay" const log = Log.create({ service: "server" }) export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({})) -async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { +// Module-level ring buffer of recent global events. Every event published on +// GlobalBus gets a monotonic id, gets stored here, and gets fanned out to all +// active /global/event SSE connections. Clients reconnecting with a +// `Last-Event-ID` header replay everything that happened during the gap. +const globalReplay = new SSEReplayBuffer() +GlobalBus.on("event", (event: any) => { + globalReplay.publish(JSON.stringify(event)) +}) + +type QueueItem = { id?: number; data: string } + +async function streamEvents(c: Context, replay: SSEReplayBuffer) { return streamSSE(c, async (stream) => { - const q = new AsyncQueue() + const q = new AsyncQueue() let done = false + let lastSentId = 0 + + // Subscribe BEFORE replay so live events arriving during the replay window + // also land in the queue. Dedupe on id below. + const unsub = replay.subscribe((entry: StoredEvent) => q.push(entry)) - q.push( - JSON.stringify({ + q.push({ + data: JSON.stringify({ payload: { type: "server.connected", properties: {}, }, }), - ) + }) // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ + q.push({ + data: JSON.stringify({ payload: { type: "server.heartbeat", properties: {}, }, }), - ) + }) }, 10_000) const stop = () => { @@ -55,14 +72,33 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue log.info("global event disconnected") } - const unsub = subscribe(q) - stream.onAbort(stop) try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) + // Replay events the client missed during a disconnect, if any. + const lastEventId = parseLastEventId(c.req.header("Last-Event-ID")) + if (lastEventId > 0) { + const missed = replay.eventsAfter(lastEventId) + if (missed.length > 0) { + log.info("global event replay", { lastEventId, replayed: missed.length }) + } + for (const ev of missed) { + await stream.writeSSE({ id: String(ev.id), data: ev.data }) + lastSentId = ev.id + } + } + + for await (const item of q) { + if (item === null) return + if (item.id != null) { + // Dedupe: events that arrived during the replay window may also be + // queued via the live subscription. + if (item.id <= lastSentId) continue + lastSentId = item.id + await stream.writeSSE({ id: String(item.id), data: item.data }) + } else { + await stream.writeSSE({ data: item.data }) + } } } finally { stop() @@ -127,13 +163,7 @@ export const GlobalRoutes = lazy(() => c.header("X-Accel-Buffering", "no") c.header("X-Content-Type-Options", "nosniff") - return streamEvents(c, (q) => { - async function handler(event: any) { - q.push(JSON.stringify(event)) - } - GlobalBus.on("event", handler) - return () => GlobalBus.off("event", handler) - }) + return streamEvents(c, globalReplay) }, ) .get( diff --git a/packages/opencode/src/server/sse-replay.ts b/packages/opencode/src/server/sse-replay.ts new file mode 100644 index 000000000000..1823a176017e --- /dev/null +++ b/packages/opencode/src/server/sse-replay.ts @@ -0,0 +1,63 @@ +// Ring buffer of recent SSE events for client reconnect replay. +// +// When a long-lived SSE stream drops (proxy idle, browser tab backgrounded, +// network blip), the client may reconnect with a `Last-Event-ID` header. This +// buffer lets the server replay everything after that ID so the UI catches up +// instead of going stale while the agents keep running on the server. + +const DEFAULT_RING_SIZE = 1024 + +export type StoredEvent = { id: number; data: string } + +export class SSEReplayBuffer { + private ring: StoredEvent[] = [] + private nextId = 0 + private listeners = new Set<(entry: StoredEvent) => void>() + + constructor(private maxSize: number = DEFAULT_RING_SIZE) {} + + /** + * Assign a monotonic ID to `data`, store it in the ring, and notify all + * live subscribers. Returns the stored entry (caller can read its id). + */ + publish(data: string): StoredEvent { + const entry: StoredEvent = { id: ++this.nextId, data } + this.ring.push(entry) + if (this.ring.length > this.maxSize) this.ring.shift() + for (const fn of this.listeners) fn(entry) + return entry + } + + /** + * Subscribe to live events. Returns an unsubscribe function. + */ + subscribe(fn: (entry: StoredEvent) => void): () => void { + this.listeners.add(fn) + return () => { + this.listeners.delete(fn) + } + } + + /** + * Snapshot of buffered events with id strictly greater than `lastEventId`. + * Returns [] if the client is up to date or had no prior connection. + * If the client was disconnected long enough that its last-seen id is older + * than what we still have in the ring, the returned slice will start from + * the oldest available — the client may detect a gap by comparing + * `lastEventId + 1` to the first id received. + */ + eventsAfter(lastEventId: number): StoredEvent[] { + if (lastEventId <= 0) return [] + return this.ring.filter((e) => e.id > lastEventId) + } +} + +/** + * Parse a `Last-Event-ID` request header into a numeric id. Returns 0 for + * missing/invalid values (caller should treat 0 as "no replay"). + */ +export function parseLastEventId(header: string | null | undefined): number { + if (!header) return 0 + const n = parseInt(header, 10) + return Number.isFinite(n) && n > 0 ? n : 0 +}