Skip to content

Commit 6a46b4d

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 5c5069b commit 6a46b4d

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
@@ -89,6 +89,7 @@ import { TuiPluginRuntime } from "../../plugin"
8989
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
9090
import { SessionRetry } from "@/session/retry"
9191
import { getRevertDiffFiles } from "../../util/revert-diff"
92+
import { getCurrentSessionWithChildren, getFirstDirectChildSession, getSiblingSessions } from "./navigation"
9293

9394
addDefaultParsers(parsers.parsers)
9495

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

355352
function moveFirstChild() {
356353
if (children().length === 1) return
357-
const next = children().find((x) => !!x.parentID)
354+
const next = getFirstDirectChildSession(children(), session()?.id)
358355
if (next) {
359356
navigate({
360357
type: "session",
@@ -364,9 +361,9 @@ export function Session() {
364361
}
365362

366363
function moveChild(direction: number) {
367-
if (children().length === 1) return
364+
if (siblingSessions().length <= 1) return
368365

369-
const sessions = children().filter((x) => !!x.parentID)
366+
const sessions = siblingSessions()
370367
let next = sessions.findIndex((x) => x.id === session()?.id) - direction
371368

372369
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)