Skip to content
101 changes: 98 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -202,6 +210,7 @@ import {
useThread,
useThreadProposedPlans,
useThreadRefs,
useThreadShell,
} from "../state/entities";
import { environmentShell } from "../state/shell";
import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
Expand All @@ -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,
Expand Down Expand Up @@ -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,
});
Comment thread
cursor[bot] marked this conversation as resolved.
}, [
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));
}
Comment thread
cursor[bot] marked this conversation as resolved.
}, [activeThreadRef, unsettleThreadMutation]);
const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
// Once revealed for a given mismatch, the banner stays mounted until the
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -3982,12 +4075,14 @@ function ChatViewContent(props: ChatViewProps) {
setBranchMismatchDismissTick((tick) => tick + 1);
},
},
...settledItems,
];
}, [
activeBranchMismatchKey,
handleRestoreThreadBranch,
isRestoringThreadBranch,
localCheckoutBranchMismatch,
settledComposerBannerItem,
showBranchMismatchBanner,
systemComposerBannerItems,
]);
Expand Down
33 changes: 20 additions & 13 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
[],
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/hooks/useNowMinute.ts
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();
}
Comment thread
cursor[bot] marked this conversation as resolved.
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;
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function useNowMinute(): string {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
Loading