From d040883429e0e60aa9cd6882a44b085fe4ff7e40 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 17:59:55 -0700 Subject: [PATCH 1/7] fix(web): keep settled threads reachable when opened directly Opening an old thread via search left it invisible in the sidebar (below "Show more") with no way to un-settle it short of sending a message. Two changes: - SidebarV2: the route thread is force-revealed in the settled tail when it falls below the paging cutoff, so the active row and its un-settle affordance are always visible. - ChatView: a composer banner on settled threads states the settled state, explains that replying re-activates the thread, and offers an explicit Un-settle action (thread.unsettle, reason "user"). Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 79 +++++++++++++++++++++++++-- apps/web/src/components/SidebarV2.tsx | 23 ++++++-- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9a3eb10a18d..e9c932ab853 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,6 +25,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -138,7 +139,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { CheckCircle2Icon, ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -153,7 +154,7 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useEnvironmentSettings } from "../hooks/useSettings"; +import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -202,6 +203,7 @@ import { useThread, useThreadProposedPlans, useThreadRefs, + useThreadShell, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; @@ -220,6 +222,7 @@ import { shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -3809,6 +3812,51 @@ function ChatViewContent(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); + // Settled state of the open thread, resolved exactly like the sidebar + // partition (same shell, same capability gate, same PR auto-settle input) + // so the banner and the sidebar row never disagree. + const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); + const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const activeThreadPr = resolveThreadPr({ + threadBranch: activeThread?.branch ?? null, + gitStatus: gitStatusQuery.data ?? null, + hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, + }); + const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const activeThreadSettled = useMemo(() => { + if (activeThreadShell === null || !supportsSettlement) return false; + return effectiveSettled(activeThreadShell, { + now: new Date().toISOString(), + autoSettleAfterDays, + changeRequestState: activeThreadPr?.state ?? null, + }); + }, [activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, supportsSettlement]); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); + const [isUnsettling, setIsUnsettling] = useState(false); + const handleUnsettleActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + setIsUnsettling(true); + try { + const result = await unsettleThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setIsUnsettling(false); + } + }, [activeThreadRef, unsettleThreadMutation]); const [branchRepairAction, setBranchRepairAction] = useState< "update-thread" | "switch-checkout" | null >(null); @@ -3912,12 +3960,32 @@ function ChatViewContent(props: ChatViewProps) { updateThreadMetadata, ]); const composerBannerItems = useMemo(() => { + const items = [...systemComposerBannerItems]; + if (activeThreadSettled) { + items.push({ + id: `thread-settled:${activeThread?.id ?? "unknown"}`, + variant: "info", + icon: , + title: "This thread is settled", + description: "Sending a message moves it back to Active in the sidebar.", + actions: ( + + ), + }); + } if (!localCheckoutBranchMismatch) { - return systemComposerBannerItems; + return items; } const isRepairingBranch = branchRepairAction !== null; return [ - ...systemComposerBannerItems, + ...items, { id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, variant: "warning", @@ -3964,9 +4032,12 @@ function ChatViewContent(props: ChatViewProps) { ]; }, [ activeThread?.id, + activeThreadSettled, branchRepairAction, handleSwitchCheckoutToThread, + handleUnsettleActiveThread, handleUpdateThreadToCheckout, + isUnsettling, localCheckoutBranchMismatch, systemComposerBannerItems, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1a78ef54eff..67f4442da70 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1223,11 +1223,24 @@ export default function SidebarV2() { lastSettledResetKeyRef.current = settledResetKey; setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); } - const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); - const visibleSettledThreads = useMemo( - () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), - [hiddenSettledCount, settledThreads, settledVisibleCount], - ); + const visibleSettledThreads = useMemo(() => { + if (settledThreads.length <= settledVisibleCount) return settledThreads; + const visible = settledThreads.slice(0, settledVisibleCount); + // The open thread must never hide under "Show more": navigating into a + // deep settled thread (search, deep link) pulls its row into the visible + // tail so the highlight and the un-settle affordance stay reachable. + if (routeThreadKey !== null) { + const routeThread = settledThreads + .slice(settledVisibleCount) + .find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + if (routeThread !== undefined) visible.push(routeThread); + } + return visible; + }, [routeThreadKey, settledThreads, settledVisibleCount]); + const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], From 3b9d4739edd750e54e8e59e9e35292c74d3a2e0c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:05:11 -0700 Subject: [PATCH 2/7] fix(web): address settled-banner review findings - Drive the settled computation off a minute-quantized clock (same tick pattern as the sidebar partition) so inactivity auto-settle updates as time passes instead of freezing at mount. - Order the settled info banner last in the composer stack so the branch-mismatch warning and its repair actions stay front-most. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 76 ++++++++++++++++++---------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e9c932ab853..6c621b433e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3823,14 +3823,31 @@ function ChatViewContent(props: ChatViewProps) { hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + // Minute-quantized clock, same as the sidebar partition's: keeps this memo + // recomputing as time passes (inactivity auto-settle) without a per-render + // time source, and keeps banner and sidebar on the same tick. + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { - now: new Date().toISOString(), + now: `${nowMinute}:00.000Z`, autoSettleAfterDays, changeRequestState: activeThreadPr?.state ?? null, }); - }, [activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, supportsSettlement]); + }, [ + activeThreadPr?.state, + activeThreadShell, + autoSettleAfterDays, + nowMinute, + supportsSettlement, + ]); const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); @@ -3959,33 +3976,39 @@ function ChatViewContent(props: ChatViewProps) { switchGitRef, updateThreadMetadata, ]); - const composerBannerItems = useMemo(() => { - const items = [...systemComposerBannerItems]; - if (activeThreadSettled) { - items.push({ - id: `thread-settled:${activeThread?.id ?? "unknown"}`, - variant: "info", - icon: , - title: "This thread is settled", - description: "Sending a message moves it back to Active in the sidebar.", - actions: ( - - ), - }); + // The stack renders items[0] front-most and tucks the rest behind hover, so + // ordering is priority: system banners, then the branch-mismatch warning, + // and the informational settled banner last — it must never cover a warning. + const settledComposerBannerItem = useMemo(() => { + if (!activeThreadSettled) { + return null; } + return { + id: `thread-settled:${activeThread?.id ?? "unknown"}`, + variant: "info", + icon: , + title: "This thread is settled", + description: "Sending a message moves it back to Active in the sidebar.", + actions: ( + + ), + }; + }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]); + const composerBannerItems = useMemo(() => { + const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; if (!localCheckoutBranchMismatch) { - return items; + return [...systemComposerBannerItems, ...settledItems]; } const isRepairingBranch = branchRepairAction !== null; return [ - ...items, + ...systemComposerBannerItems, { id: `branch-mismatch:${activeThread?.id ?? "unknown"}:${localCheckoutBranchMismatch.threadBranch}:${localCheckoutBranchMismatch.currentBranch}`, variant: "warning", @@ -4029,16 +4052,15 @@ function ChatViewContent(props: ChatViewProps) { ), }, + ...settledItems, ]; }, [ activeThread?.id, - activeThreadSettled, branchRepairAction, handleSwitchCheckoutToThread, - handleUnsettleActiveThread, handleUpdateThreadToCheckout, - isUnsettling, localCheckoutBranchMismatch, + settledComposerBannerItem, systemComposerBannerItems, ]); From 671013ec09b499c03b24fd3f9812d734990c65f5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:25:19 -0700 Subject: [PATCH 3/7] fix(web): address CodeRabbit findings on settled banner - Reset isUnsettling when the active thread changes so a mid-flight un-settle on thread A never renders thread B's banner as pending (mirrors the isRevertingCheckpoint reset pattern). - Extract the duplicated minute-quantized clock into useNowMinute, shared by the sidebar partition and the composer banner. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 18 +++++++----------- apps/web/src/components/SidebarV2.tsx | 10 ++-------- apps/web/src/hooks/useNowMinute.ts | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/hooks/useNowMinute.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6c621b433e8..e27fe99d497 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -155,6 +155,7 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -3823,17 +3824,7 @@ function ChatViewContent(props: ChatViewProps) { hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; - // Minute-quantized clock, same as the sidebar partition's: keeps this memo - // recomputing as time passes (inactivity auto-settle) without a per-render - // time source, and keeps banner and sidebar on the same tick. - const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); - useEffect(() => { - const id = window.setInterval( - () => setNowMinute(new Date().toISOString().slice(0, 16)), - 60_000, - ); - return () => window.clearInterval(id); - }, []); + const nowMinute = useNowMinute(); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { @@ -3852,6 +3843,11 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const [isUnsettling, setIsUnsettling] = useState(false); + // The pending flag belongs to the thread that was un-settled: navigating + // to another settled thread mid-flight must not show it as "Un-settling...". + useEffect(() => { + setIsUnsettling(false); + }, [activeThread?.id]); const handleUnsettleActiveThread = useCallback(async () => { if (!activeThreadRef) return; setIsUnsettling(true); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 67f4442da70..c2995359034 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -78,6 +78,7 @@ import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -952,14 +953,7 @@ export default function SidebarV2() { // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. - const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); - useEffect(() => { - const id = window.setInterval( - () => setNowMinute(new Date().toISOString().slice(0, 16)), - 60_000, - ); - return () => window.clearInterval(id); - }, []); + const nowMinute = useNowMinute(); // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition. diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts new file mode 100644 index 00000000000..19c99ba1854 --- /dev/null +++ b/apps/web/src/hooks/useNowMinute.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. + Quantizing keeps consumers on the same tick, so surfaces resolving + effectiveSettled against it (sidebar partition, composer banner) can + never disagree within a minute. */ +export function useNowMinute(): string { + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + return nowMinute; +} From d594e34341d0197768722b2259a95e1049923b9d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:27:53 -0700 Subject: [PATCH 4/7] fix(web): align useNowMinute ticks to minute boundaries A mount-time-offset interval let two consumers hold different minute values for up to a minute after a boundary crossing, so the sidebar partition and composer banner could briefly disagree on settled state. Tick on UTC minute boundaries instead so all consumers cross together. Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useNowMinute.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 19c99ba1854..61faea05e9a 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,17 +1,27 @@ import { useEffect, useState } from "react"; /** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. - Quantizing keeps consumers on the same tick, so surfaces resolving - effectiveSettled against it (sidebar partition, composer banner) can - never disagree within a minute. */ + Ticks are aligned to UTC minute boundaries — not offset from mount time — + so every consumer (sidebar partition, composer banner) crosses each minute + together and effectiveSettled can never disagree between surfaces. */ export function useNowMinute(): string { const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); useEffect(() => { - const id = window.setInterval( - () => setNowMinute(new Date().toISOString().slice(0, 16)), - 60_000, + let intervalId: number | null = null; + const timeoutId = window.setTimeout( + () => { + setNowMinute(new Date().toISOString().slice(0, 16)); + intervalId = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + }, + 60_000 - (Date.now() % 60_000), ); - return () => window.clearInterval(id); + return () => { + window.clearTimeout(timeoutId); + if (intervalId !== null) window.clearInterval(intervalId); + }; }, []); return nowMinute; } From 71ccae4a57f748558bd2a2be66accbe660c25a7a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:40:05 -0700 Subject: [PATCH 5/7] fix(web): key the unsettle pending state by thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boolean isUnsettling raced across navigation: un-settling thread A, navigating to settled thread B, and un-settling B let A's finally clear B's pending state mid-flight. Track the un-settling thread's key instead — the banner derives its pending state per thread, and a resolving request can only clear its own key. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e27fe99d497..f379c38d42c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3842,15 +3842,15 @@ function ChatViewContent(props: ChatViewProps) { const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); - const [isUnsettling, setIsUnsettling] = useState(false); - // The pending flag belongs to the thread that was un-settled: navigating - // to another settled thread mid-flight must not show it as "Un-settling...". - useEffect(() => { - setIsUnsettling(false); - }, [activeThread?.id]); + // Keyed by thread, not a boolean: the pending state must follow the thread + // it belongs to across navigation, and a request resolving for thread A + // must never clear (or re-enable) thread B's button. + const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); + const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; const handleUnsettleActiveThread = useCallback(async () => { if (!activeThreadRef) return; - setIsUnsettling(true); + const threadKey = scopedThreadKey(activeThreadRef); + setUnsettlingThreadKey(threadKey); try { const result = await unsettleThreadMutation({ environmentId: activeThreadRef.environmentId, @@ -3867,7 +3867,7 @@ function ChatViewContent(props: ChatViewProps) { ); } } finally { - setIsUnsettling(false); + setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsettleThreadMutation]); const [branchRepairAction, setBranchRepairAction] = useState< From bd0b8cb82e5a8178fdd64c0a71786bb5214c2d59 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:53:30 -0700 Subject: [PATCH 6/7] fix(web): back useNowMinute with one shared module-level timer Per-instance state and timers meant two consumers could still hold different minute values (remount timing, throttled background timers) despite boundary alignment. A single module-level clock behind useSyncExternalStore gives every consumer the same value by construction; ticks re-read the clock so late timers self-correct, and the timer stops when the last subscriber leaves. Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useNowMinute.ts | 82 +++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 61faea05e9a..940c5f88519 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,27 +1,63 @@ -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; /** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. - Ticks are aligned to UTC minute boundaries — not offset from mount time — - so every consumer (sidebar partition, composer banner) crosses each minute - together and effectiveSettled can never disagree between surfaces. */ -export function useNowMinute(): string { - const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); - useEffect(() => { - let intervalId: number | null = null; - const timeoutId = window.setTimeout( - () => { - setNowMinute(new Date().toISOString().slice(0, 16)); - intervalId = window.setInterval( - () => setNowMinute(new Date().toISOString().slice(0, 16)), - 60_000, - ); - }, - 60_000 - (Date.now() % 60_000), - ); - return () => { - window.clearTimeout(timeoutId); - if (intervalId !== null) window.clearInterval(intervalId); - }; - }, []); + One module-level timer feeds every consumer through useSyncExternalStore, + so all surfaces resolving effectiveSettled against it (sidebar partition, + composer banner) share a single value by construction and tick on UTC + minute boundaries together. */ + +function currentMinute(): string { + return new Date().toISOString().slice(0, 16); +} + +let nowMinute = currentMinute(); +let timerId: number | null = null; +let timerIsInterval = false; +const listeners = new Set<() => void>(); + +function tick(): void { + const next = currentMinute(); + if (next !== nowMinute) { + nowMinute = next; + for (const listener of listeners) listener(); + } +} + +function startTimer(): void { + // Align to the next UTC minute boundary, then tick every 60s. Ticks re-read + // the clock, so a throttled or late timer self-corrects when it fires. + timerIsInterval = false; + timerId = window.setTimeout( + () => { + tick(); + timerIsInterval = true; + timerId = window.setInterval(tick, 60_000); + }, + 60_000 - (Date.now() % 60_000), + ); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0) { + // The stored minute may have gone stale while no one was subscribed. + nowMinute = currentMinute(); + startTimer(); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && timerId !== null) { + if (timerIsInterval) window.clearInterval(timerId); + else window.clearTimeout(timerId); + timerId = null; + } + }; +} + +function getSnapshot(): string { return nowMinute; } + +export function useNowMinute(): string { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} From 54b16180afd6347a032b613c951d7f78ca82b404 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 18:59:38 -0700 Subject: [PATCH 7/7] fix(web): re-read useNowMinute snapshot when no timer is live After the last consumer unmounted, the module-level minute froze and a remount's first render served the stale value (subscribe refreshed it without notifying React). getSnapshot now re-reads the clock whenever no timer is running, so a fresh mount renders the current minute; while subscribed, the cached snapshot stays stable as React requires. Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useNowMinute.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 940c5f88519..1b9f77b2189 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -39,8 +39,6 @@ function startTimer(): void { function subscribe(listener: () => void): () => void { if (listeners.size === 0) { - // The stored minute may have gone stale while no one was subscribed. - nowMinute = currentMinute(); startTimer(); } listeners.add(listener); @@ -55,6 +53,14 @@ function subscribe(listener: () => void): () => void { } function getSnapshot(): string { + // With no timer running (no subscribers yet — e.g. the first render after + // a full unmount), the stored minute may be stale; re-read it so a fresh + // mount renders the current minute instead of waiting for the first tick. + // While the timer runs the cached value is returned untouched, as + // useSyncExternalStore requires between change notifications. + if (timerId === null) { + nowMinute = currentMinute(); + } return nowMinute; }