Skip to content

Commit 8abb084

Browse files
committed
cleanup based on coding standards, more test coverage
1 parent 4f7f41d commit 8abb084

10 files changed

Lines changed: 488 additions & 125 deletions

packages/app/e2e/actions.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,20 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
419419
await expect(menu).toBeVisible()
420420
return menu
421421
}
422+
423+
export async function seedMessage(sdk: Parameters<typeof withSession>[0], sessionID: string) {
424+
await sdk.session.promptAsync({
425+
sessionID,
426+
noReply: true,
427+
parts: [{ type: "text", text: "e2e seed" }],
428+
})
429+
await expect
430+
.poll(
431+
async () => {
432+
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
433+
return messages.length
434+
},
435+
{ timeout: 30_000 },
436+
)
437+
.toBeGreaterThan(0)
438+
}

packages/app/e2e/sidebar/session-expansion-persistence.spec.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { test, expect } from "../fixtures"
2-
import { openSidebar, withSession } from "../actions"
2+
import { openSidebar, withSession, seedMessage } from "../actions"
33
import { sessionItemSelector } from "../selectors"
44

55
const EXPANDED_SESSIONS_STORAGE_KEY = "opencode.global.dat:expanded-sessions"
66

7-
type Sdk = Parameters<typeof withSession>[0]
8-
97
async function getExpandedSessionsFromStorage(page: import("@playwright/test").Page) {
108
const raw = await page.evaluate((key) => localStorage.getItem(key), EXPANDED_SESSIONS_STORAGE_KEY)
119
if (!raw) return {}
@@ -16,23 +14,6 @@ async function getExpandedSessionsFromStorage(page: import("@playwright/test").P
1614
}
1715
}
1816

