From 1817f310ec7a7adfccdf0265174fcf9b2b20ecaa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 10:40:34 -0700 Subject: [PATCH] feat(app): delete a session from its tab menu (#57453) --- packages/app/e2e/regression/tabs.spec.ts | 29 ++++++++++ packages/app/src/shell/titlebar/tab-nav.tsx | 5 ++ packages/app/src/shell/titlebar/tab-strip.tsx | 54 +++++++++++++++++++ packages/ui/src/navigation/menu/menu.css | 8 +++ 4 files changed, 96 insertions(+) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 7d39c68fbab..351816ae655 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -161,6 +161,35 @@ for (const tabLayout of ["horizontal", "vertical"] as const) { }) } +for (const tabLayout of ["horizontal", "vertical"] as const) { + test(`a ${tabLayout} tab deletes its session after confirmation`, async ({ page }) => { + await mockWorkspace(page, { name: "Tabs", sessions: [a, b], seed: { settings: { appearance: { tabLayout } } } }) + await page.goto(sessionHref(a.id)) + const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"]) [data-slot="tab-title"]`) + + const remove = page.waitForRequest( + (request) => request.method() === "DELETE" && request.url().endsWith(`/api/session/${a.id}`), + ) + + await expect(title).toHaveText(a.title) + + await title.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Delete session…", exact: true }).click() + + const dialog = page.getByRole("dialog") + await dialog.getByRole("button", { name: "Cancel", exact: true }).click() + await expect(dialog).toHaveCount(0) + await expect(title).toHaveText(a.title) + + await title.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Delete session…", exact: true }).click() + await dialog.getByRole("button", { name: "Delete session", exact: true }).click() + await remove + await expect(title).toHaveCount(0) + await expectPath(page, sessionHref(b.id)) + }) +} + test("keyboard navigation follows the visible tab order and skips unresolved tabs", async ({ page }) => { await mockWorkspace(page, { name: "Tabs", sessions: [a, c], seed: { tabs: [a.id, "ses_tab_unresolved", c.id] } }) await page.route( diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index 17ef6e05bb8..00905063989 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -32,6 +32,7 @@ export function TabNavItem(props: { fallbackDirectory?: string onRename: (title: string) => Promise onClose: () => void + onDelete: () => void onNavigate: () => void active?: boolean suppressNavigation?: boolean @@ -435,6 +436,10 @@ export function TabNavItem(props: { {language.t("common.rename")} {language.t("common.closeTab")} + + + {language.t("session.delete.title")}… + diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 094c49ed8bb..fb978bb520f 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -18,6 +18,13 @@ import { createTabComposerState } from "@/composer/persistence" import { base64Encode } from "@opencode/util/encode" import { showToast } from "@/shell/notifications/toast" import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order" +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog" +import { sessionTreeIDs } from "@/session/requests/session-request-tree" +import { sessionTitle } from "@/session/title" +import { errorMessage } from "@/shell/layout/helpers" +import { notifySessionTabsRemoved } from "./session-events" import type { SessionInfo } from "@opencode/client/promise" function SessionTabSlot(props: { @@ -33,6 +40,7 @@ function SessionTabSlot(props: { fallbackTitle?: string fallbackDirectory?: string onRename: (title: string) => Promise + onDelete: () => void onNavigate: (element: HTMLDivElement) => void onClose: () => void onIntent: (hovered: boolean) => void @@ -76,6 +84,7 @@ function SessionTabSlot(props: { fallbackTitle={props.fallbackTitle} fallbackDirectory={props.fallbackDirectory} onRename={props.onRename} + onDelete={props.onDelete} onNavigate={() => props.onNavigate(ref)} onClose={props.onClose} active={props.active} @@ -100,6 +109,7 @@ function SessionTabEntry(props: { }) { const tabs = useTabs() const language = useLanguage() + const dialog = useDialog() const sdk = createMemo(() => props.serverCtx?.sdk ?? null) const pending = createMemo(() => tabs.pendingSession(props.tab.server, props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId)) @@ -156,6 +166,49 @@ function SessionTabEntry(props: { } } + function DeleteDialog() { + const confirm = async () => { + const value = session() + const ctx = props.serverCtx + + dialog.close() + + if (!value || !ctx) return + const ids = sessionTreeIDs(ctx.data.session.list(), value.id) + + try { + await ctx.data.session.remove(value.id) + notifySessionTabsRemoved({ server: props.tab.server, directory: value.location.directory, sessionIDs: ids }) + } catch (cause) { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(cause, language.t("session.delete.failed.title")), + }) + } + } + + return ( + + + + + + + + + + ) + } + createEffect(() => props.onVisibleChange(visible())) createEffect(() => props.onUnreadChange(unread())) @@ -232,6 +285,7 @@ function SessionTabEntry(props: { } fallbackDirectory={persisted()?.directory} onRename={rename} + onDelete={() => dialog.show(() => )} onNavigate={props.onNavigate} onClose={props.onClose} onIntent={hover} diff --git a/packages/ui/src/navigation/menu/menu.css b/packages/ui/src/navigation/menu/menu.css index b78bb2f8c16..971b1a75ab7 100644 --- a/packages/ui/src/navigation/menu/menu.css +++ b/packages/ui/src/navigation/menu/menu.css @@ -67,6 +67,10 @@ font-variant-numeric: tabular-nums; color: var(--menu-v2-fg); + &[data-variant="critical"] { + --menu-v2-fg: var(--v2-state-fg-danger); + } + [data-slot="menu-v2-item-content"] { display: flex; flex: 1 1 auto; @@ -227,6 +231,10 @@ background: var(--surface-raised-base-hover); } + &[data-variant="critical"] { + color: var(--text-on-critical-base); + } + &[data-disabled] { color: var(--text-weak); opacity: 1;