Skip to content

Commit 7bcf1d3

Browse files
Geng Tangsisyphus-dev-ai
authored andcommitted
fix: scope nested child-session navigation correctly
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <[email protected]>
1 parent 889087c commit 7bcf1d3

3 files changed

Lines changed: 146 additions & 9 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import { TuiPluginRuntime } from "../../plugin"
8888
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
8989
import { SessionRetry } from "@/session/retry"
9090
import { getRevertDiffFiles } from "../../util/revert-diff"
91+
import { getCurrentSessionWithChildren, getFirstDirectChildSession, getSiblingSessions } from "./navigation"
9192

9293
addDefaultParsers(parsers.parsers)
9394

@@ -126,12 +127,8 @@ export function Session() {
126127
const { theme } = useTheme()
127128
const promptRef = usePromptRef()
128129
const session = createMemo(() => sync.session.get(route.sessionID))
129-
const children = createMemo(() => {
130-
const parentID = session()?.parentID ?? session()?.id
131-
return sync.data.session
132-
.filter((x) => x.parentID === parentID || x.id === parentID)
133-
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
134-
})
130+
const children = createMemo(() => getCurrentSessionWithChildren(sync.data.session, session()?.id))
131+
const siblingSessions = createMemo(() => getSiblingSessions(sync.data.session, session()))
135132
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
136133
const permissions = createMemo(() => {
137134
if (session()?.parentID) return []
@@ -341,7 +338,7 @@ export function Session() {
341338

342339
function moveFirstChild() {
343340
if (children().length === 1) return
344-
const next = children().find((x) => !!x.parentID)
341+
const next = getFirstDirectChildSession(children(), session()?.id)
345342
if (next) {
346343
navigate({
347344
type: "session",
@@ -351,9 +348,9 @@ export function Session() {
351348
}
352349

353350
function moveChild(direction: number) {
354-
if (children().length === 1) return
351+
if (siblingSessions().length <= 1) return
355352

356-
const sessions = children().filter((x) => !!x.parentID)
353+
const sessions = siblingSessions()
357354
let next = sessions.findIndex((x) => x.id === session()?.id) - direction
358355

359356
if (next >= sessions.length) next = 0
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
type SessionNavigationItem = {
2+
id: string
3+
parentID?: string
4+
}
5+
6+
function sortByID<T extends SessionNavigationItem>(a: T, b: T) {
7+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
8+
}
9+
10+
export function getCurrentSessionWithChildren<T extends SessionNavigationItem>(
11+
sessions: readonly T[],
12+
currentSessionID: string | undefined,
13+
) {
14+
if (!currentSessionID) return []
15+
16+
return sessions
17+
.filter((session) => session.id === currentSessionID || session.parentID === currentSessionID)
18+
.toSorted(sortByID)
19+
}
20+
21+
export function getFirstDirectChildSession<T extends SessionNavigationItem>(
22+
sessions: readonly T[],
23+
currentSessionID: string | undefined,
24+
) {
25+
if (!currentSessionID) return undefined
26+
27+
return sessions.filter((session) => session.parentID === currentSessionID).toSorted(sortByID)[0]
28+
}
29+
30+
export function getSiblingSessions<T extends SessionNavigationItem>(
31+
sessions: readonly T[],
32+
currentSession: T | undefined,
33+
) {
34+
if (!currentSession?.parentID) return []
35+
36+
return sessions.filter((session) => session.parentID === currentSession.parentID).toSorted(sortByID)
37+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
getCurrentSessionWithChildren,
4+
getFirstDirectChildSession,
5+
getSiblingSessions,
6+
} from "../../../src/cli/cmd/tui/routes/session/navigation"
7+
8+
describe("getCurrentSessionWithChildren", () => {
9+
test("returns the root session and its direct children", () => {
10+
const sessions = [
11+
{ id: "root" },
12+
{ id: "child-b", parentID: "root" },
13+
{ id: "grandchild", parentID: "child-a" },
14+
{ id: "child-a", parentID: "root" },
15+
]
16+
17+
expect(getCurrentSessionWithChildren(sessions, "root")).toEqual([
18+
{ id: "child-a", parentID: "root" },
19+
{ id: "child-b", parentID: "root" },
20+
{ id: "root" },
21+
])
22+
})
23+
24+
test("returns the current child session and its direct children", () => {
25+
const sessions = [
26+
{ id: "root" },
27+
{ id: "child-a", parentID: "root" },
28+
{ id: "child-b", parentID: "root" },
29+
{ id: "grandchild-b", parentID: "child-a" },
30+
{ id: "grandchild-a", parentID: "child-a" },
31+
]
32+
33+
expect(getCurrentSessionWithChildren(sessions, "child-a")).toEqual([
34+
{ id: "child-a", parentID: "root" },
35+
{ id: "grandchild-a", parentID: "child-a" },
36+
{ id: "grandchild-b", parentID: "child-a" },
37+
])
38+
})
39+
40+
test("returns an empty list when the current session is missing", () => {
41+
expect(getCurrentSessionWithChildren([{ id: "root" }], undefined)).toEqual([])
42+
})
43+
})
44+
45+
describe("getFirstDirectChildSession", () => {
46+
test("preserves first-level descent from the root session", () => {
47+
const sessions = [
48+
{ id: "root" },
49+
{ id: "child-b", parentID: "root" },
50+
{ id: "child-a", parentID: "root" },
51+
]
52+
53+
const visibleSessions = getCurrentSessionWithChildren(sessions, "root")
54+
55+
expect(getFirstDirectChildSession(visibleSessions, "root")).toEqual({
56+
id: "child-a",
57+
parentID: "root",
58+
})
59+
})
60+
61+
test("nested descent skips the current child session and selects its direct child", () => {
62+
const sessions = [
63+
{ id: "root" },
64+
{ id: "child-a", parentID: "root" },
65+
{ id: "child-b", parentID: "root" },
66+
{ id: "grandchild-b", parentID: "child-a" },
67+
{ id: "grandchild-a", parentID: "child-a" },
68+
]
69+
70+
const visibleSessions = getCurrentSessionWithChildren(sessions, "child-a")
71+
72+
expect(getFirstDirectChildSession(visibleSessions, "child-a")).toEqual({
73+
id: "grandchild-a",
74+
parentID: "child-a",
75+
})
76+
})
77+
78+
test("returns nothing when there are no direct children", () => {
79+
const visibleSessions = getCurrentSessionWithChildren([{ id: "root" }], "root")
80+
81+
expect(getFirstDirectChildSession(visibleSessions, "root")).toBeUndefined()
82+
})
83+
})
84+
85+
describe("getSiblingSessions", () => {
86+
test("keeps sibling cycling scoped to the current depth", () => {
87+
const sessions = [
88+
{ id: "root" },
89+
{ id: "child-b", parentID: "root" },
90+
{ id: "child-a", parentID: "root" },
91+
{ id: "grandchild-a", parentID: "child-a" },
92+
]
93+
94+
expect(getSiblingSessions(sessions, { id: "child-a", parentID: "root" })).toEqual([
95+
{ id: "child-a", parentID: "root" },
96+
{ id: "child-b", parentID: "root" },
97+
])
98+
})
99+
100+
test("returns an empty list for the root session", () => {
101+
expect(getSiblingSessions([{ id: "root" }], { id: "root" })).toEqual([])
102+
})
103+
})

0 commit comments

Comments
 (0)