feat(app): delete a session from its tab menu (#57453)

This commit is contained in:
Kit Langton
2026-10-20 10:40:34 -07:00
committed by GitHub
Unverified
parent 1b79e9726c
commit 1817f310ec
4 changed files with 96 additions and 0 deletions
+29
View File
@@ -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(
@@ -32,6 +32,7 @@ export function TabNavItem(props: {
fallbackDirectory?: string
onRename: (title: string) => Promise<void>
onClose: () => void
onDelete: () => void
onNavigate: () => void
active?: boolean
suppressNavigation?: boolean
@@ -435,6 +436,10 @@ export function TabNavItem(props: {
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={props.onClose}>{language.t("common.closeTab")}</Menu.Item>
<Menu.Separator />
<Menu.Item data-variant="critical" disabled={!props.session} onSelect={props.onDelete}>
{language.t("session.delete.title")}…
</Menu.Item>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
@@ -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<void>
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 (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", {
name: sessionTitle(session()?.title) ?? language.t("session.tab.session"),
})}
/>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" autofocus onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</DialogFooter>
</Dialog>
)
}
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => props.onUnreadChange(unread()))
@@ -232,6 +285,7 @@ function SessionTabEntry(props: {
}
fallbackDirectory={persisted()?.directory}
onRename={rename}
onDelete={() => dialog.show(() => <DeleteDialog />)}
onNavigate={props.onNavigate}
onClose={props.onClose}
onIntent={hover}
+8
View File
@@ -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;