diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63750e91041..ab1256cddb3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,7 +25,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -140,6 +140,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { + AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, @@ -3844,7 +3845,24 @@ function ChatViewContent(props: ChatViewProps) { hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; + const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); + const activeThreadSnoozed = + activeThreadShell !== null && + supportsSnooze && + effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + useEffect(() => { + void snoozeWakeTick; + if (!activeThreadSnoozed) return; + const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); + if (!Number.isFinite(wakeAtMs)) return; + const id = window.setTimeout( + () => bumpSnoozeWakeTick((tick) => tick + 1), + Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), + ); + return () => window.clearTimeout(id); + }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { @@ -3890,6 +3908,34 @@ function ChatViewContent(props: ChatViewProps) { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsettleThreadMutation]); + const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { + reportFailure: false, + }); + const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); + const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; + const handleUnsnoozeActiveThread = useCallback(async () => { + if (!activeThreadRef) return; + const threadKey = scopedThreadKey(activeThreadRef); + setUnsnoozingThreadKey(threadKey); + try { + const result = await unsnoozeThreadMutation({ + 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 wake thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + } finally { + setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); + } + }, [activeThreadRef, unsnoozeThreadMutation]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -3999,29 +4045,48 @@ function ChatViewContent(props: ChatViewProps) { ]); // 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) { + // and the informational parked-thread banner last โ€” it must never cover another. + const parkedThreadBannerItem = useMemo(() => { + if (!activeThreadSnoozed && !activeThreadSettled) { return null; } + const isSnoozed = activeThreadSnoozed; return { - id: `thread-settled:${activeThread?.id ?? "unknown"}`, + id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, variant: "info", - icon: , - title: "This thread is settled", - description: "Sending a message moves it back to Active in the sidebar.", + icon: isSnoozed ? : , + title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, + description: isSnoozed + ? "Sending a message wakes it and moves it back to Active in the sidebar." + : "Sending a message moves it back to Active in the sidebar.", actions: ( ), }; - }, [activeThread?.id, activeThreadSettled, handleUnsettleActiveThread, isUnsettling]); + }, [ + activeThread?.id, + activeThreadSettled, + activeThreadSnoozed, + handleUnsnoozeActiveThread, + handleUnsettleActiveThread, + isUnsnoozing, + isUnsettling, + ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); @@ -4030,9 +4095,9 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { - const settledItems = settledComposerBannerItem === null ? [] : [settledComposerBannerItem]; + const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { - return [...systemComposerBannerItems, ...settledItems]; + return [...systemComposerBannerItems, ...parkedThreadItems]; } return [ ...systemComposerBannerItems, @@ -4075,14 +4140,14 @@ function ChatViewContent(props: ChatViewProps) { setBranchMismatchDismissTick((tick) => tick + 1); }, }, - ...settledItems, + ...parkedThreadItems, ]; }, [ activeBranchMismatchKey, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, - settledComposerBannerItem, + parkedThreadBannerItem, showBranchMismatchBanner, systemComposerBannerItems, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 66572e9aec1..e018491348e 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -316,7 +316,7 @@ function SnoozePopoverButton(props: { aria-label="Snooze thread" onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md border border-sidebar-border bg-sidebar-row-hover px-1.5 text-xs text-muted-foreground hover:text-foreground dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" /> } > @@ -770,8 +770,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( // Snoozed rows show when they come BACK, not when they were // last touched โ€” the return ticket is the row's whole story. - - + {props.snoozeWakeLabelText} ) : isWoke ? ( @@ -799,7 +798,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Wake thread now" onClick={handleUnsnoozeClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > @@ -911,7 +910,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.settlementSupported || showSnoozeButton ? ( @@ -1467,6 +1466,17 @@ export default function SidebarV2() { () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], ); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const renderedSettledThreads = useMemo(() => { + if (settledShelfExpanded) return visibleSettledThreads; + if (routeThreadKey === null) return []; + const routeThread = visibleSettledThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -1488,8 +1498,8 @@ export default function SidebarV2() { }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSnoozedThreads, ...visibleSettledThreads], - [activeThreads, visibleSnoozedThreads, visibleSettledThreads], + () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -2341,7 +2351,7 @@ export default function SidebarV2() { ) : null} - + - + {snoozedShelfExpanded ? "Snoozed" : `Snoozed (${snoozedThreads.length})`} + + + - - Snoozed ยท {snoozedThreads.length} - - , ); @@ -2468,38 +2478,37 @@ export default function SidebarV2() { items.push(renderThreadRow(thread, "snoozed")); } } - // The divider is its own keyed list item (not part of the - // first settled row): it keeps one stable DOM node at the - // boundary, so settling a thread slides it instead of - // teleporting it along with whichever row happens to be - // first in the tail. Matching the pre-shelf behavior, it - // only shows when something sits above it. - if ( - visibleSettledThreads.length > 0 && - (activeThreads.length > 0 || snoozedThreads.length > 0) - ) { + if (settledThreads.length > 0) { items.push( -
  • -
    +
  • +
  • , ); } - for (const thread of visibleSettledThreads) { + for (const thread of renderedSettledThreads) { items.push(renderThreadRow(thread, "settled")); } return items; })()} - {hiddenSettledCount > 0 ? ( + {settledShelfExpanded && hiddenSettledCount > 0 ? (