diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index b70917ae9b0e..fd55752e407a 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -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" @@ -166,3 +168,55 @@ describe("createSessionTabs", () => { }) }) }) + +describe("selectNextSessionAfterRemoval", () => { + const makeSession = (id: string, overrides?: Partial): 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() + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index fcbc681acdea..84c3659a334a 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -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[] = [] @@ -189,3 +190,13 @@ export const createSizing = () => { } export type Sizing = ReturnType + +// 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 +} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 9d8d82bcc9be..5b716ed2eef3 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -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" @@ -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() } }) @@ -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) => { @@ -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 }) @@ -869,7 +866,7 @@ export function MessageTimeline(props: { } } - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSessionID) sync().set( produce((draft) => {