diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 89c1e3196be80..d8913b8adac2a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -30,6 +30,7 @@ import { sortScopedProjectsForSidebar, sortSidebarV2ProjectGroups, shouldNavigateAfterThreadPark, + threadSettlesOnTabClose, type SidebarListItem, type SidebarListMarker, type SidebarSection, @@ -308,6 +309,39 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("waiting"); }); + it("settles a closed tab's thread only once it has stopped and nothing else holds it", () => { + const now = "2026-03-09T10:00:00.000Z"; + const done = { + ...idle, + runtime: { ...runtime, status: "completed" as const }, + archivedAt: null, + settledOverride: null, + pinnedAt: null, + snoozedUntil: null, + }; + expect(threadSettlesOnTabClose(done, now)).toBe(true); + expect(threadSettlesOnTabClose({ ...done, runtime: null }, now)).toBe(true); + expect( + threadSettlesOnTabClose({ ...done, runtime: { ...runtime, status: "failed" as const } }, now), + ).toBe(true); + expect( + threadSettlesOnTabClose({ ...done, snoozedUntil: "2026-03-09T09:00:00.000Z" }, now), + ).toBe(true); + + expect(threadSettlesOnTabClose(null, now)).toBe(false); + expect(threadSettlesOnTabClose({ ...done, runtime }, now)).toBe(false); + expect(threadSettlesOnTabClose({ ...done, hasPendingApprovals: true }, now)).toBe(false); + expect(threadSettlesOnTabClose({ ...done, hasPendingUserInput: true }, now)).toBe(false); + expect(threadSettlesOnTabClose({ ...done, settledOverride: "settled" as const }, now)).toBe( + false, + ); + expect(threadSettlesOnTabClose({ ...done, pinnedAt: now }, now)).toBe(false); + expect(threadSettlesOnTabClose({ ...done, archivedAt: now }, now)).toBe(false); + expect( + threadSettlesOnTabClose({ ...done, snoozedUntil: "2026-03-09T11:00:00.000Z" }, now), + ).toBe(false); + }); + it("shows Merged ahead of Done but behind live and wake states", () => { const merged = { isUnread: false, isWoke: false, isMerged: true }; expect(resolveSidebarV2TopStatus({ ...merged, status: "ready" })).toBe("merged"); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 11e96874a8e6a..9f0c072300e39 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -882,6 +882,21 @@ type SidebarThreadStatusInput = Pick< | "blockedOnThreadWait" >; +export function threadSettlesOnTabClose( + thread: + | (SidebarThreadStatusInput & + ThreadSnoozeShell & + Pick) + | null, + now: string, +): boolean { + if (!thread || thread.archivedAt != null) return false; + if (thread.settledOverride === "settled" || thread.pinnedAt != null) return false; + if (effectiveSnoozed(thread, { now })) return false; + const status = resolveSidebarThreadStatus(thread); + return status !== "working" && status !== "approval" && status !== "input"; +} + export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; diff --git a/apps/web/src/layout/PaneTabs.tsx b/apps/web/src/layout/PaneTabs.tsx index 09c08f1ec646f..cfdb1c2ef0359 100644 --- a/apps/web/src/layout/PaneTabs.tsx +++ b/apps/web/src/layout/PaneTabs.tsx @@ -23,6 +23,7 @@ import { memo, useLayoutEffect, useRef, + useState, type ReactNode, type ComponentType, type MouseEvent as ReactMouseEvent, @@ -67,6 +68,7 @@ import { closeLayoutTabByUser, closeOtherLayoutTabsByUser, splitLayoutTab } from import { beginLayoutTabDrag } from "./layoutDrag"; import { useLayoutPane } from "./layoutPaneContext"; import { useLayoutStore } from "./layoutStore"; +import { tabSettlesOnClose } from "./settleOnClose"; import { setWheelScroller, wheelDeltaPx } from "../motion/gesture"; import { observeResize } from "../lib/observeResize"; @@ -219,15 +221,23 @@ export function buildLayoutTabMenuItems( separatorBefore: true, }); } + const now = new Date().toISOString(); items.push({ id: "layout:close-tab", - label: "Close", + label: tabSettlesOnClose(location.tab, now) ? "Close and settle" : "Close", icon: "x", ...keys("tab.close"), separatorBefore: !items.some((item) => item.id === "layout:copy-link"), }); if (hasSiblings) { - items.push({ id: "layout:close-other-tabs", label: "Close others", icon: "copy-x" }); + const othersSettle = location.pane.tabs.some( + (tab) => tab.id !== tabId && tabSettlesOnClose(tab, now), + ); + items.push({ + id: "layout:close-other-tabs", + label: othersSettle ? "Close others and settle" : "Close others", + icon: "copy-x", + }); } return items; } @@ -342,8 +352,15 @@ export const LAYOUT_TAB_ICON_CLASS = "contents [visibility:var(--tab-icon-visibi export function LayoutTabCloseButton({ tabId, title }: { tabId: string; title: string }) { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const shortcut = shortcutLabelForCommand(keybindings, "tab.close"); + const [settles, setSettles] = useState(false); return ( - + { + if (!open) return; + const tab = findLayoutTab(useLayoutStore.getState().layout, (t) => t.id === tabId)?.tab; + setSettles(tab !== undefined && tabSettlesOnClose(tab, new Date().toISOString())); + }} + > - Close tab{shortcut ? ` (${shortcut})` : ""} + + {settles ? "Close and settle" : "Close tab"} + {shortcut ? ` (${shortcut})` : ""} + ); } diff --git a/apps/web/src/layout/settleOnClose.ts b/apps/web/src/layout/settleOnClose.ts new file mode 100644 index 0000000000000..b3accae41e5a8 --- /dev/null +++ b/apps/web/src/layout/settleOnClose.ts @@ -0,0 +1,14 @@ +import { threadSettlesOnTabClose } from "../components/Sidebar.logic"; +import type { ThreadRouteTarget } from "../threadRoutes"; +import { readEnvironmentServerConfigLoaded, readThreadShell } from "../state/entities"; +import type { LayoutTab } from "./layoutModel"; + +export function targetSettlesOnClose(target: ThreadRouteTarget, now: string): boolean { + if (target.kind !== "server") return false; + if (!readEnvironmentServerConfigLoaded(target.threadRef.environmentId)) return false; + return threadSettlesOnTabClose(readThreadShell(target.threadRef), now); +} + +export function tabSettlesOnClose(tab: LayoutTab, now: string): boolean { + return tab.content.kind === "chat" && targetSettlesOnClose(tab.content.target, now); +} diff --git a/apps/web/src/layout/useSettleOnLayoutClose.ts b/apps/web/src/layout/useSettleOnLayoutClose.ts index 93aea90b57988..2203803e76cf2 100644 --- a/apps/web/src/layout/useSettleOnLayoutClose.ts +++ b/apps/web/src/layout/useSettleOnLayoutClose.ts @@ -4,16 +4,15 @@ * Reopening does not unsettle; sending a message does. */ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { useEffect } from "react"; -import { resolveSidebarThreadStatus } from "../components/Sidebar.logic"; import { hideThreadSurfaceTabs } from "../components/ThreadSurfaceTab"; import { useComposerDraftStore } from "../composerDraftStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useStableCallback } from "../hooks/useStableCallback"; -import { readEnvironmentServerConfigLoaded, readThreadShell } from "../state/entities"; +import { readThreadShell } from "../state/entities"; import { subscribeLayoutThreadsClosedByUser } from "./layoutStore"; +import { targetSettlesOnClose } from "./settleOnClose"; /** Only user closes settle eligible threads; working, queued, pinned and snoozed threads keep their * state. Unsent drafts also hide their surfaces. */ @@ -36,15 +35,8 @@ export function useSettleOnLayoutClose(): void { if (readThreadShell(ref) === null) hideThreadSurfaceTabs(ref); continue; } - const ref = target.threadRef; - if (!readEnvironmentServerConfigLoaded(ref.environmentId)) continue; - const shell = readThreadShell(ref); - if (!shell || shell.archivedAt != null) continue; - if (shell.settledOverride === "settled" || shell.pinnedAt != null) continue; - if (effectiveSnoozed(shell, { now })) continue; - const status = resolveSidebarThreadStatus(shell); - if (status === "working" || status === "approval" || status === "input") continue; - void settle(ref); + if (!targetSettlesOnClose(target, now)) continue; + void settle(target.threadRef); } }), [settle],