Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
type SessionNavigationItem = {
id: string
parentID?: string
}

function sortByID<T extends SessionNavigationItem>(a: T, b: T) {
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
}

export function getCurrentSessionWithChildren<T extends SessionNavigationItem>(
sessions: readonly T[],
currentSessionID: string | undefined,
) {
if (!currentSessionID) return []

return sessions
.filter((session) => session.id === currentSessionID || session.parentID === currentSessionID)
.toSorted(sortByID)
}

export function getFirstDirectChildSession<T extends SessionNavigationItem>(
sessions: readonly T[],
currentSessionID: string | undefined,
) {
if (!currentSessionID) return undefined

return sessions.filter((session) => session.parentID === currentSessionID).toSorted(sortByID)[0]
}

export function getSiblingSessions<T extends SessionNavigationItem>(
sessions: readonly T[],
currentSession: T | undefined,
) {
if (!currentSession?.parentID) return []

return sessions.filter((session) => session.parentID === currentSession.parentID).toSorted(sortByID)
}
103 changes: 103 additions & 0 deletions packages/opencode/test/cli/tui/session-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
Loading