authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 19:18:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 19:18:32-07:00
logbb38c3320d0a4eec4e9097fdc400970d881ff559
tree17b930170d4ea593dc093ed7d772069792ae004a
parent6b9a5987f686d0d904abe6f51b4be3bf873907e9
signature Signed by PGP key B5690EEEBB952194

fix(web): keep settled threads reachable when opened directly (#4413)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 187 insertions(+), 16 deletions(-)

apps/web/src/components/ChatView.tsx+98-3
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 connectionStatusTitle,25 connectionStatusTitle,
26 type EnvironmentConnectionPresentation,26 type EnvironmentConnectionPresentation,
27} from "@t3tools/client-runtime/connection";27} from "@t3tools/client-runtime/connection";
28import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
28import {29import {
29 parseScopedThreadKey,30 parseScopedThreadKey,
30 scopedThreadKey,31 scopedThreadKey,
...@@ -138,7 +139,13 @@ import { BranchToolbar } from "./BranchToolbar";...@@ -138,7 +139,13 @@ import { BranchToolbar } from "./BranchToolbar";
138import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";139import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
139import PlanSidebar from "./PlanSidebar";140import PlanSidebar from "./PlanSidebar";
140import ThreadTerminalDrawer from "./ThreadTerminalDrawer";141import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
141import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";142import {
143 CheckCircle2Icon,
144 ChevronDownIcon,
145 GitBranchIcon,
146 TriangleAlertIcon,
147 WifiOffIcon,
148} from "lucide-react";
142import { cn, randomHex } from "~/lib/utils";149import { cn, randomHex } from "~/lib/utils";
143import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";150import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
144import { stackedThreadToast, toastManager } from "./ui/toast";151import { stackedThreadToast, toastManager } from "./ui/toast";
...@@ -153,7 +160,8 @@ import {...@@ -153,7 +160,8 @@ import {
153import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";160import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
154import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";161import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
155import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";162import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
156import { useEnvironmentSettings } from "../hooks/useSettings";163import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
164import { useNowMinute } from "../hooks/useNowMinute";
157import { resolveAppModelSelectionForInstance } from "../modelSelection";165import { resolveAppModelSelectionForInstance } from "../modelSelection";
158import { getTerminalFocusOwner } from "../lib/terminalFocus";166import { getTerminalFocusOwner } from "../lib/terminalFocus";
159import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";167import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
...@@ -202,6 +210,7 @@ import {...@@ -202,6 +210,7 @@ import {
202 useThread,210 useThread,
203 useThreadProposedPlans,211 useThreadProposedPlans,
204 useThreadRefs,212 useThreadRefs,
213 useThreadShell,
205} from "../state/entities";214} from "../state/entities";
206import { environmentShell } from "../state/shell";215import { environmentShell } from "../state/shell";
207import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";216import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
...@@ -220,6 +229,7 @@ import {...@@ -220,6 +229,7 @@ import {
220 shouldShowProviderStatusBanner,229 shouldShowProviderStatusBanner,
221} from "./chat/ProviderStatusBanner";230} from "./chat/ProviderStatusBanner";
222import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";231import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
232import { resolveThreadPr } from "./ThreadStatusIndicators";
223import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";233import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
224import {234import {
225 DRAFT_HERO_TRANSITION_ANIMATION_ID,235 DRAFT_HERO_TRANSITION_ANIMATION_ID,
...@@ -3823,6 +3833,63 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3823,6 +3833,63 @@ function ChatViewContent(props: ChatViewProps) {
3823 : null,3833 : null,
3824 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],3834 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
3825 );3835 );
3836 // Settled state of the open thread, resolved exactly like the sidebar
3837 // partition (same shell, same capability gate, same PR auto-settle input)
3838 // so the banner and the sidebar row never disagree.
3839 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
3840 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
3841 const activeThreadPr = resolveThreadPr({
3842 threadBranch: activeThread?.branch ?? null,
3843 gitStatus: gitStatusQuery.data ?? null,
3844 hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null,
3845 });
3846 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
3847 const nowMinute = useNowMinute();
3848 const activeThreadSettled = useMemo(() => {
3849 if (activeThreadShell === null || !supportsSettlement) return false;
3850 return effectiveSettled(activeThreadShell, {
3851 now: `${nowMinute}:00.000Z`,
3852 autoSettleAfterDays,
3853 changeRequestState: activeThreadPr?.state ?? null,
3854 });
3855 }, [
3856 activeThreadPr?.state,
3857 activeThreadShell,
3858 autoSettleAfterDays,
3859 nowMinute,
3860 supportsSettlement,
3861 ]);
3862 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
3863 reportFailure: false,
3864 });
3865 // Keyed by thread, not a boolean: the pending state must follow the thread
3866 // it belongs to across navigation, and a request resolving for thread A
3867 // must never clear (or re-enable) thread B's button.
3868 const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null);
3869 const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey;
3870 const handleUnsettleActiveThread = useCallback(async () => {
3871 if (!activeThreadRef) return;
3872 const threadKey = scopedThreadKey(activeThreadRef);
3873 setUnsettlingThreadKey(threadKey);
3874 try {
3875 const result = await unsettleThreadMutation({
3876 environmentId: activeThreadRef.environmentId,
3877 input: { threadId: activeThreadRef.threadId, reason: "user" },
3878 });
3879 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
3880 const error = squashAtomCommandFailure(result);
3881 toastManager.add(
3882 stackedThreadToast({
3883 type: "error",
3884 title: "Failed to un-settle thread",
3885 description: error instanceof Error ? error.message : "An error occurred.",
3886 }),
3887 );
3888 }
3889 } finally {
3890 setUnsettlingThreadKey((current) => (current === threadKey ? null : current));
3891 }
3892 }, [activeThreadRef, unsettleThreadMutation]);
3826 const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);3893 const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
3827 const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);3894 const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
3828 // Once revealed for a given mismatch, the banner stays mounted until the3895 // Once revealed for a given mismatch, the banner stays mounted until the
...@@ -3930,6 +3997,31 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3930,6 +3997,31 @@ function ChatViewContent(props: ChatViewProps) {
3930 switchGitRef,3997 switchGitRef,
3931 updateThreadMetadata,3998 updateThreadMetadata,
3932 ]);3999 ]);
4000 // The stack renders items[0] front-most and tucks the rest behind hover, so
4001 // ordering is priority: system banners, then the branch-mismatch notice,
4002 // and the informational settled banner last — it must never cover another.
4003 const settledComposerBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4004 if (!activeThreadSettled) {
4005 return null;
4006 }
4007 return {
4008 id: `thread-settled:${activeThread?.id ?? "unknown"}`,
4009 variant: "info",
4010 icon: <CheckCircle2Icon />,
4011 title: "This thread is settled",
4012 description: "Sending a message moves it back to Active in the sidebar.",
4013 actions: (
4014 <Button
4015 size="xs"
4016 variant="outline"
4017 disabled={isUnsettling}
4018 onClick={() => void handleUnsettleActiveThread()}
4019 >
4020 {isUnsettling ? "Un-settling..." : "Un-settle"}
4021 </Button>
4022 ),
4023 };
4024 }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]);
3933 const handleRestoreThreadBranch = useCallback(() => {4025 const handleRestoreThreadBranch = useCallback(() => {
3934 if (gitStatusQuery.data?.hasWorkingTreeChanges) {4026 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
3935 setBranchRestoreConfirmOpen(true);4027 setBranchRestoreConfirmOpen(true);
...@@ -3938,8 +4030,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3938,8 +4030,9 @@ function ChatViewContent(props: ChatViewProps) {
3938 void handleSwitchCheckoutToThread();4030 void handleSwitchCheckoutToThread();
3939 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);4031 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
3940 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {4032 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
4033 const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem];
3941 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {4034 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
3942 return systemComposerBannerItems;4035 return [...systemComposerBannerItems, ...settledItems];
3943 }4036 }
3944 return [4037 return [
3945 ...systemComposerBannerItems,4038 ...systemComposerBannerItems,
...@@ -3982,12 +4075,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3982,12 +4075,14 @@ function ChatViewContent(props: ChatViewProps) {
3982 setBranchMismatchDismissTick((tick) => tick + 1);4075 setBranchMismatchDismissTick((tick) => tick + 1);
3983 },4076 },
3984 },4077 },
4078 ...settledItems,
3985 ];4079 ];
3986 }, [4080 }, [
3987 activeBranchMismatchKey,4081 activeBranchMismatchKey,
3988 handleRestoreThreadBranch,4082 handleRestoreThreadBranch,
3989 isRestoringThreadBranch,4083 isRestoringThreadBranch,
3990 localCheckoutBranchMismatch,4084 localCheckoutBranchMismatch,
4085 settledComposerBannerItem,
3991 showBranchMismatchBanner,4086 showBranchMismatchBanner,
3992 systemComposerBannerItems,4087 systemComposerBannerItems,
3993 ]);4088 ]);
apps/web/src/components/SidebarV2.tsx+20-13
...@@ -78,6 +78,7 @@ import { openCommandPalette } from "../commandPaletteBus";...@@ -78,6 +78,7 @@ import { openCommandPalette } from "../commandPaletteBus";
78import { startNewThreadFromContext } from "../lib/chatThreadActions";78import { startNewThreadFromContext } from "../lib/chatThreadActions";
79import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";79import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
80import { useCopyToClipboard } from "../hooks/useCopyToClipboard";80import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
81import { useNowMinute } from "../hooks/useNowMinute";
81import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";82import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
82import { useProjects, useThreadShells } from "../state/entities";83import { useProjects, useThreadShells } from "../state/entities";
83import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";84import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
...@@ -952,14 +953,7 @@ export default function SidebarV2() {...@@ -952,14 +953,7 @@ export default function SidebarV2() {
952953
953 // now is quantized to the minute so effectiveSettled memoization doesn't954 // now is quantized to the minute so effectiveSettled memoization doesn't
954 // churn on every render; auto-settle thresholds are day-granular anyway.955 // churn on every render; auto-settle thresholds are day-granular anyway.
955 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));956 const nowMinute = useNowMinute();
956 useEffect(() => {
957 const id = window.setInterval(
958 () => setNowMinute(new Date().toISOString().slice(0, 16)),
959 60_000,
960 );
961 return () => window.clearInterval(id);
962 }, []);
963957
964 // PR states stream in per-row (rows own the VCS subscriptions); a merged or958 // PR states stream in per-row (rows own the VCS subscriptions); a merged or
965 // closed PR auto-settles its thread on the next partition.959 // closed PR auto-settles its thread on the next partition.
...@@ -1223,11 +1217,24 @@ export default function SidebarV2() {...@@ -1223,11 +1217,24 @@ export default function SidebarV2() {
1223 lastSettledResetKeyRef.current = settledResetKey;1217 lastSettledResetKeyRef.current = settledResetKey;
1224 setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT);1218 setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT);
1225 }1219 }
1226 const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount);1220 const visibleSettledThreads = useMemo(() => {
1227 const visibleSettledThreads = useMemo(1221 if (settledThreads.length <= settledVisibleCount) return settledThreads;
1228 () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads),1222 const visible = settledThreads.slice(0, settledVisibleCount);
1229 [hiddenSettledCount, settledThreads, settledVisibleCount],1223 // The open thread must never hide under "Show more": navigating into a
1230 );1224 // deep settled thread (search, deep link) pulls its row into the visible
1225 // tail so the highlight and the un-settle affordance stay reachable.
1226 if (routeThreadKey !== null) {
1227 const routeThread = settledThreads
1228 .slice(settledVisibleCount)
1229 .find(
1230 (thread) =>
1231 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey,
1232 );
1233 if (routeThread !== undefined) visible.push(routeThread);
1234 }
1235 return visible;
1236 }, [routeThreadKey, settledThreads, settledVisibleCount]);
1237 const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length;
1231 const showMoreSettled = useCallback(1238 const showMoreSettled = useCallback(
1232 () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),1239 () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),
1233 [],1240 [],
apps/web/src/hooks/useNowMinute.ts created+69
...@@ -0,0 +1,69 @@
1import { useSyncExternalStore } from "react";
2
3/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution.
4 One module-level timer feeds every consumer through useSyncExternalStore,
5 so all surfaces resolving effectiveSettled against it (sidebar partition,
6 composer banner) share a single value by construction and tick on UTC
7 minute boundaries together. */
8
9function currentMinute(): string {
10 return new Date().toISOString().slice(0, 16);
11}
12
13let nowMinute = currentMinute();
14let timerId: number | null = null;
15let timerIsInterval = false;
16const listeners = new Set<() => void>();
17
18function tick(): void {
19 const next = currentMinute();
20 if (next !== nowMinute) {
21 nowMinute = next;
22 for (const listener of listeners) listener();
23 }
24}
25
26function startTimer(): void {
27 // Align to the next UTC minute boundary, then tick every 60s. Ticks re-read
28 // the clock, so a throttled or late timer self-corrects when it fires.
29 timerIsInterval = false;
30 timerId = window.setTimeout(
31 () => {
32 tick();
33 timerIsInterval = true;
34 timerId = window.setInterval(tick, 60_000);
35 },
36 60_000 - (Date.now() % 60_000),
37 );
38}
39
40function subscribe(listener: () => void): () => void {
41 if (listeners.size === 0) {
42 startTimer();
43 }
44 listeners.add(listener);
45 return () => {
46 listeners.delete(listener);
47 if (listeners.size === 0 && timerId !== null) {
48 if (timerIsInterval) window.clearInterval(timerId);
49 else window.clearTimeout(timerId);
50 timerId = null;
51 }
52 };
53}
54
55function getSnapshot(): string {
56 // With no timer running (no subscribers yet — e.g. the first render after
57 // a full unmount), the stored minute may be stale; re-read it so a fresh
58 // mount renders the current minute instead of waiting for the first tick.
59 // While the timer runs the cached value is returned untouched, as
60 // useSyncExternalStore requires between change notifications.
61 if (timerId === null) {
62 nowMinute = currentMinute();
63 }
64 return nowMinute;
65}
66
67export function useNowMinute(): string {
68 return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
69}