Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 5.2k
fix(web): keep settled threads reachable when opened directly#4413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
d0408833b9d473671013ed594e3471ccae418b3e5ebd0b8cb54b1618File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | null>(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)); | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }, [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<ComposerBannerStackItem | null>(() => { | ||
| if (!activeThreadSettled) { | ||
| return null; | ||
| } | ||
| return { | ||
| id: `thread-settled:${activeThread?.id ?? "unknown"}`, | ||
| variant: "info", | ||
| icon: <CheckCircle2Icon />, | ||
| title: "This thread is settled", | ||
| description: "Sending a message moves it back to Active in the sidebar.", | ||
| actions: ( | ||
| <Button | ||
| size="xs" | ||
| variant="outline" | ||
| disabled={isUnsettling} | ||
| onClick={() => void handleUnsettleActiveThread()} | ||
| > | ||
| {isUnsettling ? "Un-settling..." : "Un-settle"} | ||
| </Button> | ||
| ), | ||
| }; | ||
| }, [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<ComposerBannerStackItem[]>(() => { | ||
| 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, | ||
| ]); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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; | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| export function useNowMinute(): string { | ||
| return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.