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
54 changes: 54 additions & 0 deletions packages/app/src/pages/session/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { Session } from "@opencode-ai/sdk/v2"
import {
createOpenReviewFile,
createOpenSessionFileTab,
createSessionTabs,
focusTerminalById,
getTabReorderIndex,
selectNextSessionAfterRemoval,
shouldShowFileTree,
} from "./helpers"

Expand Down Expand Up @@ -166,3 +168,55 @@ describe("createSessionTabs", () => {
})
})
})

describe("selectNextSessionAfterRemoval", () => {
const makeSession = (id: string, overrides?: Partial<Session>): Session =>
({
id,
slug: id,
projectID: "prj",
directory: "/tmp",
title: id,
version: "0",
time: { created: 0, updated: 0 },
...overrides,
}) as Session

test("returns undefined when archiving the only root session", () => {
expect(selectNextSessionAfterRemoval([makeSession("ses_a")], "ses_a")).toBeUndefined()
})

test("excludes child and archived sessions so a lone root still returns undefined", () => {
const sessions = [
makeSession("ses_a"),
makeSession("ses_child", { parentID: "ses_a" }),
makeSession("ses_archived", { time: { created: 0, updated: 0, archived: 1 } }),
]
expect(selectNextSessionAfterRemoval(sessions, "ses_a")).toBeUndefined()
})

test("picks the following root neighbor when multiple roots exist", () => {
const sessions = [makeSession("ses_a"), makeSession("ses_b"), makeSession("ses_c")]
expect(selectNextSessionAfterRemoval(sessions, "ses_a")).toBe("ses_b")
})

test("falls back to the previous root neighbor for the last root", () => {
const sessions = [makeSession("ses_a"), makeSession("ses_b")]
expect(selectNextSessionAfterRemoval(sessions, "ses_b")).toBe("ses_a")
})

test("skips interleaved child and archived rows when picking the neighbor", () => {
const sessions = [
makeSession("ses_a"),
makeSession("ses_child", { parentID: "ses_a" }),
makeSession("ses_archived", { time: { created: 0, updated: 0, archived: 1 } }),
makeSession("ses_b"),
]
expect(selectNextSessionAfterRemoval(sessions, "ses_a")).toBe("ses_b")
})

test("returns undefined when the removed id is not a navigation candidate", () => {
const sessions = [makeSession("ses_a"), makeSession("ses_child", { parentID: "ses_a" })]
expect(selectNextSessionAfterRemoval(sessions, "ses_child")).toBeUndefined()
})
})
11 changes: 11 additions & 0 deletions packages/app/src/pages/session/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { Session } from "@opencode-ai/sdk/v2"
import { same } from "@/utils/same"

const emptyTabs: string[] = []
Expand Down Expand Up @@ -189,3 +190,13 @@ export const createSizing = () => {
}

export type Sizing = ReturnType<typeof createSizing>

// Pick the session to navigate to after `sessionID` is removed (archived or deleted).
// Only root, non-archived sessions are navigation candidates, so archiving the only
// open session lands on the empty/draft view instead of reopening a recent session.
export function selectNextSessionAfterRemoval(sessions: readonly Session[], sessionID: string): string | undefined {
const candidates = sessions.filter((s) => !s.parentID && !s.time?.archived)
const index = candidates.findIndex((s) => s.id === sessionID)
if (index === -1) return undefined
return (candidates[index + 1] ?? candidates[index - 1])?.id
}
13 changes: 5 additions & 8 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { selectNextSessionAfterRemoval } from "@/pages/session/helpers"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
Expand Down Expand Up @@ -795,9 +796,7 @@ export function MessageTimeline(props: {
const session = sync().session.get(sessionID)
if (!session) return

const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const nextSessionID = selectNextSessionAfterRemoval(sync().data.session ?? [], sessionID)

await sdk()
.client.session.update({ sessionID, time: { archived: Date.now() } })
Expand All @@ -809,7 +808,7 @@ export function MessageTimeline(props: {
}),
)
sync().session.evict(sessionID)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSessionID)
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
})
.catch((err) => {
Expand All @@ -824,9 +823,7 @@ export function MessageTimeline(props: {
const session = sync().session.get(sessionID)
if (!session) return false

const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const nextSessionID = selectNextSessionAfterRemoval(sync().data.session ?? [], sessionID)

const result = await sdk()
.client.session.delete({ sessionID })
Expand Down Expand Up @@ -869,7 +866,7 @@ export function MessageTimeline(props: {
}
}

navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSessionID)

sync().set(
produce((draft) => {
Expand Down
Loading