diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 516f406aea07..5e536343e846 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -89,6 +89,7 @@ import { TuiPluginRuntime } from "../../plugin" import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" +import { getCurrentSessionWithChildren, getFirstDirectChildSession, getSiblingSessions } from "./navigation" addDefaultParsers(parsers.parsers) @@ -127,12 +128,8 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) - const children = createMemo(() => { - const parentID = session()?.parentID ?? session()?.id - return sync.data.session - .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }) + const children = createMemo(() => getCurrentSessionWithChildren(sync.data.session, session()?.id)) + const siblingSessions = createMemo(() => getSiblingSessions(sync.data.session, session())) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => { if (session()?.parentID) return [] @@ -354,7 +351,7 @@ export function Session() { function moveFirstChild() { if (children().length === 1) return - const next = children().find((x) => !!x.parentID) + const next = getFirstDirectChildSession(children(), session()?.id) if (next) { navigate({ type: "session", @@ -364,9 +361,9 @@ export function Session() { } function moveChild(direction: number) { - if (children().length === 1) return + if (siblingSessions().length <= 1) return - const sessions = children().filter((x) => !!x.parentID) + const sessions = siblingSessions() let next = sessions.findIndex((x) => x.id === session()?.id) - direction if (next >= sessions.length) next = 0 diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts b/packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts new file mode 100644 index 000000000000..7402b00abb52 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts @@ -0,0 +1,37 @@ +type SessionNavigationItem = { + id: string + parentID?: string +} + +function sortByID(a: T, b: T) { + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} + +export function getCurrentSessionWithChildren( + sessions: readonly T[], + currentSessionID: string | undefined, +) { + if (!currentSessionID) return [] + + return sessions + .filter((session) => session.id === currentSessionID || session.parentID === currentSessionID) + .toSorted(sortByID) +} + +export function getFirstDirectChildSession( + sessions: readonly T[], + currentSessionID: string | undefined, +) { + if (!currentSessionID) return undefined + + return sessions.filter((session) => session.parentID === currentSessionID).toSorted(sortByID)[0] +} + +export function getSiblingSessions( + sessions: readonly T[], + currentSession: T | undefined, +) { + if (!currentSession?.parentID) return [] + + return sessions.filter((session) => session.parentID === currentSession.parentID).toSorted(sortByID) +} diff --git a/packages/opencode/test/cli/tui/session-navigation.test.ts b/packages/opencode/test/cli/tui/session-navigation.test.ts new file mode 100644 index 000000000000..6ec3b69d546a --- /dev/null +++ b/packages/opencode/test/cli/tui/session-navigation.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test" +import { + getCurrentSessionWithChildren, + getFirstDirectChildSession, + getSiblingSessions, +} from "../../../src/cli/cmd/tui/routes/session/navigation" + +describe("getCurrentSessionWithChildren", () => { + test("returns the root session and its direct children", () => { + const sessions = [ + { id: "root" }, + { id: "child-b", parentID: "root" }, + { id: "grandchild", parentID: "child-a" }, + { id: "child-a", parentID: "root" }, + ] + + expect(getCurrentSessionWithChildren(sessions, "root")).toEqual([ + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + { id: "root" }, + ]) + }) + + test("returns the current child session and its direct children", () => { + const sessions = [ + { id: "root" }, + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + { id: "grandchild-b", parentID: "child-a" }, + { id: "grandchild-a", parentID: "child-a" }, + ] + + expect(getCurrentSessionWithChildren(sessions, "child-a")).toEqual([ + { id: "child-a", parentID: "root" }, + { id: "grandchild-a", parentID: "child-a" }, + { id: "grandchild-b", parentID: "child-a" }, + ]) + }) + + test("returns an empty list when the current session is missing", () => { + expect(getCurrentSessionWithChildren([{ id: "root" }], undefined)).toEqual([]) + }) +}) + +describe("getFirstDirectChildSession", () => { + test("preserves first-level descent from the root session", () => { + const sessions = [ + { id: "root" }, + { id: "child-b", parentID: "root" }, + { id: "child-a", parentID: "root" }, + ] + + const visibleSessions = getCurrentSessionWithChildren(sessions, "root") + + expect(getFirstDirectChildSession(visibleSessions, "root")).toEqual({ + id: "child-a", + parentID: "root", + }) + }) + + test("nested descent skips the current child session and selects its direct child", () => { + const sessions = [ + { id: "root" }, + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + { id: "grandchild-b", parentID: "child-a" }, + { id: "grandchild-a", parentID: "child-a" }, + ] + + const visibleSessions = getCurrentSessionWithChildren(sessions, "child-a") + + expect(getFirstDirectChildSession(visibleSessions, "child-a")).toEqual({ + id: "grandchild-a", + parentID: "child-a", + }) + }) + + test("returns nothing when there are no direct children", () => { + const visibleSessions = getCurrentSessionWithChildren([{ id: "root" }], "root") + + expect(getFirstDirectChildSession(visibleSessions, "root")).toBeUndefined() + }) +}) + +describe("getSiblingSessions", () => { + test("keeps sibling cycling scoped to the current depth", () => { + const sessions = [ + { id: "root" }, + { id: "child-b", parentID: "root" }, + { id: "child-a", parentID: "root" }, + { id: "grandchild-a", parentID: "child-a" }, + ] + + expect(getSiblingSessions(sessions, { id: "child-a", parentID: "root" })).toEqual([ + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + ]) + }) + + test("returns an empty list for the root session", () => { + expect(getSiblingSessions([{ id: "root" }], { id: "root" })).toEqual([]) + }) +})