From 8cdbf75e9ad0477e39176f3909a47beca061a4c6 Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 2 Nov 2026 04:53:39 +0000 Subject: [PATCH 1/2] fix(web): closing a tab says when it settles the thread --- apps/web/src/components/Sidebar.logic.test.ts | 34 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 15 ++++++++ .../components/sidebar/SidebarListMarkers.tsx | 19 +++++++++-- apps/web/src/layout/PaneTabs.tsx | 28 ++++++++++++--- apps/web/src/layout/settleOnClose.ts | 22 ++++++++++++ apps/web/src/layout/useSettleOnLayoutClose.ts | 18 ++++------ 6 files changed, 117 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/layout/settleOnClose.ts 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/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 5600740b82ba2..07efdead46571 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -1,8 +1,9 @@ import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { type ReactNode, useLayoutEffect } from "react"; +import { type ReactNode, useLayoutEffect, useState } from "react"; import { cn } from "~/lib/utils"; +import { useTabCloseSettles } from "../../layout/settleOnClose"; import { animateSidebarLayoutChanges, sidebarMarkerId, @@ -123,6 +124,9 @@ export function SidebarSectionHeader(props: { : props.marker === "snoozed-header" ? "snoozed" : "settled"; + const closeSettles = useTabCloseSettles((state) => (shelf === "settled" ? state.count : 0)); + const [closeSettlesAtMount] = useState(closeSettles); + const pulseKey = closeSettles > closeSettlesAtMount ? closeSettles : 0; return ( {props.count}} + accessory={ + 0 && "animate-stash-pulse-count")} + > + {props.count} + + } > - {props.label} + 0 && "animate-stash-pulse-row")}> + {props.label} + ); 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..6158e658b883f --- /dev/null +++ b/apps/web/src/layout/settleOnClose.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; + +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); +} + +export const useTabCloseSettles = create<{ count: number }>(() => ({ count: 0 })); + +export function noteTabCloseSettled(): void { + useTabCloseSettles.setState((state) => ({ count: state.count + 1 })); +} diff --git a/apps/web/src/layout/useSettleOnLayoutClose.ts b/apps/web/src/layout/useSettleOnLayoutClose.ts index 93aea90b57988..e247ccd1fd2a7 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 { noteTabCloseSettled, 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,10 @@ 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).then((result) => { + if (result._tag === "Success") noteTabCloseSettled(); + }); } }), [settle], From a6557beb6a09071e94ba07ebf28326d41ffbbb18 Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 2 Nov 2026 19:51:39 +0000 Subject: [PATCH 2/2] fix(web): drop the Settled header pulse after a tab close settles --- .../components/sidebar/SidebarListMarkers.tsx | 19 +++---------------- apps/web/src/layout/settleOnClose.ts | 8 -------- apps/web/src/layout/useSettleOnLayoutClose.ts | 6 ++---- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 07efdead46571..5600740b82ba2 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -1,9 +1,8 @@ import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { type ReactNode, useLayoutEffect, useState } from "react"; +import { type ReactNode, useLayoutEffect } from "react"; import { cn } from "~/lib/utils"; -import { useTabCloseSettles } from "../../layout/settleOnClose"; import { animateSidebarLayoutChanges, sidebarMarkerId, @@ -124,9 +123,6 @@ export function SidebarSectionHeader(props: { : props.marker === "snoozed-header" ? "snoozed" : "settled"; - const closeSettles = useTabCloseSettles((state) => (shelf === "settled" ? state.count : 0)); - const [closeSettlesAtMount] = useState(closeSettles); - const pulseKey = closeSettles > closeSettlesAtMount ? closeSettles : 0; return ( 0 && "animate-stash-pulse-count")} - > - {props.count} - - } + accessory={{props.count}} > - 0 && "animate-stash-pulse-row")}> - {props.label} - + {props.label} ); diff --git a/apps/web/src/layout/settleOnClose.ts b/apps/web/src/layout/settleOnClose.ts index 6158e658b883f..b3accae41e5a8 100644 --- a/apps/web/src/layout/settleOnClose.ts +++ b/apps/web/src/layout/settleOnClose.ts @@ -1,5 +1,3 @@ -import { create } from "zustand"; - import { threadSettlesOnTabClose } from "../components/Sidebar.logic"; import type { ThreadRouteTarget } from "../threadRoutes"; import { readEnvironmentServerConfigLoaded, readThreadShell } from "../state/entities"; @@ -14,9 +12,3 @@ export function targetSettlesOnClose(target: ThreadRouteTarget, now: string): bo export function tabSettlesOnClose(tab: LayoutTab, now: string): boolean { return tab.content.kind === "chat" && targetSettlesOnClose(tab.content.target, now); } - -export const useTabCloseSettles = create<{ count: number }>(() => ({ count: 0 })); - -export function noteTabCloseSettled(): void { - useTabCloseSettles.setState((state) => ({ count: state.count + 1 })); -} diff --git a/apps/web/src/layout/useSettleOnLayoutClose.ts b/apps/web/src/layout/useSettleOnLayoutClose.ts index e247ccd1fd2a7..2203803e76cf2 100644 --- a/apps/web/src/layout/useSettleOnLayoutClose.ts +++ b/apps/web/src/layout/useSettleOnLayoutClose.ts @@ -12,7 +12,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useStableCallback } from "../hooks/useStableCallback"; import { readThreadShell } from "../state/entities"; import { subscribeLayoutThreadsClosedByUser } from "./layoutStore"; -import { noteTabCloseSettled, targetSettlesOnClose } from "./settleOnClose"; +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,9 +36,7 @@ export function useSettleOnLayoutClose(): void { continue; } if (!targetSettlesOnClose(target, now)) continue; - void settle(target.threadRef).then((result) => { - if (result._tag === "Success") noteTabCloseSettled(); - }); + void settle(target.threadRef); } }), [settle],