| ... | ... | @@ -39,7 +39,6 @@ import { feedbackBannerItem } from "./chat/ComposerFeedback"; |
| 39 | 39 | import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; |
| 40 | 40 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 41 | 41 | import * as Schema from "effect/Schema"; |
| 42 | | import { Minimize2Icon } from "lucide-react"; |
| 43 | 42 | import { |
| 44 | 43 | questionAttachmentDraftId, |
| 45 | 44 | questionAttachmentDraftPrefix, |
| ... | ... | @@ -486,6 +485,8 @@ import { |
| 486 | 485 | } from "./chat/QueuedRunsControl"; |
| 487 | 486 | import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators"; |
| 488 | 487 | import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 488 | import { ThreadStatusLine } from "./chat/ThreadStatusLine"; |
| 489 | import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat"; |
| 489 | 490 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 490 | 491 | import { resolveThreadSyncPhase } from "../threadSync"; |
| 491 | 492 | import { |
| ... | ... | @@ -493,7 +494,7 @@ import { |
| 493 | 494 | hasDismissedResumeCompaction, |
| 494 | 495 | shouldOfferResumeCompaction, |
| 495 | 496 | } from "./chat/ContextWindowMeter.logic"; |
| 496 | | import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; |
| 497 | import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; |
| 497 | 498 | import { |
| 498 | 499 | DRAFT_HERO_TRANSITION_ANIMATION_ID, |
| 499 | 500 | DRAFT_HERO_TRANSITION_EASING, |
| ... | ... | @@ -7563,73 +7564,61 @@ export default function ChatView(props: ChatViewProps) { |
| 7563 | 7564 | isStoppingBackgroundWork, |
| 7564 | 7565 | onOpenRelatedThread, |
| 7565 | 7566 | ]); |
| 7566 | | // A woken thread announces itself in the open view, not just the sidebar |
| 7567 | | // pill. Dismissing marks the wake as seen (same acknowledgment as the |
| 7568 | | // pill); sending a message clears it as a side effect of the send path. |
| 7569 | | const wokeThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 7570 | | if (!activeThreadWokeVisible) { |
| 7571 | | return null; |
| 7572 | | } |
| 7573 | | return { |
| 7574 | | id: `thread-woke:${activeThread?.id ?? "unknown"}`, |
| 7575 | | variant: "info", |
| 7576 | | icon: <AlarmClockIcon />, |
| 7577 | | title: "Thread woke from snooze", |
| 7578 | | description: "Send a message to continue", |
| 7579 | | dismissLabel: "Dismiss Woke notification", |
| 7580 | | onDismiss: acknowledgeActiveThreadWoke, |
| 7581 | | }; |
| 7582 | | }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); |
| 7583 | | const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 7584 | | if (!activeThreadSnoozed && !activeThreadSettled) { |
| 7585 | | return null; |
| 7586 | | } |
| 7587 | | const isSnoozed = activeThreadSnoozed; |
| 7588 | | return { |
| 7589 | | id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, |
| 7590 | | variant: "info", |
| 7591 | | icon: isSnoozed ? <AlarmClockIcon /> : <CheckCircle2Icon />, |
| 7592 | | title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, |
| 7593 | | description: `Send a message to ${isSnoozed ? "wake" : "unsettle"}`, |
| 7594 | | actions: ( |
| 7595 | | <Button |
| 7596 | | size="xs" |
| 7597 | | variant="ghost" |
| 7598 | | disabled={!canOperateThread || (isSnoozed ? isUnsnoozing : isUnsettling)} |
| 7599 | | onClick={() => |
| 7600 | | void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread()) |
| 7601 | | } |
| 7602 | | > |
| 7603 | | {isSnoozed |
| 7604 | | ? isUnsnoozing |
| 7605 | | ? "Waking..." |
| 7606 | | : "Wake now" |
| 7607 | | : isUnsettling |
| 7608 | | ? "Un-settling..." |
| 7609 | | : "Un-settle"} |
| 7610 | | </Button> |
| 7611 | | ), |
| 7612 | | }; |
| 7567 | // Settled, snoozed, and woke are thread state, not composer actions: each |
| 7568 | // gets one quiet line after the last message instead of a banner. A woken |
| 7569 | // thread announces itself here, not just in the sidebar pill. Dismissing |
| 7570 | // marks the wake as seen (same acknowledgment as the pill); sending a |
| 7571 | // message clears it as a side effect of the send path. |
| 7572 | // Memoized: it is the timeline's list footer, and a new element re-renders that footer. |
| 7573 | // nowMinute keeps the relative time fresh. |
| 7574 | const threadStatusLine = useMemo(() => { |
| 7575 | void nowMinute; |
| 7576 | return activeThreadSnoozed ? ( |
| 7577 | <ThreadStatusLine |
| 7578 | icon={<AlarmClockIcon />} |
| 7579 | label={ |
| 7580 | activeThreadShell?.snoozedUntil |
| 7581 | ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}` |
| 7582 | : "Snoozed" |
| 7583 | } |
| 7584 | actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} |
| 7585 | actionDisabled={!canOperateThread || isUnsnoozing} |
| 7586 | onAction={() => void handleUnsnoozeActiveThread()} |
| 7587 | /> |
| 7588 | ) : activeThreadSettled ? ( |
| 7589 | <ThreadStatusLine |
| 7590 | icon={<CheckCircle2Icon />} |
| 7591 | label={ |
| 7592 | activeThreadShell?.settledAt |
| 7593 | ? `Settled ${formatRelativeTimeLabel(activeThreadShell.settledAt)}` |
| 7594 | : "Settled" |
| 7595 | } |
| 7596 | actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"} |
| 7597 | actionDisabled={!canOperateThread || isUnsettling} |
| 7598 | onAction={() => void handleUnsettleActiveThread()} |
| 7599 | /> |
| 7600 | ) : activeThreadWokeVisible ? ( |
| 7601 | <ThreadStatusLine |
| 7602 | icon={<AlarmClockIcon />} |
| 7603 | label="Woke from snooze" |
| 7604 | actionLabel="Dismiss" |
| 7605 | onAction={acknowledgeActiveThreadWoke} |
| 7606 | /> |
| 7607 | ) : null; |
| 7613 | 7608 | }, [ |
| 7614 | | activeThread?.id, |
| 7609 | acknowledgeActiveThreadWoke, |
| 7615 | 7610 | activeThreadSettled, |
| 7611 | activeThreadShell?.settledAt, |
| 7612 | activeThreadShell?.snoozedUntil, |
| 7616 | 7613 | activeThreadSnoozed, |
| 7614 | activeThreadWokeVisible, |
| 7617 | 7615 | canOperateThread, |
| 7618 | | handleUnsnoozeActiveThread, |
| 7619 | 7616 | handleUnsettleActiveThread, |
| 7620 | | isUnsnoozing, |
| 7617 | handleUnsnoozeActiveThread, |
| 7621 | 7618 | isUnsettling, |
| 7619 | isUnsnoozing, |
| 7620 | nowMinute, |
| 7622 | 7621 | ]); |
| 7623 | | // Session-scoped dismissals, one key per (thread, snapshot). A set rather |
| 7624 | | // than a single slot so dismissing the banner on one thread does not |
| 7625 | | // resurface it on another thread dismissed earlier. |
| 7626 | | const [dismissedResumeCompactionKeys, setDismissedResumeCompactionKeys] = useState< |
| 7627 | | ReadonlySet<string> |
| 7628 | | >(new Set()); |
| 7629 | | const resumeCompactionKey = |
| 7630 | | activeThread && activeContextWindow |
| 7631 | | ? `${activeThread.id}:${activeContextWindow.updatedAt}` |
| 7632 | | : null; |
| 7633 | 7622 | const activeThreadHasCompactableConversation = serverVisibleTurnItems.some( |
| 7634 | 7623 | ({ item }) => |
| 7635 | 7624 | item.type === "user_message" && |
| ... | ... | @@ -7661,73 +7650,36 @@ export default function ChatView(props: ChatViewProps) { |
| 7661 | 7650 | ? "Compaction is unavailable for this provider" |
| 7662 | 7651 | : "Compacting is unavailable right now" |
| 7663 | 7652 | : null; |
| 7664 | | const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 7665 | | if ( |
| 7666 | | !activeThread || |
| 7667 | | !activeContextWindow || |
| 7668 | | resumeCompactionKey === null || |
| 7669 | | dismissedResumeCompactionKeys.has(resumeCompactionKey) || |
| 7670 | | resumeCompactionPermanentlyDismissed || |
| 7671 | | nativeResumeCompactionDismissed || |
| 7672 | | pendingUserInputs.length > 0 || |
| 7673 | | phase === "running" || |
| 7674 | | !shouldOfferResumeCompaction({ |
| 7675 | | provider: selectedProvider, |
| 7676 | | usedTokens: activeContextWindow.usedTokens, |
| 7677 | | updatedAt: activeContextWindow.updatedAt, |
| 7678 | | now: `${nowMinute}:00.000Z`, |
| 7679 | | }) |
| 7680 | | ) { |
| 7681 | | return null; |
| 7653 | // Tokens a stale Claude session would re-read on its next turn. While set, |
| 7654 | // Enter compacts first and the composer's send button says so; "Send with |
| 7655 | // full history" in its menu skips that once. Held queues and multi-model |
| 7656 | // sends never compact first, so the offer hides for them. |
| 7657 | const resumeCompactionTokens = |
| 7658 | activeContextWindow && |
| 7659 | !resumeCompactionPermanentlyDismissed && |
| 7660 | !nativeResumeCompactionDismissed && |
| 7661 | !compactDisabled && |
| 7662 | !hasHeldQueuedRuns && |
| 7663 | multipleModelSelections === null && |
| 7664 | shouldOfferResumeCompaction({ |
| 7665 | provider: selectedProvider, |
| 7666 | usedTokens: activeContextWindow.usedTokens, |
| 7667 | updatedAt: activeContextWindow.updatedAt, |
| 7668 | now: `${nowMinute}:00.000Z`, |
| 7669 | }) |
| 7670 | ? activeContextWindow.usedTokens |
| 7671 | : null; |
| 7672 | // Set only for the synchronous span of a "Send with full history" submit; |
| 7673 | // onSend reads it before its first await. |
| 7674 | const keepFullHistoryOnceRef = useRef(false); |
| 7675 | const sendWithFullHistory = useCallback((send: () => void) => { |
| 7676 | keepFullHistoryOnceRef.current = true; |
| 7677 | try { |
| 7678 | send(); |
| 7679 | } finally { |
| 7680 | keepFullHistoryOnceRef.current = false; |
| 7682 | 7681 | } |
| 7683 | | |
| 7684 | | const dismiss = () => |
| 7685 | | setDismissedResumeCompactionKeys((keys) => new Set(keys).add(resumeCompactionKey)); |
| 7686 | | const compactAction = ( |
| 7687 | | <Button |
| 7688 | | size="xs" |
| 7689 | | variant="ghost" |
| 7690 | | disabled={compactDisabled} |
| 7691 | | onClick={() => { |
| 7692 | | if (compactDisabled) return; |
| 7693 | | composerRef.current?.compactContext(); |
| 7694 | | }} |
| 7695 | | > |
| 7696 | | Compact |
| 7697 | | </Button> |
| 7698 | | ); |
| 7699 | | return { |
| 7700 | | id: `resume-compaction:${resumeCompactionKey}`, |
| 7701 | | variant: "info", |
| 7702 | | icon: <Minimize2Icon />, |
| 7703 | | title: "Resume with less context", |
| 7704 | | description: `${formatContextWindowTokens(activeContextWindow.usedTokens)} tokens from earlier`, |
| 7705 | | actions: compactDisabledReason ? ( |
| 7706 | | <Tooltip> |
| 7707 | | <TooltipTrigger render={<span className="inline-flex">{compactAction}</span>} /> |
| 7708 | | <TooltipPopup side="top">{compactDisabledReason}</TooltipPopup> |
| 7709 | | </Tooltip> |
| 7710 | | ) : ( |
| 7711 | | compactAction |
| 7712 | | ), |
| 7713 | | dismissLabel: "Keep full history", |
| 7714 | | onDismiss: dismiss, |
| 7715 | | }; |
| 7716 | | }, [ |
| 7717 | | activeContextWindow, |
| 7718 | | activeThread, |
| 7719 | | compactDisabled, |
| 7720 | | compactDisabledReason, |
| 7721 | | composerRef, |
| 7722 | | dismissedResumeCompactionKeys, |
| 7723 | | nativeResumeCompactionDismissed, |
| 7724 | | nowMinute, |
| 7725 | | pendingUserInputs.length, |
| 7726 | | phase, |
| 7727 | | resumeCompactionKey, |
| 7728 | | resumeCompactionPermanentlyDismissed, |
| 7729 | | selectedProvider, |
| 7730 | | ]); |
| 7682 | }, []); |
| 7731 | 7683 | const handleRestoreThreadBranch = useCallback(() => { |
| 7732 | 7684 | if (!canWriteSourceControl) return; |
| 7733 | 7685 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| ... | ... | @@ -7779,10 +7731,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7779 | 7731 | const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( |
| 7780 | 7732 | (item) => item !== null, |
| 7781 | 7733 | ); |
| 7782 | | const resumeCompactionItems = |
| 7783 | | resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; |
| 7784 | | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; |
| 7785 | | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 7786 | 7734 | // The user asked for this one, so it leads the notice tier instead of trailing it. |
| 7787 | 7735 | const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; |
| 7788 | 7736 | const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; |
| ... | ... | @@ -7794,9 +7742,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7794 | 7742 | ...projectCloneItems, |
| 7795 | 7743 | ...systemComposerBannerItems, |
| 7796 | 7744 | ...backgroundWorkItems, |
| 7797 | | ...resumeCompactionItems, |
| 7798 | | ...wokeThreadItems, |
| 7799 | | ...parkedThreadItems, |
| 7800 | 7745 | ]; |
| 7801 | 7746 | } |
| 7802 | 7747 | return [ |
| ... | ... | @@ -7806,8 +7751,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7806 | 7751 | ...projectCloneItems, |
| 7807 | 7752 | ...systemComposerBannerItems, |
| 7808 | 7753 | ...backgroundWorkItems, |
| 7809 | | ...resumeCompactionItems, |
| 7810 | | ...wokeThreadItems, |
| 7811 | 7754 | { |
| 7812 | 7755 | id: `branch-mismatch:${activeBranchMismatchKey}`, |
| 7813 | 7756 | variant: "info", |
| ... | ... | @@ -7846,7 +7789,6 @@ export default function ChatView(props: ChatViewProps) { |
| 7846 | 7789 | setBranchMismatchDismissTick((tick) => tick + 1); |
| 7847 | 7790 | }, |
| 7848 | 7791 | }, |
| 7849 | | ...parkedThreadItems, |
| 7850 | 7792 | ]; |
| 7851 | 7793 | }, [ |
| 7852 | 7794 | activeBranchMismatchKey, |
| ... | ... | @@ -7860,13 +7802,10 @@ export default function ChatView(props: ChatViewProps) { |
| 7860 | 7802 | backgroundWorkBannerItem, |
| 7861 | 7803 | goalBannerItem, |
| 7862 | 7804 | localCheckoutBranchMismatch, |
| 7863 | | parkedThreadBannerItem, |
| 7864 | 7805 | projectCloneBannerItem, |
| 7865 | | resumeCompactionBannerItem, |
| 7866 | 7806 | showBranchMismatchBanner, |
| 7867 | 7807 | systemComposerBannerItems, |
| 7868 | 7808 | usageLimitsBanner, |
| 7869 | | wokeThreadBannerItem, |
| 7870 | 7809 | ]); |
| 7871 | 7810 | |
| 7872 | 7811 | useEffect(() => { |
| ... | ... | @@ -8692,6 +8631,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8692 | 8631 | }, |
| 8693 | 8632 | ) => { |
| 8694 | 8633 | e?.preventDefault(); |
| 8634 | const keepFullHistory = keepFullHistoryOnceRef.current; |
| 8695 | 8635 | // Typed out in full rather than picked from the menu. Attachments or contexts |
| 8696 | 8636 | // mean the user is sending a prompt, so those go through as usual. |
| 8697 | 8637 | if ( |
| ... | ... | @@ -9216,7 +9156,16 @@ export default function ChatView(props: ChatViewProps) { |
| 9216 | 9156 | ); |
| 9217 | 9157 | const messageIdForSend = newMessageId(); |
| 9218 | 9158 | const messageCreatedAt = new Date().toISOString(); |
| 9219 | | const shouldQueueBehindActiveRun = phase === "running" && dispatchMode === "queue"; |
| 9159 | // A stale Claude session compacts before this message so the turn does not re-read the |
| 9160 | // old history. The message queues behind the /compact run, since steering into it is |
| 9161 | // rejected. |
| 9162 | const compactBeforeSend = |
| 9163 | resumeCompactionTokens !== null && |
| 9164 | !keepFullHistory && |
| 9165 | messageTextForSend.toLowerCase() !== "/compact"; |
| 9166 | const turnDispatchMode = compactBeforeSend ? "queue" : dispatchMode; |
| 9167 | const shouldQueueBehindActiveRun = |
| 9168 | compactBeforeSend || (phase === "running" && dispatchMode === "queue"); |
| 9220 | 9169 | const outgoingMessageText = formatOutgoingPrompt({ |
| 9221 | 9170 | provider: ctxSelectedProvider, |
| 9222 | 9171 | model: ctxSelectedModel, |
| ... | ... | @@ -9799,6 +9748,22 @@ export default function ChatView(props: ChatViewProps) { |
| 9799 | 9748 | failure = turnAttachmentsResult; |
| 9800 | 9749 | } |
| 9801 | 9750 | |
| 9751 | if (failure === null && compactBeforeSend) { |
| 9752 | const compactResult = await startThreadTurn({ |
| 9753 | environmentId, |
| 9754 | input: { |
| 9755 | threadId: threadIdForSend, |
| 9756 | message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] }, |
| 9757 | modelSelection: ctxSelectedModelSelection, |
| 9758 | runtimeMode, |
| 9759 | interactionMode: sendInteractionMode, |
| 9760 | }, |
| 9761 | }); |
| 9762 | if (compactResult._tag === "Failure") { |
| 9763 | failure = compactResult; |
| 9764 | } |
| 9765 | } |
| 9766 | |
| 9802 | 9767 | let backgroundDraftOpened = false; |
| 9803 | 9768 | let turnStartSucceeded = false; |
| 9804 | 9769 | if (failure === null && turnAttachmentsResult._tag === "Success") { |
| ... | ... | @@ -9876,7 +9841,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9876 | 9841 | titleSeed: title, |
| 9877 | 9842 | runtimeMode, |
| 9878 | 9843 | interactionMode: sendInteractionMode, |
| 9879 | | dispatchMode, |
| 9844 | dispatchMode: turnDispatchMode, |
| 9880 | 9845 | ...(bootstrap ? { bootstrap } : {}), |
| 9881 | 9846 | createdAt: messageCreatedAt, |
| 9882 | 9847 | }, |
| ... | ... | @@ -11324,6 +11289,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11324 | 11289 | {...(draftId ? { onWorktreeSetupWorkLocally } : {})} |
| 11325 | 11290 | {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} |
| 11326 | 11291 | isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} |
| 11292 | footer={paintOnlyDisplayedTimeline ? null : threadStatusLine} |
| 11327 | 11293 | listRef={legendListRef} |
| 11328 | 11294 | timelineEntries={displayedTimeline.entries} |
| 11329 | 11295 | providerStatuses={ |
| ... | ... | @@ -11575,6 +11541,8 @@ export default function ChatView(props: ChatViewProps) { |
| 11575 | 11541 | ) : null |
| 11576 | 11542 | } |
| 11577 | 11543 | bannerItems={composerBannerItems} |
| 11544 | resumeCompactionTokens={resumeCompactionTokens} |
| 11545 | onSendWithFullHistory={sendWithFullHistory} |
| 11578 | 11546 | // With attachments or contexts aboard the pick just inserts the |
| 11579 | 11547 | // text, so it sends as a prompt like the typed path would. |
| 11580 | 11548 | onUsageLimitsCommand={ |