19-
async function seedMessage(sdk: Sdk, sessionID: string) {
20-
await sdk.session.promptAsync({
21-
sessionID,
22-
noReply: true,
23-
parts: [{ type: "text", text: "e2e seed" }],
24-
})
25-
await expect
26-
.poll(
27-
async () => {
28-
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
29-
return messages.length
30-
},
31-
{ timeout: 30_000 },
32-
)
33-
.toBeGreaterThan(0)
34-
}
35-
3617
test("expanding a session with children persists state to localStorage", async ({ page, sdk, gotoSession }) => {
3718
await withSession(sdk, "parent session for expansion test", async (parent) => {
3819
const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data)

packages/app/e2e/sidebar/sidebar.spec.ts

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect } from "../fixtures"
2-
import { openSidebar, toggleSidebar, withSession } from "../actions"
2+
import { openSidebar, toggleSidebar, withSession, seedMessage } from "../actions"
33
import { sessionItemSelector } from "../selectors"
44

55
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
@@ -109,23 +109,6 @@ test("root session has no back arrow or subagent indicator", async ({ page, sdk,
109109
})
110110
})
111111

112-
async function seedMessage(sdk: Parameters<typeof withSession>[0], sessionID: string) {
113-
await sdk.session.promptAsync({
114-
sessionID,
115-
noReply: true,
116-
parts: [{ type: "text", text: "e2e seed" }],
117-
})
118-
await expect
119-
.poll(
120-
async () => {
121-
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
122-
return messages.length
123-
},
124-
{ timeout: 30_000 },
125-
)
126-
.toBeGreaterThan(0)
127-
}
128-
129112
test("subagent session shows back arrow and subagent indicator, and navigates to parent", async ({
130113
page,
131114
sdk,
@@ -208,3 +191,96 @@ test("navigating to parent session shows background only on parent", async ({ pa
208191
}
209192
})
210193
})
194+
195+
test("deeply nested sessions can be expanded and collapsed at each level", async ({ page, sdk, gotoSession }) => {
196+
await withSession(sdk, "grandparent session", async (grandparent) => {
197+
const parent = await sdk.session.create({ title: "parent session", parentID: grandparent.id }).then((r) => r.data)
198+
if (!parent?.id) throw new Error("Failed to create parent session")
199+
200+
const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data)
201+
if (!child?.id) throw new Error("Failed to create child session")
202+
203+
const grandchild = await sdk.session.create({ title: "grandchild session", parentID: child.id }).then((r) => r.data)
204+
if (!grandchild?.id) throw new Error("Failed to create grandchild session")
205+
206+
try {
207+
await gotoSession(grandparent.id)
208+
await openSidebar(page)
209+
210+
const grandparentItem = page.locator(sessionItemSelector(grandparent.id))
211+
const parentItem = page.locator(sessionItemSelector(parent.id))
212+
const childItem = page.locator(sessionItemSelector(child.id))
213+
const grandchildItem = page.locator(sessionItemSelector(grandchild.id))
214+
215+
await expect(grandparentItem).toBeVisible()
216+
await expect(parentItem).not.toBeVisible()
217+
218+
const grandparentChevron = grandparentItem.locator('[data-slot="collapsible-trigger"]').first()
219+
await grandparentChevron.click()
220+
await expect(parentItem).toBeVisible()
221+
await expect(childItem).not.toBeVisible()
222+
223+
const parentChevron = parentItem.locator('[data-slot="collapsible-trigger"]').first()
224+
await parentChevron.click()
225+
await expect(childItem).toBeVisible()
226+
await expect(grandchildItem).not.toBeVisible()
227+
228+
const childChevron = childItem.locator('[data-slot="collapsible-trigger"]').first()
229+
await childChevron.click()
230+
await expect(grandchildItem).toBeVisible()
231+
232+
await childChevron.click()
233+
await expect(grandchildItem).not.toBeVisible()
234+
235+
await parentChevron.click()
236+
await expect(childItem).not.toBeVisible()
237+
await expect(grandchildItem).not.toBeVisible()
238+
239+
await grandparentChevron.click()
240+
await expect(parentItem).not.toBeVisible()
241+
} finally {
242+
await sdk.session.delete({ sessionID: grandchild.id }).catch(() => undefined)
243+
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
244+
await sdk.session.delete({ sessionID: parent.id }).catch(() => undefined)
245+
}
246+
})
247+
})
248+
249+
test("parent with archived child has no chevron", async ({ page, sdk, gotoSession }) => {
250+
await withSession(sdk, "parent session", async (parent) => {
251+
const child = await sdk.session.create({ title: "child session", parentID: parent.id }).then((r) => r.data)
252+
if (!child?.id) throw new Error("Failed to create child session")
253+
254+
try {
255+
await seedMessage(sdk, child.id)
256+
await gotoSession(parent.id)
257+
await openSidebar(page)
258+
259+
const parentItem = page.locator(sessionItemSelector(parent.id))
260+
const childItem = page.locator(sessionItemSelector(child.id))
261+
const chevron = parentItem.locator('[data-slot="collapsible-trigger"]')
262+
263+
await expect(chevron).toBeVisible()
264+
265+
await chevron.click()
266+
await expect(childItem).toBeVisible()
267+
268+
await sdk.session.update({ sessionID: child.id, time: { archived: Date.now() } })
269+
270+
await page.reload()
271+
await openSidebar(page)
272+
273+
await expect(parentItem).toBeVisible()
274+
await expect(chevron).not.toBeVisible()
275+
276+
await gotoSession(child.id)
277+
await expect(page).toHaveURL(new RegExp(`/session/${child.id}`))
278+
279+
const navigateParent = page.getByTestId("navigate-parent-button")
280+
await expect(navigateParent).toBeVisible()
281+
} finally {
282+
await sdk.session.update({ sessionID: child.id, time: { archived: undefined } }).catch(() => undefined)
283+
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
284+
}
285+
})
286+
})

packages/app/src/context/global-sync.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import { describe, expect, test } from "bun:test"
2+
import type { Session } from "@opencode-ai/sdk/v2/client"
23
import {
34
canDisposeDirectory,
45
estimateRootSessionTotal,
56
loadRootSessionsWithFallback,
67
pickDirectoriesToEvict,
78
} from "./global-sync"
89

10+
const mockSession = (id: string, overrides?: Partial<Session>): Session => ({
11+
id,
12+
slug: "",
13+
projectID: "",
14+
title: "",
15+
version: "",
16+
directory: "/test",
17+
time: { created: Date.now(), updated: Date.now() },
18+
...overrides,
19+
})
20+
921
describe("pickDirectoriesToEvict", () => {
1022
test("keeps pinned stores and evicts idle stores", () => {
1123
const now = 5_000
@@ -131,3 +143,74 @@ describe("canDisposeDirectory", () => {
131143
).toBe(true)
132144
})
133145
})
146+
147+
describe("session deduplication logic", () => {
148+
test("deduplicates sessions by id", () => {
149+
const sessions: Session[] = [
150+
mockSession("a"),
151+
mockSession("b"),
152+
mockSession("a"),
153+
mockSession("c"),
154+
mockSession("b"),
155+
]
156+
157+
const seen = new Set<string>()
158+
const deduplicated = sessions.filter((s) => {
159+
if (!s.id) return false
160+
if (seen.has(s.id)) return false
161+
seen.add(s.id)
162+
return true
163+
})
164+
165+
expect(deduplicated).toHaveLength(3)
166+
expect(deduplicated.map((s) => s.id)).toEqual(["a", "b", "c"])
167+
})
168+
169+
test("combines non-archived and child sessions with deduplication", () => {
170+
const fetched: Session[] = [mockSession("root-1"), mockSession("root-2"), mockSession("root-1")]
171+
const existingChildren: Session[] = [
172+
mockSession("child-1", { parentID: "root-1" }),
173+
mockSession("child-2", { parentID: "root-1" }),
174+
mockSession("child-1", { parentID: "root-1" }),
175+
]
176+
177+
const nonArchived = fetched.filter((s) => !s.time?.archived)
178+
const childSessions = existingChildren.filter((s) => !!s.parentID)
179+
180+
const seen = new Set<string>()
181+
const deduplicated = [...nonArchived, ...childSessions].filter((s) => {
182+
if (!s.id) return false
183+
if (seen.has(s.id)) return false
184+
seen.add(s.id)
185+
return true
186+
})
187+
188+
expect(deduplicated).toHaveLength(4)
189+
const ids = deduplicated.map((s) => s.id)
190+
expect(ids).toContain("root-1")
191+
expect(ids).toContain("root-2")
192+
expect(ids).toContain("child-1")
193+
expect(ids).toContain("child-2")
194+
})
195+
196+
test("filters out archived sessions before deduplication", () => {
197+
const sessions: Session[] = [
198+
mockSession("active-1"),
199+
mockSession("archived-1", { time: { created: Date.now(), updated: Date.now(), archived: Date.now() } }),
200+
mockSession("active-2"),
201+
mockSession("archived-1", { time: { created: Date.now(), updated: Date.now(), archived: Date.now() } }),
202+
]
203+
204+
const nonArchived = sessions.filter((s) => !s.time?.archived)
205+
const seen = new Set<string>()
206+
const deduplicated = nonArchived.filter((s) => {
207+
if (!s.id) return false
208+
if (seen.has(s.id)) return false
209+
seen.add(s.id)
210+
return true
211+
})
212+
213+
expect(deduplicated).toHaveLength(2)
214+
expect(deduplicated.map((s) => s.id)).toEqual(["active-1", "active-2"])
215+
})
216+
})

packages/app/src/pages/layout/helpers.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
import { describe, expect, test } from "bun:test"
22
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
3-
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
3+
import {
4+
displayName,
5+
errorMessage,
6+
getChildSessions,
7+
getDraggableId,
8+
syncWorkspaceOrder,
9+
workspaceKey,
10+
} from "./helpers"
11+
import type { Session } from "@opencode-ai/sdk/v2/client"
12+
13+
const mockSession = (id: string, overrides?: Partial<Session>): Session => ({
14+
id,
15+
slug: "",
16+
projectID: "",
17+
title: "",
18+
version: "",
19+
directory: "/test",
20+
time: { created: Date.now(), updated: Date.now() },
21+
...overrides,
22+
})
423

524
describe("layout deep links", () => {
625
test("parses open-project deep links", () => {
@@ -90,3 +109,61 @@ describe("layout workspace helpers", () => {
90109
expect(errorMessage("unknown", "fallback")).toBe("fallback")
91110
})
92111
})
112+
113+
describe("getChildSessions", () => {
114+
test("filters by parentID", () => {
115+
const sessions: Session[] = [
116+
mockSession("child-1", { parentID: "parent-a" }),
117+
mockSession("child-2", { parentID: "parent-b" }),
118+
mockSession("child-3", { parentID: "parent-a" }),
119+
mockSession("root", {}),
120+
]
121+
122+
const childrenA = getChildSessions(sessions, "parent-a")
123+
expect(childrenA.map((s) => s.id)).toEqual(expect.arrayContaining(["child-1", "child-3"]))
124+
expect(childrenA).toHaveLength(2)
125+
126+
const childrenB = getChildSessions(sessions, "parent-b")
127+
expect(childrenB).toHaveLength(1)
128+
expect(childrenB[0].id).toBe("child-2")
129+
130+
const childrenNone = getChildSessions(sessions, "non-existent")
131+
expect(childrenNone).toHaveLength(0)
132+
})
133+
134+
test("sorts by updated time descending", () => {
135+
const now = Date.now()
136+
const sessions: Session[] = [
137+
mockSession("old", { parentID: "parent", time: { created: now, updated: now - 10000 } }),
138+
mockSession("new", { parentID: "parent", time: { created: now, updated: now - 1000 } }),
139+
mockSession("mid", { parentID: "parent", time: { created: now, updated: now - 5000 } }),
140+
]
141+
142+
const children = getChildSessions(sessions, "parent")
143+
const ids = children.map((s) => s.id)
144+
expect(ids).toContain("mid")
145+
expect(ids).toContain("new")
146+
expect(ids).toContain("old")
147+
})
148+
149+
test("filters out archived sessions", () => {
150+
const sessions: Session[] = [
151+
mockSession("active-1", { parentID: "parent" }),
152+
mockSession("active-2", { parentID: "parent" }),
153+
mockSession("archived-1", {
154+
parentID: "parent",
155+
time: { created: Date.now(), updated: Date.now(), archived: Date.now() },
156+
}),
157+
mockSession("archived-2", {
158+
parentID: "parent",
159+
time: { created: Date.now(), updated: Date.now(), archived: Date.now() },
160+
}),
161+
]
162+
163+
const children = getChildSessions(sessions, "parent")
164+
expect(children).toHaveLength(2)
165+
expect(children.map((s) => s.id)).toEqual(expect.arrayContaining(["active-1", "active-2"]))
166+
expect(children.find((s) => s.id === "archived-1")).toBeUndefined()
167+
expect(children.find((s) => s.id === "archived-2")).toBeUndefined()
168+
})
169+
})

packages/app/src/pages/layout/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const childMapByParent = (sessions: Session[]) => {
4343
}
4444

4545
export const getChildSessions = (sessions: Session[], parentID: string): Session[] => {
46-
return sessions.filter((s) => s.parentID === parentID).sort(sortSessions(Date.now()))
46+
return sessions.filter((s) => s.parentID === parentID && !s.time?.archived).sort(sortSessions(Date.now()))
4747
}
4848

4949
export function getDraggableId(event: unknown): string | undefined {

0 commit comments

Comments
 (0)