diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 764ad164214..63750e91041 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,13 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { + CheckCircle2Icon, + ChevronDownIcon, + GitBranchIcon, + 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 +160,8 @@ 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 { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -202,6 +210,7 @@ import { useThread, useThreadProposedPlans, useThreadRefs, + useThreadShell, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; @@ -220,6 +229,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, @@ -3823,6 +3833,63 @@ 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 nowMinute = useNowMinute(); + const activeThreadSettled = useMemo(() => { + if (activeThreadShell === null || !supportsSettlement) return false; + return effectiveSettled(activeThreadShell, { + now: `${nowMinute}:00.000Z`, + autoSettleAfterDays, + changeRequestState: activeThreadPr?.state ?? null, + }); + }, [ + activeThreadPr?.state, + activeThreadShell, + autoSettleAfterDays, + nowMinute, + supportsSettlement, + ]); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); + // 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; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsettlingThreadKey(threadKey); + 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 { + setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsettleThreadMutation]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -3930,6 +3997,31 @@ function ChatViewContent(props: ChatViewProps) { switchGitRef, updateThreadMetadata, ]); + // The stack renders items[0] front-most and tucks the rest behind hover, so + // ordering is priority: system banners, then the branch-mismatch notice, + // and the informational settled banner last — it must never cover another. + 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 handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -3938,8 +4030,9 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { + const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return systemComposerBannerItems; + return [...systemComposerBannerItems, ...settledItems]; } return [ ...systemComposerBannerItems, @@ -3982,12 +4075,14 @@ function ChatViewContent(props: ChatViewProps) { setBranchMismatchDismissTick((tick) => tick + 1); }, }, + ...settledItems, ]; }, [ activeBranchMismatchKey, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, + settledComposerBannerItem, showBranchMismatchBanner, systemComposerBannerItems, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1a78ef54eff..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. @@ -1223,11 +1217,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), [], diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts new file mode 100644 index 00000000000..1b9f77b2189 --- /dev/null +++ b/apps/web/src/hooks/useNowMinute.ts @@ -0,0 +1,69 @@ +import { useSyncExternalStore } from "react"; + +/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. + 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) { + 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 { + // 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; +} + +export function useNowMinute(): string { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}