From 07982872b3e8663182c9f3987e2147f60ab6bd5f Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 21:15:55 -0600 Subject: [PATCH 01/10] showing hierarchy, have navigation up and down --- packages/app/src/context/global-sync.test.ts | 11 +- .../src/context/global-sync/session-load.ts | 4 +- packages/app/src/context/global-sync/types.ts | 2 +- packages/app/src/pages/layout/helpers.ts | 4 + .../app/src/pages/layout/sidebar-items.tsx | 174 ++++++++++++------ .../app/src/pages/layout/sidebar-project.tsx | 15 +- .../src/pages/layout/sidebar-workspace.tsx | 4 + .../src/pages/session/message-timeline.tsx | 15 ++ 8 files changed, 163 insertions(+), 66 deletions(-) diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts index 396b412318b..082ff689c4b 100644 --- a/packages/app/src/context/global-sync.test.ts +++ b/packages/app/src/context/global-sync.test.ts @@ -29,7 +29,7 @@ describe("pickDirectoriesToEvict", () => { describe("loadRootSessionsWithFallback", () => { test("uses limited roots query when supported", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + const calls: Array<{ directory: string; roots?: boolean; limit?: number }> = [] let fallback = 0 const result = await loadRootSessionsWithFallback({ @@ -46,12 +46,12 @@ describe("loadRootSessionsWithFallback", () => { expect(result.data).toEqual([]) expect(result.limited).toBe(true) - expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + expect(calls).toEqual([{ directory: "dir", limit: 10 }]) expect(fallback).toBe(0) }) test("falls back to full roots query on limited-query failure", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + const calls: Array<{ directory: string; roots?: boolean; limit?: number }> = [] let fallback = 0 const result = await loadRootSessionsWithFallback({ @@ -69,10 +69,7 @@ describe("loadRootSessionsWithFallback", () => { expect(result.data).toEqual([]) expect(result.limited).toBe(false) - expect(calls).toEqual([ - { directory: "dir", roots: true, limit: 25 }, - { directory: "dir", roots: true }, - ]) + expect(calls).toEqual([{ directory: "dir", limit: 25 }, { directory: "dir" }]) expect(fallback).toBe(1) }) }) diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts index 443aa845020..438a9e8c996 100644 --- a/packages/app/src/context/global-sync/session-load.ts +++ b/packages/app/src/context/global-sync/session-load.ts @@ -2,7 +2,7 @@ import type { RootLoadArgs } from "./types" export async function loadRootSessionsWithFallback(input: RootLoadArgs) { try { - const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) + const result = await input.list({ directory: input.directory, limit: input.limit }) return { data: result.data, limit: input.limit, @@ -10,7 +10,7 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) { } as const } catch { input.onFallback() - const result = await input.list({ directory: input.directory, roots: true }) + const result = await input.list({ directory: input.directory }) return { data: result.data, limit: input.limit, diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index ade0b973a2a..ae4ef30de3e 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -118,7 +118,7 @@ export type DisposeCheck = { export type RootLoadArgs = { directory: string limit: number - list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> + list: (query: { directory: string; roots?: boolean; limit?: number }) => Promise<{ data?: Session[] }> onFallback: () => void } diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 6a1e7c0123d..1e439802bfb 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -42,6 +42,10 @@ export const childMapByParent = (sessions: Session[]) => { return map } +export const getChildSessions = (sessions: Session[], parentID: string): Session[] => { + return sessions.filter((s) => s.parentID === parentID).sort(sortSessions(Date.now())) +} + export function getDraggableId(event: unknown): string | undefined { if (typeof event !== "object" || event === null) return undefined if (!("draggable" in event)) return undefined diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index d5509037075..cf8ef947242 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -5,6 +5,7 @@ import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout" import { useNotification } from "@/context/notification" import { base64Encode } from "@opencode-ai/util/encode" import { Avatar } from "@opencode-ai/ui/avatar" +import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" @@ -14,8 +15,9 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/util/path" import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client" -import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" +import { For, Match, Show, Switch, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js" import { agentColor } from "@/utils/agent" +import { getChildSessions } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -60,6 +62,7 @@ export type SessionItemProps = { dense?: boolean popover?: boolean children: Map + allSessions: Session[] sidebarExpanded: Accessor sidebarHovering: Accessor nav: Accessor @@ -102,25 +105,27 @@ const SessionRow = (props: { }} >
-
- }> - - - - -
- - -
- - 0}> -
- - -
+ 0}> +
+ + + + + +
+ + +
+ + 0}> +
+ + +
+ {props.session.title} @@ -187,9 +192,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const language = useLanguage() const notification = useNotification() const globalSync = useGlobalSync() + const [expanded, setExpanded] = createSignal(false) const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const [sessionStore] = globalSync.child(props.session.directory) + const childSessions = createMemo(() => getChildSessions(props.allSessions, props.session.id)) + const hasChildren = createMemo(() => childSessions().length > 0) const hasPermissions = createMemo(() => { const permissions = sessionStore.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true @@ -251,6 +259,20 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) return text?.text } + + const ExpandButton = () => ( + + ) + const item = ( { return (
- - {item} - - } - > - { - if (!isActive()) { - layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) - navigate(`${props.slug}/session/${props.session.id}`) - return - } - window.history.replaceState(null, "", `#message-${message.id}`) - window.dispatchEvent(new HashChangeEvent("hashchange")) - }} - trigger={item} - /> - + +
+ + + + + + +
+ +
+ + {item} + + } + > + { + if (!isActive()) { + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) + navigate(`${props.slug}/session/${props.session.id}`) + return + } + window.history.replaceState(null, "", `#message-${message.id}`) + window.dispatchEvent(new HashChangeEvent("hashchange")) + }} + trigger={item} + /> + +
+
+ +
+ + {(child) => ( + + )} + +
+
+
@@ -32,7 +33,7 @@ export type ProjectSidebarContext = { workspacesEnabled: (project: LocalProject) => boolean workspaceIds: (project: LocalProject) => string[] workspaceLabel: (directory: string, branch?: string, projectId?: string) => string - sessionProps: Omit + sessionProps: Omit setHoverSession: (id: string | undefined) => void } @@ -169,8 +170,10 @@ const ProjectPreviewPanel = (props: { workspaces: Accessor label: (directory: string) => string projectSessions: Accessor> + projectAllSessions: Accessor projectChildren: Accessor> workspaceSessions: (directory: string) => ReturnType + workspaceAllSessions: (directory: string) => Session[] workspaceChildren: (directory: string) => Map setOpen: (value: boolean) => void ctx: ProjectSidebarContext @@ -210,6 +213,7 @@ const ProjectPreviewPanel = (props: { mobile={props.mobile} popover={false} children={props.projectChildren()} + allSessions={props.projectAllSessions()} /> )} @@ -218,6 +222,7 @@ const ProjectPreviewPanel = (props: { {(directory) => { const sessions = createMemo(() => props.workspaceSessions(directory)) + const allSessions = createMemo(() => props.workspaceAllSessions(directory)) const children = createMemo(() => props.workspaceChildren(directory)) return (
@@ -237,6 +242,7 @@ const ProjectPreviewPanel = (props: { mobile={props.mobile} popover={false} children={children()} + allSessions={allSessions()} /> )} @@ -310,11 +316,16 @@ export const SortableProject = (props: { const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2)) + const projectAllSessions = createMemo(() => projectStore().session) const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) return sortedRootSessions(data, props.sortNow()).slice(0, 2) } + const workspaceAllSessions = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return data.session + } const workspaceChildren = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) return childMapByParent(data.session) @@ -368,8 +379,10 @@ export const SortableProject = (props: { workspaces={workspaces} label={label} projectSessions={projectSessions} + projectAllSessions={projectAllSessions} projectChildren={projectChildren} workspaceSessions={workspaceSessions} + workspaceAllSessions={workspaceAllSessions} workspaceChildren={workspaceChildren} setOpen={setOpen} ctx={props.ctx} diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 43d99cf8954..32bd6d536ec 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -244,6 +244,7 @@ const WorkspaceSessionList = (props: { showNew: Accessor loading: Accessor sessions: Accessor + allSessions: Accessor children: Accessor> hasMore: Accessor loadMore: () => Promise @@ -269,6 +270,7 @@ const WorkspaceSessionList = (props: { slug={props.slug()} mobile={props.mobile} children={props.children()} + allSessions={props.allSessions()} sidebarExpanded={props.ctx.sidebarExpanded} sidebarHovering={props.ctx.sidebarHovering} nav={props.ctx.nav} @@ -451,6 +453,7 @@ export const SortableWorkspace = (props: { showNew={showNew} loading={loading} sessions={sessions} + allSessions={() => workspaceStore.session} children={children} hasMore={hasMore} loadMore={loadMore} @@ -501,6 +504,7 @@ export const LocalWorkspace = (props: { slug={slug()} mobile={props.mobile} children={children()} + allSessions={workspace().store.session} sidebarExpanded={props.ctx.sidebarExpanded} sidebarHovering={props.ctx.sidebarHovering} nav={props.ctx.nav} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index a8d22ccc84d..361261244f2 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -219,6 +219,21 @@ export function MessageTimeline(props: { {(id) => (
+ + + + + + + + Date: Mon, 16 Feb 2026 21:29:30 -0600 Subject: [PATCH 02/10] fix layout --- packages/app/src/pages/layout/sidebar-items.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index cf8ef947242..57e28f9681a 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -92,7 +92,7 @@ const SessionRow = (props: { }): JSX.Element => ( { return (
From e68067c7f77746c533df20c436435d2edd126641 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 21:44:51 -0600 Subject: [PATCH 03/10] prevent duplicate subagents on load more --- packages/app/src/context/global-sync.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 9733b72afbd..7b8a7a60c36 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -218,7 +218,14 @@ function createGlobalSync() { .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const limit = store.limit const childSessions = store.session.filter((s) => !!s.parentID) - const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission }) + const seen = new Set() + const deduplicated = [...nonArchived, ...childSessions].filter((s) => { + if (!s.id) return false + if (seen.has(s.id)) return false + seen.add(s.id) + return true + }) + const sessions = trimSessions(deduplicated, { limit, permission: store.permission }) setStore( "sessionTotal", estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), From bd7efef0168297b230c522516afd645cc47a7608 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 21:59:43 -0600 Subject: [PATCH 04/10] fixed styling to show background only selected row --- packages/app/src/pages/layout/sidebar-items.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 57e28f9681a..40d533d56ca 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -92,7 +92,7 @@ const SessionRow = (props: { }): JSX.Element => ( { return (
-
+
From 3ecad6788e969fc6091c10cb00027d43b7bfa738 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Mon, 16 Feb 2026 23:42:59 -0600 Subject: [PATCH 05/10] added e2e tests for sidebar navigation with subagents --- packages/app/e2e/sidebar/sidebar.spec.ts | 173 ++++++++++++++++++ .../src/pages/session/message-timeline.tsx | 3 +- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts index 5c78c2220d2..585fcd29ea8 100644 --- a/packages/app/e2e/sidebar/sidebar.spec.ts +++ b/packages/app/e2e/sidebar/sidebar.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "../fixtures" import { openSidebar, toggleSidebar, withSession } from "../actions" +import { sessionItemSelector } from "../selectors" test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { await gotoSession() @@ -35,3 +36,175 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p }) }) }) + +test("session without subagents has no chevron", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "no subagents session", async (session) => { + await gotoSession(session.id) + await openSidebar(page) + + const sessionItem = page.locator(sessionItemSelector(session.id)) + await expect(sessionItem).toBeVisible() + + const chevron = sessionItem.locator('[data-slot="collapsible-trigger"]') + await expect(chevron).not.toBeVisible() + }) +}) + +test("session with subagents shows chevron", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent session", async (parent) => { + const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data) + if (!child?.id) throw new Error("Failed to create child session") + + try { + await gotoSession(parent.id) + await openSidebar(page) + + const sessionItem = page.locator(sessionItemSelector(parent.id)) + await expect(sessionItem).toBeVisible() + + const chevron = sessionItem.locator('[data-slot="collapsible-trigger"]') + await expect(chevron).toBeVisible() + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) + +test("subagents are hidden until parent session is expanded", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent session", async (parent) => { + const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data) + if (!child?.id) throw new Error("Failed to create child session") + + try { + await gotoSession(parent.id) + await openSidebar(page) + + const parentItem = page.locator(sessionItemSelector(parent.id)) + const childItem = page.locator(sessionItemSelector(child.id)) + const chevron = parentItem.locator('[data-slot="collapsible-trigger"]') + + await expect(parentItem).toBeVisible() + await expect(childItem).not.toBeVisible() + + await chevron.click() + await expect(childItem).toBeVisible() + + await chevron.click() + await expect(childItem).not.toBeVisible() + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) + +test("root session has no back arrow or subagent indicator", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "root session", async (session) => { + await gotoSession(session.id) + + const backButton = page.getByTestId("navigate-parent-button") + const subagentIcon = page.getByTestId("subagent-indicator") + + await expect(backButton).not.toBeVisible() + await expect(subagentIcon).not.toBeVisible() + }) +}) + +async function seedMessage(sdk: Parameters[0], sessionID: string) { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: "e2e seed" }], + }) + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0) +} + +test("subagent session shows back arrow and subagent indicator, and navigates to parent", async ({ + page, + sdk, + gotoSession, +}) => { + await withSession(sdk, "parent session", async (parent) => { + const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data) + if (!child?.id) throw new Error("Failed to create child session") + + try { + await seedMessage(sdk, child.id) + await gotoSession(child.id) + + await expect(page.getByTestId("navigate-parent-button")).toBeVisible() + await expect(page.getByTestId("subagent-indicator")).toBeVisible() + + await page.getByTestId("navigate-parent-button").click() + await expect(page).toHaveURL(new RegExp(`/session/${parent.id}`)) + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) + +test("subagent session selection shows background only on selected session", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent session", async (parent) => { + const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data) + if (!child?.id) throw new Error("Failed to create child session") + + try { + await seedMessage(sdk, child.id) + await gotoSession(child.id) + await openSidebar(page) + + const parentItem = page.locator(sessionItemSelector(parent.id)) + const childItem = page.locator(sessionItemSelector(child.id)) + const chevron = parentItem.locator('[data-slot="collapsible-trigger"]') + + await chevron.click() + await expect(childItem).toBeVisible() + + const parentRow = parentItem.locator("div.flex.items-center.w-full").first() + const childRow = childItem.locator("div.flex.items-center.w-full").first() + + await expect(childRow).toHaveClass(/bg-surface-base-active/) + await expect(parentRow).not.toHaveClass(/bg-surface-base-active/) + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) + +test("navigating to parent session shows background only on parent", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent session", async (parent) => { + const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data) + if (!child?.id) throw new Error("Failed to create child session") + + try { + await seedMessage(sdk, child.id) + await gotoSession(child.id) + await openSidebar(page) + + const parentItem = page.locator(sessionItemSelector(parent.id)) + const childItem = page.locator(sessionItemSelector(child.id)) + const chevron = parentItem.locator('[data-slot="collapsible-trigger"]') + + await chevron.click() + await expect(childItem).toBeVisible() + + await page.getByTestId("navigate-parent-button").click() + await expect(page).toHaveURL(new RegExp(`/session/${parent.id}`)) + + const parentRow = parentItem.locator("div.flex.items-center.w-full").first() + const childRow = childItem.locator("div.flex.items-center.w-full").first() + + await expect(parentRow).toHaveClass(/bg-surface-base-active/) + await expect(childRow).not.toHaveClass(/bg-surface-base-active/) + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 361261244f2..cbc3c2f49c3 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -228,10 +228,11 @@ export function MessageTimeline(props: { onClick={props.onNavigateParent} aria-label="Navigate to parent" class="size-5" + data-testid="navigate-parent-button" /> - + From f8ee806fb478c0d89561930b2d2565ee5b449724 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Tue, 17 Feb 2026 00:12:09 -0600 Subject: [PATCH 06/10] persist state expansion state --- .../app/src/pages/layout/sidebar-items.tsx | 17 ++- .../src/pages/layout/sidebar-workspace.tsx | 15 ++- .../src/pages/layout/use-expanded-sessions.ts | 100 ++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/pages/layout/use-expanded-sessions.ts diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 40d533d56ca..f11259d0145 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -71,6 +71,8 @@ export type SessionItemProps = { clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise + isSessionExpanded?: (sessionId: string) => boolean + toggleSessionExpanded?: (sessionId: string) => void } const SessionRow = (props: { @@ -192,7 +194,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const language = useLanguage() const notification = useNotification() const globalSync = useGlobalSync() - const [expanded, setExpanded] = createSignal(false) + const isSessionExpanded = props.isSessionExpanded ?? (() => false) + const toggleSessionExpanded = props.toggleSessionExpanded ?? (() => {}) + const expanded = createMemo(() => isSessionExpanded(props.session.id)) const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const [sessionStore] = globalSync.child(props.session.directory) @@ -266,7 +270,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { onClick={(e) => { e.preventDefault() e.stopPropagation() - setExpanded((v) => !v) + toggleSessionExpanded(props.session.id) }} > @@ -298,7 +302,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { data-session-id={props.session.id} class="group/session relative w-full rounded-md cursor-default pl-2 pr-0 group-hover/session:pr-0 [&:has(:focus-visible)]:bg-surface-raised-base-hover" > - + toggleSessionExpanded(props.session.id)} + class="w-full" + variant="ghost" + >
{ clearHoverProjectSoon={props.clearHoverProjectSoon} prefetchSession={props.prefetchSession} archiveSession={props.archiveSession} + isSessionExpanded={props.isSessionExpanded} + toggleSessionExpanded={props.toggleSessionExpanded} /> )} diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 32bd6d536ec..26c2d5b7d0f 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -18,6 +18,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { childMapByParent, sortedRootSessions } from "./helpers" +import { useExpandedSessions } from "./use-expanded-sessions" type InlineEditorComponent = (props: { id: string @@ -249,6 +250,8 @@ const WorkspaceSessionList = (props: { hasMore: Accessor loadMore: () => Promise language: ReturnType + isSessionExpanded: (sessionId: string) => boolean + toggleSessionExpanded: (sessionId: string) => void }): JSX.Element => (
-
+
{(child) => ( { mobile={props.mobile} dense={props.dense} popover={props.popover} + depth={depth + 1} children={props.children} allSessions={props.allSessions} sidebarExpanded={props.sidebarExpanded} diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 26c2d5b7d0f..90c6fe90c73 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -324,8 +324,10 @@ export const SortableWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) - const allSessions = createMemo(() => Object.values(workspaceStore.session).flat()) - const expandedSessions = useExpandedSessions(() => props.directory, allSessions) + const expandedSessions = useExpandedSessions( + () => props.directory, + () => workspaceStore.session, + ) const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => props.ctx.currentDir() === props.directory) @@ -460,13 +462,13 @@ export const SortableWorkspace = (props: { showNew={showNew} loading={loading} sessions={sessions} - allSessions={allSessions} + allSessions={() => workspaceStore.session} children={children} hasMore={hasMore} loadMore={loadMore} language={language} - isSessionExpanded={expandedSessions.isExpanded} - toggleSessionExpanded={expandedSessions.toggleExpanded} + isSessionExpanded={expandedSessions.expanded} + toggleSessionExpanded={expandedSessions.toggle} /> @@ -488,8 +490,10 @@ export const LocalWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) - const allSessions = createMemo(() => Object.values(workspace().store.session).flat()) - const expandedSessions = useExpandedSessions(() => props.project.worktree, allSessions) + const expandedSessions = useExpandedSessions( + () => props.project.worktree, + () => workspace().store.session, + ) const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const loading = createMemo(() => !booted() && sessions().length === 0) @@ -524,8 +528,8 @@ export const LocalWorkspace = (props: { clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} prefetchSession={props.ctx.prefetchSession} archiveSession={props.ctx.archiveSession} - isSessionExpanded={expandedSessions.isExpanded} - toggleSessionExpanded={expandedSessions.toggleExpanded} + isSessionExpanded={expandedSessions.expanded} + toggleSessionExpanded={expandedSessions.toggle} /> )} diff --git a/packages/app/src/pages/layout/use-expanded-sessions.test.ts b/packages/app/src/pages/layout/use-expanded-sessions.test.ts new file mode 100644 index 00000000000..875484e61cf --- /dev/null +++ b/packages/app/src/pages/layout/use-expanded-sessions.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createStore } from "solid-js/store" +import type { Session } from "@opencode-ai/sdk/v2/client" + +describe("useExpandedSessions", () => { + describe("expanded state management", () => { + test("returns false for non-expanded sessions by default", () => { + createRoot((dispose) => { + const expandedSet = new Set() + const local = createSignal([]) + + const expanded = (sessionId: string) => { + return expandedSet.has(sessionId) + } + + expect(expanded("session-1")).toBe(false) + expect(expanded("session-2")).toBe(false) + dispose() + }) + }) + + test("toggle changes expansion state", () => { + createRoot((dispose) => { + const expandedSet = new Set() + + const expanded = (sessionId: string) => expandedSet.has(sessionId) + const toggle = (sessionId: string) => { + if (expandedSet.has(sessionId)) { + expandedSet.delete(sessionId) + } else { + expandedSet.add(sessionId) + } + } + + expect(expanded("session-1")).toBe(false) + toggle("session-1") + expect(expanded("session-1")).toBe(true) + toggle("session-1") + expect(expanded("session-1")).toBe(false) + dispose() + }) + }) + + test("multiple sessions can be expanded independently", () => { + createRoot((dispose) => { + const expandedSet = new Set() + + const expanded = (sessionId: string) => expandedSet.has(sessionId) + const toggle = (sessionId: string) => { + if (expandedSet.has(sessionId)) { + expandedSet.delete(sessionId) + } else { + expandedSet.add(sessionId) + } + } + + toggle("session-1") + toggle("session-2") + + expect(expanded("session-1")).toBe(true) + expect(expanded("session-2")).toBe(true) + expect(expanded("session-3")).toBe(false) + dispose() + }) + }) + }) + + describe("storage key generation", () => { + test("generates unique key per directory", () => { + const checksum = (str: string) => { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash + } + return Math.abs(hash).toString(16) + } + + const generateKey = (dir: string) => { + const sum = checksum(dir) ?? "0" + const head = dir.slice(0, 12) || "workspace" + return `workspace.${head}.${sum}.expanded-sessions` + } + + const key1 = generateKey("/test/workspace1") + const key2 = generateKey("/test/workspace2") + const key3 = generateKey("/different/path") + + expect(key1).not.toBe(key2) + expect(key2).not.toBe(key3) + expect(key1).not.toBe(key3) + }) + }) + + describe("local state fallback when not ready", () => { + test("uses local signal when ready is false", () => { + createRoot((dispose) => { + const [local, setLocal] = createSignal([]) + const ready = () => false + + const expanded = (sessionId: string) => { + if (!ready()) return local().includes(sessionId) + return false + } + + const toggle = (sessionId: string) => { + if (!ready()) { + const current = expanded(sessionId) + setLocal((prev) => (current ? prev.filter((id) => id !== sessionId) : [...prev, sessionId])) + return + } + } + + expect(expanded("session-1")).toBe(false) + toggle("session-1") + expect(expanded("session-1")).toBe(true) + toggle("session-1") + expect(expanded("session-1")).toBe(false) + dispose() + }) + }) + + test("uses store when ready is true", () => { + createRoot((dispose) => { + const [store, setStore] = createStore>({}) + const ready = () => true + const prefix = "workspace.test." + + const expanded = (sessionId: string) => { + if (!ready()) return false + return store[prefix + sessionId] ?? false + } + + const toggle = (sessionId: string) => { + const key = prefix + sessionId + const current = expanded(sessionId) + if (!ready()) return + setStore(key, !current) + } + + expect(expanded("session-1")).toBe(false) + toggle("session-1") + expect(expanded("session-1")).toBe(true) + toggle("session-1") + expect(expanded("session-1")).toBe(false) + dispose() + }) + }) + + test("local state is isolated from store state", () => { + createRoot((dispose) => { + const [local, setLocal] = createSignal([]) + const [store, setStore] = createStore>({}) + let isReady = false + const prefix = "workspace.test." + + const expanded = (sessionId: string) => { + if (!isReady) return local().includes(sessionId) + return store[prefix + sessionId] ?? false + } + + const toggle = (sessionId: string) => { + if (!isReady) { + const current = expanded(sessionId) + setLocal((prev) => (current ? prev.filter((id) => id !== sessionId) : [...prev, sessionId])) + return + } + const key = prefix + sessionId + setStore(key, !expanded(sessionId)) + } + + toggle("session-1") + expect(expanded("session-1")).toBe(true) + + isReady = true + expect(expanded("session-1")).toBe(false) + + toggle("session-1") + expect(expanded("session-1")).toBe(true) + dispose() + }) + }) + }) +}) diff --git a/packages/app/src/pages/layout/use-expanded-sessions.ts b/packages/app/src/pages/layout/use-expanded-sessions.ts index 53116c3fd6b..f4bcf80b13f 100644 --- a/packages/app/src/pages/layout/use-expanded-sessions.ts +++ b/packages/app/src/pages/layout/use-expanded-sessions.ts @@ -1,19 +1,16 @@ -import { createEffect, createMemo, createSignal } from "solid-js" -import { createStore, reconcile } from "solid-js/store" +import { createMemo, createSignal } from "solid-js" +import { createStore } from "solid-js/store" import { persisted } from "@/utils/persist" import { Persist } from "@/utils/persist" import { checksum } from "@opencode-ai/util/encode" import type { Session } from "@opencode-ai/sdk/v2/client" -const CLEANUP_DELAY = 1000 - export function useExpandedSessions( directory: () => string, sessions: () => Session[], ): { - isExpanded: (sessionId: string) => boolean - toggleExpanded: (sessionId: string) => void - ready: () => boolean + expanded: (sessionId: string) => boolean + toggle: (sessionId: string) => void } { const storageKey = createMemo(() => { const dir = directory() @@ -27,74 +24,26 @@ export function useExpandedSessions( createStore({} as Record), ) - const [localExpanded, setLocalExpanded] = createSignal([]) - let cleanupTimeout: ReturnType | undefined + const [local, setLocal] = createSignal([]) const prefix = () => storageKey() + "." - createEffect(() => { - const p = prefix() - const ids = sessions() - if (ids.length === 0) return - const validIds = new Set(ids.map((s) => s.id)) - const validExpanded: Record = {} - let hasStale = false - - for (const key of Object.keys(store)) { - if (!key.startsWith(p)) continue - const sessionId = key.slice(p.length) - if (validIds.has(sessionId)) { - validExpanded[key] = store[key] - } else { - hasStale = true - } - } - - if (hasStale) { - setStore(reconcile(validExpanded)) - } - }) - - const isExpanded = (sessionId: string) => { - if (!ready()) return localExpanded().includes(sessionId) + const expanded = (sessionId: string) => { + if (!ready()) return local().includes(sessionId) return store[prefix() + sessionId] ?? false } - const toggleExpanded = (sessionId: string) => { + const toggle = (sessionId: string) => { const key = prefix() + sessionId - const current = isExpanded(sessionId) + const current = expanded(sessionId) if (!ready()) { - setLocalExpanded((prev) => (current ? prev.filter((id) => id !== sessionId) : [...prev, sessionId])) + setLocal((prev) => (current ? prev.filter((id) => id !== sessionId) : [...prev, sessionId])) return } setStore(key, !current) - - if (cleanupTimeout) clearTimeout(cleanupTimeout) - cleanupTimeout = setTimeout(() => { - const p = prefix() - const ids = sessions() - if (ids.length === 0) return - const validIds = new Set(ids.map((s) => s.id)) - const validExpanded: Record = {} - let hasStale = false - - for (const key of Object.keys(store)) { - if (!key.startsWith(p)) continue - const sessionId = key.slice(p.length) - if (validIds.has(sessionId)) { - validExpanded[key] = store[key] - } else { - hasStale = true - } - } - - if (hasStale) { - setStore(reconcile(validExpanded)) - } - }, CLEANUP_DELAY) } - return { isExpanded, toggleExpanded, ready } + return { expanded, toggle } } From 9ba290d388113ddf6e637f7c96420c29f10ea4a8 Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Tue, 17 Feb 2026 16:45:04 -0600 Subject: [PATCH 09/10] fix after rebase --- packages/app/src/pages/session/message-timeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index cbc3c2f49c3..9f260b2a191 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -8,6 +8,7 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn" import type { UserMessage } from "@opencode-ai/sdk/v2" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" +import { Tooltip } from "@opencode-ai/ui/tooltip" const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined From 900e0e733eac99745bc75c61368b68f12d3aa66e Mon Sep 17 00:00:00 2001 From: Jeffrey Magder Date: Tue, 17 Feb 2026 17:34:43 -0600 Subject: [PATCH 10/10] ai code review feedback --- packages/app/src/context/global-sync.tsx | 7 +- packages/app/src/pages/layout/helpers.test.ts | 43 +++++++++ packages/app/src/pages/layout/helpers.ts | 14 +++ .../app/src/pages/layout/sidebar-items.tsx | 96 +++++++++---------- .../src/pages/layout/sidebar-workspace.tsx | 2 +- .../layout/use-expanded-sessions.test.ts | 65 ++++++++++++- .../src/pages/layout/use-expanded-sessions.ts | 25 ++++- 7 files changed, 193 insertions(+), 59 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 7b8a7a60c36..fc136633414 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -31,6 +31,7 @@ import { createRefreshQueue } from "./global-sync/queue" import { createChildStoreManager } from "./global-sync/child-store" import { trimSessions } from "./global-sync/session-trim" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { validateParentIDs } from "../pages/layout/helpers" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { sanitizeProject } from "./global-sync/utils" @@ -221,11 +222,15 @@ function createGlobalSync() { const seen = new Set() const deduplicated = [...nonArchived, ...childSessions].filter((s) => { if (!s.id) return false - if (seen.has(s.id)) return false + if (seen.has(s.id)) { + console.warn(`[global-sync] Duplicate session ${s.id} detected in directory "${directory}"`) + return false + } seen.add(s.id) return true }) const sessions = trimSessions(deduplicated, { limit, permission: store.permission }) + validateParentIDs(sessions) setStore( "sessionTotal", estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 6471c846fd7..e9b944f48ad 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -6,6 +6,7 @@ import { getChildSessions, getDraggableId, syncWorkspaceOrder, + validateParentIDs, workspaceKey, } from "./helpers" import type { Session } from "@opencode-ai/sdk/v2/client" @@ -167,3 +168,45 @@ describe("getChildSessions", () => { expect(children.find((s) => s.id === "archived-2")).toBeUndefined() }) }) + +describe("validateParentIDs", () => { + test("returns valid when all parentID references exist", () => { + const sessions: Session[] = [ + mockSession("root"), + mockSession("child-1", { parentID: "root" }), + mockSession("child-2", { parentID: "root" }), + mockSession("grandchild", { parentID: "child-1" }), + ] + + const result = validateParentIDs(sessions) + expect(result.valid).toBe(true) + expect(result.orphaned).toHaveLength(0) + }) + + test("returns invalid when parentID references are missing", () => { + const sessions: Session[] = [ + mockSession("root"), + mockSession("child-1", { parentID: "root" }), + mockSession("orphaned-1", { parentID: "non-existent" }), + mockSession("orphaned-2", { parentID: "also-missing" }), + ] + + const result = validateParentIDs(sessions) + expect(result.valid).toBe(false) + expect(result.orphaned).toHaveLength(2) + expect(result.orphaned).toContain("orphaned-1") + expect(result.orphaned).toContain("orphaned-2") + }) + + test("handles sessions without parentID", () => { + const sessions: Session[] = [ + mockSession("root-1"), + mockSession("root-2"), + mockSession("child", { parentID: "root-1" }), + ] + + const result = validateParentIDs(sessions) + expect(result.valid).toBe(true) + expect(result.orphaned).toHaveLength(0) + }) +}) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 36de074b4f9..e072a2ee16f 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -46,6 +46,20 @@ export const getChildSessions = (sessions: Session[], parentID: string): Session return sessions.filter((s) => s.parentID === parentID && !s.time?.archived).sort(sortSessions(Date.now())) } +export const validateParentIDs = (sessions: Session[]): { valid: boolean; orphaned: string[] } => { + const sessionIds = new Set(sessions.map((s) => s.id)) + const orphaned: string[] = [] + + for (const session of sessions) { + if (session.parentID && !sessionIds.has(session.parentID)) { + orphaned.push(session.id) + console.warn(`[layout] Session "${session.id}" has missing parentID reference: "${session.parentID}"`) + } + } + + return { valid: orphaned.length === 0, orphaned } +} + export function getDraggableId(event: unknown): string | undefined { if (typeof event !== "object" || event === null) return undefined if (!("draggable" in event)) return undefined diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index c562c11c43c..1020922d704 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -92,56 +92,54 @@ const SessionRow = (props: { prefetchSession: (session: Session, priority?: "high" | "low") => void scheduleHoverPrefetch: () => void cancelHoverPrefetch: () => void -}): JSX.Element => ( - props.prefetchSession(props.session, "high")} - onClick={() => { - props.setHoverSession(undefined) - if (props.sidebarOpened()) return - props.clearHoverProjectSoon() - }} - > -
- 0}> -
- - - - - -
- - -
- - 0}> -
- - -
- - - {props.session.title} - - - {(summary) => ( -
- +}): JSX.Element => { + const hasStatus = () => props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0 + + const getStatusIcon = () => { + if (props.isWorking()) return + if (props.hasPermissions()) return
+ if (props.hasError()) return
+ return + + ) +} const SessionHoverPreview = (props: { mobile?: boolean diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 90c6fe90c73..141b36fb3ad 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -506,7 +506,7 @@ export const LocalWorkspace = (props: { return (
props.ctx.setScrollContainerRef(el, props.mobile)} - class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none] pr-2" >