Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
sortScopedProjectsForSidebar,
sortSidebarV2ProjectGroups,
shouldNavigateAfterThreadPark,
threadSettlesOnTabClose,
type SidebarListItem,
type SidebarListMarker,
type SidebarSection,
Expand Down Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,21 @@ type SidebarThreadStatusInput = Pick<
| "blockedOnThreadWait"
>;

export function threadSettlesOnTabClose(
thread:
| (SidebarThreadStatusInput &
ThreadSnoozeShell &
Pick<SidebarThreadSummary, "archivedAt" | "settledOverride" | "pinnedAt">)
| 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";
Expand Down
28 changes: 24 additions & 4 deletions apps/web/src/layout/PaneTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
memo,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type ComponentType,
type MouseEvent as ReactMouseEvent,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 (
<Tooltip>
<Tooltip
onOpenChange={(open) => {
if (!open) return;
const tab = findLayoutTab(useLayoutStore.getState().layout, (t) => t.id === tabId)?.tab;
setSettles(tab !== undefined && tabSettlesOnClose(tab, new Date().toISOString()));
}}
>
<TooltipTrigger
render={
<ContentButton
Expand All @@ -358,7 +375,10 @@ export function LayoutTabCloseButton({ tabId, title }: { tabId: string; title: s
>
<XIcon aria-hidden className="size-3" />
</TooltipTrigger>
<TooltipPopup side="bottom">Close tab{shortcut ? ` (${shortcut})` : ""}</TooltipPopup>
<TooltipPopup side="bottom">
{settles ? "Close and settle" : "Close tab"}
{shortcut ? ` (${shortcut})` : ""}
</TooltipPopup>
</Tooltip>
);
}
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/layout/settleOnClose.ts
Original file line number Diff line number Diff line change
@@ -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);
}
16 changes: 4 additions & 12 deletions apps/web/src/layout/useSettleOnLayoutClose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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],
Expand Down
Loading