diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 3467effa6b3b..cf09d24bcfbf 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -419,3 +419,20 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { await expect(menu).toBeVisible() return menu } + +export 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) +} diff --git a/packages/app/e2e/sidebar/session-expansion-persistence.spec.ts b/packages/app/e2e/sidebar/session-expansion-persistence.spec.ts new file mode 100644 index 000000000000..a1a7a9ad5b56 --- /dev/null +++ b/packages/app/e2e/sidebar/session-expansion-persistence.spec.ts @@ -0,0 +1,149 @@ +import { test, expect } from "../fixtures" +import { openSidebar, withSession, seedMessage } from "../actions" +import { sessionItemSelector } from "../selectors" + +const EXPANDED_SESSIONS_STORAGE_KEY = "opencode.global.dat:expanded-sessions" + +async function getExpandedSessionsFromStorage(page: import("@playwright/test").Page) { + const raw = await page.evaluate((key) => localStorage.getItem(key), EXPANDED_SESSIONS_STORAGE_KEY) + if (!raw) return {} + try { + return JSON.parse(raw) as Record + } catch { + return {} + } +} + +test("expanding a session with children persists state to localStorage", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent session for expansion test", 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() + + const beforeExpand = await getExpandedSessionsFromStorage(page) + + await chevron.click() + await expect(childItem).toBeVisible() + + const afterExpand = await getExpandedSessionsFromStorage(page) + const expandedKeys = Object.keys(afterExpand).filter((k) => afterExpand[k] === true) + expect(expandedKeys.length).toBeGreaterThan( + beforeExpand.length ? Object.keys(beforeExpand).filter((k) => beforeExpand[k] === true).length : 0, + ) + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) + +test("expanded session state persists after page reload", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent session for reload test", 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 page.reload() + await expect(page.locator(sessionItemSelector(parent.id))).toBeVisible() + + await openSidebar(page) + await expect(childItem).toBeVisible() + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) + +test("collapsing an expanded session updates localStorage", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent session for collapse test", 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 chevron.click() + await expect(childItem).toBeVisible() + + const afterExpand = await getExpandedSessionsFromStorage(page) + const expandedKeysAfterExpand = Object.keys(afterExpand).filter((k) => afterExpand[k] === true) + expect(expandedKeysAfterExpand.length).toBeGreaterThan(0) + + await chevron.click() + await expect(childItem).not.toBeVisible() + + const afterCollapse = await getExpandedSessionsFromStorage(page) + const matchingKey = Object.keys(afterExpand).find((k) => k.includes(parent.id)) + if (matchingKey) { + expect(afterCollapse[matchingKey]).toBeFalsy() + } + } finally { + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) + +test("multiple sessions can be expanded and all persist", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "parent 1 for multi-expand test", async (parent1) => { + await withSession(sdk, "parent 2 for multi-expand test", async (parent2) => { + const child1 = await sdk.session.create({ title: "child 1", parentID: parent1.id }).then((r) => r.data) + const child2 = await sdk.session.create({ title: "child 2", parentID: parent2.id }).then((r) => r.data) + if (!child1?.id || !child2?.id) throw new Error("Failed to create child sessions") + + try { + await gotoSession(parent1.id) + await openSidebar(page) + + const parent1Item = page.locator(sessionItemSelector(parent1.id)) + const parent2Item = page.locator(sessionItemSelector(parent2.id)) + const child1Item = page.locator(sessionItemSelector(child1.id)) + const child2Item = page.locator(sessionItemSelector(child2.id)) + + const chevron1 = parent1Item.locator('[data-slot="collapsible-trigger"]') + const chevron2 = parent2Item.locator('[data-slot="collapsible-trigger"]') + + await chevron1.click() + await expect(child1Item).toBeVisible() + + await page.goto(`/${page.url().split("/")[3]}/session/${parent2.id}`) + await openSidebar(page) + await chevron2.click() + await expect(child2Item).toBeVisible() + + const afterExpand = await getExpandedSessionsFromStorage(page) + const expandedKeys = Object.keys(afterExpand).filter((k) => afterExpand[k] === true) + expect(expandedKeys.length).toBeGreaterThanOrEqual(2) + } finally { + await sdk.session.delete({ sessionID: child1.id }).catch(() => undefined) + await sdk.session.delete({ sessionID: child2.id }).catch(() => undefined) + } + }) + }) +}) diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts index 5c78c2220d29..21dca0d69b6f 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 { openSidebar, toggleSidebar, withSession, seedMessage } from "../actions" +import { sessionItemSelector } from "../selectors" test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { await gotoSession() @@ -35,3 +36,251 @@ 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() + }) +}) + +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) + } + }) +}) + +test("deeply nested sessions can be expanded and collapsed at each level", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "grandparent session", async (grandparent) => { + const parent = await sdk.session.create({ title: "parent session", parentID: grandparent.id }).then((r) => r.data) + if (!parent?.id) throw new Error("Failed to create parent session") + + 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") + + const grandchild = await sdk.session.create({ title: "grandchild session", parentID: child.id }).then((r) => r.data) + if (!grandchild?.id) throw new Error("Failed to create grandchild session") + + try { + await gotoSession(grandparent.id) + await openSidebar(page) + + const grandparentItem = page.locator(sessionItemSelector(grandparent.id)) + const parentItem = page.locator(sessionItemSelector(parent.id)) + const childItem = page.locator(sessionItemSelector(child.id)) + const grandchildItem = page.locator(sessionItemSelector(grandchild.id)) + + await expect(grandparentItem).toBeVisible() + await expect(parentItem).not.toBeVisible() + + const grandparentChevron = grandparentItem.locator('[data-slot="collapsible-trigger"]').first() + await grandparentChevron.click() + await expect(parentItem).toBeVisible() + await expect(childItem).not.toBeVisible() + + const parentChevron = parentItem.locator('[data-slot="collapsible-trigger"]').first() + await parentChevron.click() + await expect(childItem).toBeVisible() + await expect(grandchildItem).not.toBeVisible() + + const childChevron = childItem.locator('[data-slot="collapsible-trigger"]').first() + await childChevron.click() + await expect(grandchildItem).toBeVisible() + + await childChevron.click() + await expect(grandchildItem).not.toBeVisible() + + await parentChevron.click() + await expect(childItem).not.toBeVisible() + await expect(grandchildItem).not.toBeVisible() + + await grandparentChevron.click() + await expect(parentItem).not.toBeVisible() + } finally { + await sdk.session.delete({ sessionID: grandchild.id }).catch(() => undefined) + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + await sdk.session.delete({ sessionID: parent.id }).catch(() => undefined) + } + }) +}) + +test("parent with archived child has no 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 seedMessage(sdk, child.id) + 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(chevron).toBeVisible() + + await chevron.click() + await expect(childItem).toBeVisible() + + await sdk.session.update({ sessionID: child.id, time: { archived: Date.now() } }) + + await page.reload() + await openSidebar(page) + + await expect(parentItem).toBeVisible() + await expect(chevron).not.toBeVisible() + + await gotoSession(child.id) + await expect(page).toHaveURL(new RegExp(`/session/${child.id}`)) + + const navigateParent = page.getByTestId("navigate-parent-button") + await expect(navigateParent).toBeVisible() + } finally { + await sdk.session.update({ sessionID: child.id, time: { archived: undefined } }).catch(() => undefined) + await sdk.session.delete({ sessionID: child.id }).catch(() => undefined) + } + }) +}) diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts index 396b412318be..70ea74e68f99 100644 --- a/packages/app/src/context/global-sync.test.ts +++ b/packages/app/src/context/global-sync.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2/client" import { canDisposeDirectory, estimateRootSessionTotal, @@ -6,6 +7,17 @@ import { pickDirectoriesToEvict, } from "./global-sync" +const mockSession = (id: string, overrides?: Partial): Session => ({ + id, + slug: "", + projectID: "", + title: "", + version: "", + directory: "/test", + time: { created: Date.now(), updated: Date.now() }, + ...overrides, +}) + describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { const now = 5_000 @@ -29,7 +41,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 +58,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 +81,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) }) }) @@ -134,3 +143,74 @@ describe("canDisposeDirectory", () => { ).toBe(true) }) }) + +describe("session deduplication logic", () => { + test("deduplicates sessions by id", () => { + const sessions: Session[] = [ + mockSession("a"), + mockSession("b"), + mockSession("a"), + mockSession("c"), + mockSession("b"), + ] + + const seen = new Set() + const deduplicated = sessions.filter((s) => { + if (!s.id) return false + if (seen.has(s.id)) return false + seen.add(s.id) + return true + }) + + expect(deduplicated).toHaveLength(3) + expect(deduplicated.map((s) => s.id)).toEqual(["a", "b", "c"]) + }) + + test("combines non-archived and child sessions with deduplication", () => { + const fetched: Session[] = [mockSession("root-1"), mockSession("root-2"), mockSession("root-1")] + const existingChildren: Session[] = [ + mockSession("child-1", { parentID: "root-1" }), + mockSession("child-2", { parentID: "root-1" }), + mockSession("child-1", { parentID: "root-1" }), + ] + + const nonArchived = fetched.filter((s) => !s.time?.archived) + const childSessions = existingChildren.filter((s) => !!s.parentID) + + 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 + }) + + expect(deduplicated).toHaveLength(4) + const ids = deduplicated.map((s) => s.id) + expect(ids).toContain("root-1") + expect(ids).toContain("root-2") + expect(ids).toContain("child-1") + expect(ids).toContain("child-2") + }) + + test("filters out archived sessions before deduplication", () => { + const sessions: Session[] = [ + mockSession("active-1"), + mockSession("archived-1", { time: { created: Date.now(), updated: Date.now(), archived: Date.now() } }), + mockSession("active-2"), + mockSession("archived-1", { time: { created: Date.now(), updated: Date.now(), archived: Date.now() } }), + ] + + const nonArchived = sessions.filter((s) => !s.time?.archived) + const seen = new Set() + const deduplicated = nonArchived.filter((s) => { + if (!s.id) return false + if (seen.has(s.id)) return false + seen.add(s.id) + return true + }) + + expect(deduplicated).toHaveLength(2) + expect(deduplicated.map((s) => s.id)).toEqual(["active-1", "active-2"]) + }) +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 9733b72afbd6..fc136633414a 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" @@ -218,7 +219,18 @@ 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)) { + 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/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts index 443aa8450203..438a9e8c9965 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 ade0b973a2a5..ae4ef30de3e6 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.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 83d8f4748aba..e9b944f48ad7 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -1,6 +1,26 @@ import { describe, expect, test } from "bun:test" import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links" -import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" +import { + displayName, + errorMessage, + getChildSessions, + getDraggableId, + syncWorkspaceOrder, + validateParentIDs, + workspaceKey, +} from "./helpers" +import type { Session } from "@opencode-ai/sdk/v2/client" + +const mockSession = (id: string, overrides?: Partial): Session => ({ + id, + slug: "", + projectID: "", + title: "", + version: "", + directory: "/test", + time: { created: Date.now(), updated: Date.now() }, + ...overrides, +}) describe("layout deep links", () => { test("parses open-project deep links", () => { @@ -90,3 +110,103 @@ describe("layout workspace helpers", () => { expect(errorMessage("unknown", "fallback")).toBe("fallback") }) }) + +describe("getChildSessions", () => { + test("filters by parentID", () => { + const sessions: Session[] = [ + mockSession("child-1", { parentID: "parent-a" }), + mockSession("child-2", { parentID: "parent-b" }), + mockSession("child-3", { parentID: "parent-a" }), + mockSession("root", {}), + ] + + const childrenA = getChildSessions(sessions, "parent-a") + expect(childrenA.map((s) => s.id)).toEqual(expect.arrayContaining(["child-1", "child-3"])) + expect(childrenA).toHaveLength(2) + + const childrenB = getChildSessions(sessions, "parent-b") + expect(childrenB).toHaveLength(1) + expect(childrenB[0].id).toBe("child-2") + + const childrenNone = getChildSessions(sessions, "non-existent") + expect(childrenNone).toHaveLength(0) + }) + + test("sorts by updated time descending", () => { + const now = Date.now() + const sessions: Session[] = [ + mockSession("old", { parentID: "parent", time: { created: now, updated: now - 10000 } }), + mockSession("new", { parentID: "parent", time: { created: now, updated: now - 1000 } }), + mockSession("mid", { parentID: "parent", time: { created: now, updated: now - 5000 } }), + ] + + const children = getChildSessions(sessions, "parent") + const ids = children.map((s) => s.id) + expect(ids).toContain("mid") + expect(ids).toContain("new") + expect(ids).toContain("old") + }) + + test("filters out archived sessions", () => { + const sessions: Session[] = [ + mockSession("active-1", { parentID: "parent" }), + mockSession("active-2", { parentID: "parent" }), + mockSession("archived-1", { + parentID: "parent", + time: { created: Date.now(), updated: Date.now(), archived: Date.now() }, + }), + mockSession("archived-2", { + parentID: "parent", + time: { created: Date.now(), updated: Date.now(), archived: Date.now() }, + }), + ] + + const children = getChildSessions(sessions, "parent") + expect(children).toHaveLength(2) + expect(children.map((s) => s.id)).toEqual(expect.arrayContaining(["active-1", "active-2"])) + expect(children.find((s) => s.id === "archived-1")).toBeUndefined() + 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 6a1e7c0123d8..e072a2ee16fb 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -42,6 +42,24 @@ export const childMapByParent = (sessions: Session[]) => { return map } +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 d55090370750..1020922d7043 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" @@ -59,7 +61,9 @@ export type SessionItemProps = { mobile?: boolean dense?: boolean popover?: boolean + depth?: number children: Map + allSessions: Session[] sidebarExpanded: Accessor sidebarHovering: Accessor nav: Accessor @@ -68,6 +72,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: { @@ -86,54 +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}> -
- - -
- - {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 @@ -187,9 +193,15 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const language = useLanguage() const notification = useNotification() const globalSync = useGlobalSync() + const isSessionExpanded = props.isSessionExpanded ?? (() => false) + const toggleSessionExpanded = props.toggleSessionExpanded ?? (() => {}) + const depth = props.depth ?? 0 + 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) + 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 +263,7 @@ 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 item = ( { return (
- - {item} - - } + toggleSessionExpanded(props.session.id)} + class="w-full" + variant="ghost" > - { - 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 43d99cf8954e..141b36fb3ad3 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 @@ -244,10 +245,13 @@ const WorkspaceSessionList = (props: { showNew: Accessor loading: Accessor sessions: Accessor + allSessions: Accessor children: Accessor> hasMore: Accessor loadMore: () => Promise language: ReturnType + isSessionExpanded: (sessionId: string) => boolean + toggleSessionExpanded: (sessionId: string) => void }): JSX.Element => (