Skip to content
95 changes: 92 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,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";
Expand All @@ -153,7 +154,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 +204,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 +223,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 @@ -3809,6 +3813,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,
});
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);
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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}, [activeThreadRef, unsettleThreadMutation]);
const [branchRepairAction, setBranchRepairAction] = useState<
"update-thread" | "switch-checkout" | null
>(null);
Expand Down Expand Up @@ -3911,9 +3972,35 @@ 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 warning,
// and the informational settled banner last — it must never cover a warning.
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 composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem];
if (!localCheckoutBranchMismatch) {
return systemComposerBannerItems;
return [...systemComposerBannerItems, ...settledItems];
}
const isRepairingBranch = branchRepairAction !== null;
return [
Expand Down Expand Up @@ -3961,13 +4048,15 @@ function ChatViewContent(props: ChatViewProps) {
</>
),
},
...settledItems,
];
}, [
activeThread?.id,
branchRepairAction,
handleSwitchCheckoutToThread,
handleUpdateThreadToCheckout,
localCheckoutBranchMismatch,
settledComposerBannerItem,
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
27 changes: 27 additions & 0 deletions apps/web/src/hooks/useNowMinute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect, useState } 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);
};
}, []);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
return nowMinute;
Comment thread
cursor[bot] marked this conversation as resolved.
}
Loading