diff --git a/bun.lock b/bun.lock index ee4afb1440f..6930e3f1569 100644 --- a/bun.lock +++ b/bun.lock @@ -71,6 +71,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-listener": "2.4.5", "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/keyed": "1.5.3", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/scheduled": "1.5.3", @@ -6499,8 +6500,6 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "db0/drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="], - "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], diff --git a/packages/app/e2e/regression/home-session-actions.spec.ts b/packages/app/e2e/regression/home-session-actions.spec.ts new file mode 100644 index 00000000000..1867f44d910 --- /dev/null +++ b/packages/app/e2e/regression/home-session-actions.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" + +test("renames, exports, and deletes a home session from its context menu", async ({ page }) => { + const sessions = fixture.sessions.map((session) => ({ ...session })) + await mockOpenCodeServer(page, { + sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.route("**/api/session/*/rename", async (route) => { + const sessionID = new URL(route.request().url()).pathname.split("/").at(-2) + const session = sessions.find((item) => item.id === sessionID) + const payload: unknown = route.request().postDataJSON() + if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string") + throw new Error("Invalid rename payload") + if (session) session.title = payload.title + await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + + await page.goto("/") + const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }) + await expect(row).toBeVisible() + const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`) + const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox() + const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox() + await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0) + + await row.focus() + await row.press("Shift+F10") + await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible() + await page.keyboard.press("Escape") + await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden() + await expect(row).toBeFocused() + + const rowBox = await row.boundingBox() + await row.click({ button: "right", position: { x: 48, y: 12 } }) + await expect(page).toHaveURL("/") + await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible() + await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible() + await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible() + const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox() + expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4) + + await page.getByRole("menuitem", { name: "Rename" }).click() + const title = page.locator('[data-component="home-session-rename"]') + await expect(title).toBeFocused() + await expect(title).toHaveValue(fixture.expected.targetTitle) + const editorBox = await title.boundingBox() + const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox() + expect(editorBox?.x).toBe(titleBox?.x) + expect(editingAvatarBox).toEqual(avatarBox) + expect( + await title.evaluate((element) => ({ + outline: getComputedStyle(element).outlineStyle, + shadow: getComputedStyle(element).boxShadow, + })), + ).toEqual({ outline: "none", shadow: "none" }) + expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none") + await title.fill("Renamed from Home") + const renamed = page.waitForRequest( + (request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"), + ) + await title.press("Enter") + expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" }) + let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" }) + await expect(renamedRow).toBeVisible() + + await renamedRow.click() + await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`)) + await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible() + await page.getByRole("button", { name: "Home" }).click() + await expect(page).toHaveURL("/") + renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" }) + await expect(renamedRow).toBeVisible() + + await renamedRow.click({ button: "right" }) + const download = page.waitForEvent("download") + const exportItem = page.getByRole("menuitem", { name: "Export..." }) + await exportItem.click() + expect((await download).suggestedFilename()).toBe("renamed-from-home.json") + await expect(exportItem).toBeHidden() + + await renamedRow.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Delete..." }).click() + const dialog = page.getByRole("dialog") + await expect(dialog).toContainText('Delete session "Renamed from Home"?') + const removed = page.waitForRequest( + (request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`), + ) + await dialog.getByRole("button", { name: "Delete session" }).click() + await removed + await expect(renamedRow).toBeHidden() +}) diff --git a/packages/app/package.json b/packages/app/package.json index 2e91c6d043e..276b3d2c526 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -52,12 +52,12 @@ "vite-plugin-solid": "2.11.14" }, "dependencies": { - "@ibm/plex": "6.4.1", "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", + "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -69,6 +69,7 @@ "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-listener": "2.4.5", "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/keyed": "1.5.3", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/scheduled": "1.5.3", diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index e06b344122b..0772b97fc26 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -1,8 +1,12 @@ import type { SessionInfo } from "@opencode-ai/client/promise" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { skipToken, useQuery } from "@tanstack/solid-query" +import { Button } from "@opencode-ai/ui/button" +import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-ai/ui/dialog" +import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query" import { DateTime } from "luxon" import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js" +import { createStore } from "solid-js/store" +import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events" import { useCommand } from "@/shell/commands/command" import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index" import type { LocalProject } from "@/shell/state/layout" @@ -11,7 +15,10 @@ import { ServerConnection } from "@/runtime/server/registry" import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs" import { errorMessage } from "@/shell/layout/helpers" import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state" +import { removedSessionIDs } from "@/session/session-domain" import { pathKey } from "@/workspaces/path-key" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export" +import { sessionLabel, sessionTitle } from "@/session/title" import { showToast } from "@/shell/notifications/toast" import { archiveHomeSession } from "./archive" import type { HomeController } from "../model" @@ -35,6 +42,8 @@ export function createHomeSessionsController(home: HomeController) { const command = useCommand() const dialog = useDialog() const language = useLanguage() + const queryClient = useQueryClient() + const [removed, setRemoved] = createStore({ keys: [] as string[] }) const projectDirectories = createMemo(() => { const selected = home.selection.value().directory if (!selected) return @@ -59,9 +68,13 @@ export function createHomeSessionsController(home: HomeController) { }) const indexedSessions = createMemo(() => { const ctx = home.server.focusedContext() - if (!ctx) return [] + const conn = home.server.focused() + if (!ctx || !conn) return [] + const server = ServerConnection.key(conn) return retainHomeSessions( - mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()), + mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter( + (session) => !removed.keys.includes(`${server}\0${session.id}`), + ), HOME_SESSION_LIMIT, Date.now(), ) @@ -123,6 +136,119 @@ export function createHomeSessionsController(home: HomeController) { }, ]) + const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => { + const conn = home.server.list().find((item) => ServerConnection.key(item) === server) + const ctx = conn ? home.server.context(conn) : undefined + if (!conn || !ctx) return false + const next = title.trim() + if (!next || next === sessionLabel(session)) return true + return ctx.sdk.api.session + .rename({ sessionID: session.id, title: next }) + .then(() => { + ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next }) + // Rename advances time.updated server-side; re-sync the canonical + // record so date grouping and ordering do not go stale. + ctx.data.session.invalidate(session.id) + void ctx.data.session.sync(session.id).catch(() => {}) + queryClient.setQueryData(["home-sessions", conn], (current) => + current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)), + ) + return true + }) + .catch((cause) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }) + return false + }) + } + + const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => { + const conn = home.server.list().find((item) => ServerConnection.key(item) === server) + const ctx = conn ? home.server.context(conn) : undefined + if (!ctx) return + try { + const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (cause) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"), + }) + } + } + + const remove = async (server: ServerConnection.Key, session: SessionInfo) => { + const conn = home.server.list().find((item) => ServerConnection.key(item) === server) + const ctx = conn ? home.server.context(conn) : undefined + if (!conn || !ctx) return false + const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)] + await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true }) + return ctx.sdk.api.session + .remove({ sessionID: session.id }) + .then(() => { + const removedIDs = new Set(ids) + setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])]) + queryClient.setQueryData(["home-sessions", conn], (current) => + current?.filter((item) => !removedIDs.has(item.id)), + ) + notifySessionTabsRemoved({ + server: ServerConnection.key(conn), + directory: session.location.directory, + sessionIDs: ids, + }) + return true + }) + .catch((cause) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(cause, language.t("session.delete.failed.title")), + }) + return false + }) + .finally(() => { + // Always refetch: the pre-mutation cancel may have aborted an + // in-flight index fetch, and a failed delete must not leave the + // index unloaded either. + void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true }) + }) + } + + function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) { + const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new") + const confirm = async () => { + await remove(props.server, props.session) + dialog.close() + } + return ( + + + + + + + + + + ) + } + return { copy: { language, @@ -193,6 +319,10 @@ export function createHomeSessionsController(home: HomeController) { }), }) }, + rename, + export: exportSession, + showDelete: (server: ServerConnection.Key, session: SessionInfo) => + dialog.show(() => ), }, tab: { isOpen: (record: HomeSessionRecord) => diff --git a/packages/app/src/home/sessions/region.tsx b/packages/app/src/home/sessions/region.tsx index 877e0eff928..cdbb3fb0f65 100644 --- a/packages/app/src/home/sessions/region.tsx +++ b/packages/app/src/home/sessions/region.tsx @@ -27,6 +27,9 @@ export function HomeSessions(props: { onCreateSession={props.sessions.session.create} onOpenSession={props.sessions.session.open} onArchiveSession={props.sessions.session.archive} + onRenameSession={props.sessions.session.rename} + onExportSession={props.sessions.session.export} + onDeleteSession={props.sessions.session.showDelete} onSetHoverTarget={props.scroll.viewport.setHoverTarget} onSetThumbTrack={props.scroll.viewport.setThumbTrack} onSetContent={props.scroll.header.setContent} diff --git a/packages/app/src/home/sessions/view.tsx b/packages/app/src/home/sessions/view.tsx index 60a4c8f4320..6648f63fac2 100644 --- a/packages/app/src/home/sessions/view.tsx +++ b/packages/app/src/home/sessions/view.tsx @@ -1,10 +1,14 @@ import type { SessionInfo } from "@opencode-ai/client/promise" -import { createMemo, For, Show, Suspense } from "solid-js" +import { Key } from "@solid-primitives/keyed" +import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { Spinner } from "@opencode-ai/ui/spinner" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" +import { Menu } from "@opencode-ai/ui/menu" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useLanguage } from "@/runtime/i18n/language" import { ServerConnection } from "@/runtime/server/registry" @@ -22,6 +26,7 @@ import { const SHOW_HOME_SESSION_ARCHIVE = false const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" +const HOME_SESSION_LONG_PRESS_MS = 500 // Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session // tab in the background without navigating, matching browser conventions. @@ -54,6 +59,9 @@ export type HomeSessionsViewProps = { onCreateSession: () => void onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void onArchiveSession: (session: SessionInfo) => Promise + onRenameSession: (server: ServerConnection.Key, session: SessionInfo, title: string) => Promise + onExportSession: (server: ServerConnection.Key, session: SessionInfo) => Promise + onDeleteSession: (server: ServerConnection.Key, session: SessionInfo) => void onSetHoverTarget: (element: HTMLElement) => void onSetThumbTrack: (element: HTMLDivElement) => void onSetContent: (element: HTMLDivElement) => void @@ -71,7 +79,16 @@ export type HomeSessionsViewProps = { onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void } +// Session store updates recreate row components, so row-local state would +// close an open context menu or drop an in-progress rename. Keep both keyed +// by session ID at the view root, like the projects list does. +type HomeSessionRowUI = { + menu: { id: string; x: number; y: number } | undefined + editor: { id: string; draft: string; renaming: boolean } | undefined +} + export function HomeSessionsView(props: HomeSessionsViewProps) { + const [rowUI, setRowUI] = createStore({ menu: undefined, editor: undefined }) return (
- + {/* Index keeps group subtrees mounted when the group arrays are + rebuilt, so store updates cannot recreate rows mid-gesture. */} + {(group, index) => ( <> props.onSetHeader(group.id, element)} - elevated={index() === 0} + title={group().title} + titleOpacity={props.titleOpacity(group().id)} + onSetRef={(element) => props.onSetHeader(group().id, element)} + elevated={index === 0} /> -
- {(record) => } +
+ {/* Rows key by session ID: session.sync replaces the + stored session object wholesale, so reference-keyed + rows would be disposed mid-interaction whenever a + sync response lands. */} + record.session.id}> + {(record) => } +
)} - +
@@ -412,45 +435,263 @@ function HomeSessionGroupHeader(props: { ) } -function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { +function HomeSessionRow( + props: HomeSessionsViewProps & { + record: HomeSessionRecord + rowUI: HomeSessionRowUI + setRowUI: SetStoreFunction + }, +) { const title = createMemo(() => sessionLabel(props.record.session)) const showProjectName = () => props.showProjectName && props.record.projectName + const sessionID = () => props.record.session.id + const menu = () => (props.rowUI.menu?.id === sessionID() ? props.rowUI.menu : undefined) + const editor = () => (props.rowUI.editor?.id === sessionID() ? props.rowUI.editor : undefined) + let longPressTimer: ReturnType | undefined + let longPressStart: { x: number; y: number } | undefined + let suppressClick = false + let menuInteractedOutside = false + + // Focus targets are looked up by session ID: session store updates recreate + // row components, so instance refs can point at detached nodes by the time + // deferred focus runs. + const rowSelector = () => `[data-component="home-session-row-container"][data-session-id="${sessionID()}"]` + const rowButton = () => + document.querySelector(`${rowSelector()} [data-component="home-session-row"]`) + const renameInput = () => + document.querySelector(`${rowSelector()} [data-component="home-session-rename"]`) + + const clearLongPress = () => { + if (longPressTimer !== undefined) clearTimeout(longPressTimer) + longPressTimer = undefined + longPressStart = undefined + } + onCleanup(clearLongPress) + + const openMenu = (element: HTMLElement, clientX: number, clientY: number) => { + const bounds = element.getBoundingClientRect() + props.setRowUI("menu", { id: sessionID(), x: clientX - bounds.left, y: clientY - bounds.top }) + } + + const openEditor = () => { + props.setRowUI("editor", { id: sessionID(), draft: title(), renaming: false }) + requestAnimationFrame(() => { + const input = renameInput() + input?.focus() + input?.select() + }) + } + const closeEditor = () => { + if (editor()?.renaming) return + props.setRowUI("editor", (value) => (value?.id === sessionID() ? undefined : value)) + } + const saveEditor = async () => { + const current = editor() + if (!current || current.renaming) return + props.setRowUI("editor", { ...current, renaming: true }) + const saved = await props.onRenameSession(props.server, props.record.session, current.draft) + // Disabling the input during the request drops focus to the body; restore + // it unless the user focused another control while the rename was pending. + const restore = document.activeElement === document.body || document.activeElement === renameInput() + props.setRowUI("editor", (value) => { + if (value?.id !== sessionID()) return value + return saved ? undefined : { ...value, renaming: false } + }) + if (!restore) return + requestAnimationFrame(() => { + if (saved) { + rowButton()?.focus() + return + } + renameInput()?.focus() + }) + } return (
{ + // While renaming, keep the native menu so paste and spelling work. + if (editor()) return + event.preventDefault() + openMenu(event.currentTarget, event.clientX, event.clientY) + }} > -
+ } + > + + + { + if (open) return + props.setRowUI("menu", (value) => (value?.id === sessionID() ? undefined : value)) }} > -
{ + test("includes all descendants without unrelated sessions", () => { + const sessions = [ + { id: "root" }, + { id: "child", parentID: "root" }, + { id: "grandchild", parentID: "child" }, + { id: "other" }, + ] + + expect([...removedSessionIDs(sessions, "root")]).toEqual(["root", "child", "grandchild"]) + }) +}) diff --git a/packages/app/src/session/session-domain.ts b/packages/app/src/session/session-domain.ts index df028c32f64..0f68094948f 100644 --- a/packages/app/src/session/session-domain.ts +++ b/packages/app/src/session/session-domain.ts @@ -17,3 +17,19 @@ export function selectVisibleSessionUserMessages(messages: SessionMessageUser[], if (!revertMessageID) return messages return messages.filter((message) => message.id < revertMessageID) } + +export function removedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) { + const removed = new Set([sessionID]) + const byParent = Map.groupBy( + sessions.filter((session) => session.parentID), + (session) => session.parentID!, + ) + const visit = (id: string) => + byParent.get(id)?.forEach((child) => { + if (removed.has(child.id)) return + removed.add(child.id) + visit(child.id) + }) + visit(sessionID) + return removed +} diff --git a/packages/app/src/session/timeline/controller-projection.ts b/packages/app/src/session/timeline/controller-projection.ts index 5e4fca0503d..ec0c0c259ac 100644 --- a/packages/app/src/session/timeline/controller-projection.ts +++ b/packages/app/src/session/timeline/controller-projection.ts @@ -31,19 +31,3 @@ export function timelineChildTitle(input: { if (input.taskDescription) return input.taskDescription return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback } - -export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) { - const removed = new Set([sessionID]) - const byParent = Map.groupBy( - sessions.filter((session) => session.parentID), - (session) => session.parentID!, - ) - const visit = (id: string) => - byParent.get(id)?.forEach((child) => { - if (removed.has(child.id)) return - removed.add(child.id) - visit(child.id) - }) - visit(sessionID) - return removed -} diff --git a/packages/app/src/session/timeline/controller.tsx b/packages/app/src/session/timeline/controller.tsx index 9b1edf0d799..e7db9164a33 100644 --- a/packages/app/src/session/timeline/controller.tsx +++ b/packages/app/src/session/timeline/controller.tsx @@ -11,17 +11,13 @@ import { useSettings } from "@/settings/model" import { useWorkspaceLocation } from "@/workspaces/location" import { useTabs } from "@/shell/tabs/tabs" import type { SessionModel } from "@/session/model" +import { removedSessionIDs } from "@/session/session-domain" import { useServerSDK } from "@/runtime/server/client" import { sessionHref } from "@/shell/routes/session" import { sessionTitle } from "@/session/title" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export" import { showToast } from "@/shell/notifications/toast" -import { - applyTimelineMessageHandoff, - timelineChildTitle, - timelineRemovedSessionIDs, - visibleTimelineMessages, -} from "./controller-projection" +import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessages } from "./controller-projection" import { createTimelineProjection } from "./projection" import { useServer } from "@/runtime/server/current" import { getSessionMessageHandoff } from "@/session/handoff" @@ -171,7 +167,7 @@ export function createTimelineController(input: { session: TimelineSessionSource return false }) if (!success) return false - const removed = timelineRemovedSessionIDs(data.session.list(), id) + const removed = removedSessionIDs(data.session.list(), id) void navigateAfterRemoval(id, session.parentID, next?.id) notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] }) return true