From 2e66b1fdfc63d39822cd58948bcf43e8fbc239b6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 06:10:36 -0400 Subject: [PATCH 1/4] fix(web): live background-work banner no longer hides behind the update notice (#5595) Co-authored-by: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 23 +++++++++++++++---- .../chat/ComposerBannerStack.test.tsx | 20 ++++++++++++++-- .../components/chat/ComposerBannerStack.tsx | 20 ++++++++++++++-- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f8b7ee509b..352e23c3b1ce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1934,6 +1934,8 @@ function ChatViewContent(props: ChatViewProps) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", + // Live connection status: calm styling, but it must front the stack. + urgent: true, icon: ( (() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; @@ -4423,21 +4432,27 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { + const isUrgentSystemItem = (item: ComposerBannerStackItem) => + item.urgent === true || item.variant === "error" || item.variant === "warning"; + const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem); + const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ - ...systemComposerBannerItems, + ...urgentSystemItems, ...backgroundLivenessItems, + ...calmSystemItems, ...wokeThreadItems, ...parkedThreadItems, ]; } return [ - ...systemComposerBannerItems, + ...urgentSystemItems, ...backgroundLivenessItems, + ...calmSystemItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 1b592168c20d..6eed4fb05315 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -3,9 +3,12 @@ import { describe, expect, it } from "vite-plus/test"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; -const banner = (id: string): ComposerBannerStackItem => ({ +const banner = ( + id: string, + variant: ComposerBannerStackItem["variant"] = "warning", +): ComposerBannerStackItem => ({ id, - variant: "warning", + variant, icon: , title: `${id} warning`, }); @@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => { expect(markup).toContain("group-focus-within/banner-stack:visible"); }); + it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => { + const neutralBehind = renderToStaticMarkup( + , + ); + expect(neutralBehind).toContain("border-border"); + expect(neutralBehind).not.toContain("border-warning/24"); + + const warningBehind = renderToStaticMarkup( + , + ); + expect(warningBehind).toContain("border-warning/24"); + }); + it("does not render an expandable region for a single banner", () => { const markup = renderToStaticMarkup(); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 548bd0f4262e..41a717d07ce4 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -22,9 +22,23 @@ const exitTransitionStyle = { transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, } satisfies CSSProperties; +// The collapsed cap peeking above the front banner is the only hint that more +// banners are stacked behind it, so its border must match the severity of the +// first hidden banner — a neutral banner must not masquerade as a warning. +const stackCapBorderClass: Record = { + default: "border-border", + error: "border-destructive/24", + info: "border-info/24", + success: "border-success/24", + warning: "border-warning/24", +}; + export interface ComposerBannerStackItem { readonly id: string; readonly variant: "default" | "error" | "info" | "success" | "warning"; + // Ordering hint for stack assemblers: front this banner even though its + // variant is calm (e.g. live update progress). The stack itself ignores it. + readonly urgent?: boolean; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; @@ -67,6 +81,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro const stackedItems = items.slice(1); const hasStack = stackedItems.length > 0; const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; + const firstStackedItem = stackedItems[0]; const requestDismiss = (item: ComposerBannerStackItem) => { if (!item.onDismiss || exitingItemId) { @@ -90,11 +105,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null, )} > - {showCollapsedStackCap ? ( + {showCollapsedStackCap && firstStackedItem ? (
Date: Fri, 7 Aug 2026 11:12:19 +0100 Subject: [PATCH 2/4] fix(web): stabilize chat timeline positioning (#5449) Co-authored-by: codex --- apps/web/package.json | 2 +- apps/web/src/components/ChatView.tsx | 136 ++++------------ .../components/chat/MessagesTimeline.test.tsx | 14 +- .../src/components/chat/MessagesTimeline.tsx | 153 ++++++++++-------- pnpm-lock.yaml | 30 ++-- 5 files changed, 133 insertions(+), 202 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 83f55abcfd58..0a2f0d8e86b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.2.0", + "@legendapp/list": "3.3.3", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 352e23c3b1ce..06aceffad657 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3532,12 +3532,8 @@ function ChatViewContent(props: ChatViewProps) { const activeTimelineAnchorIndexRef = useRef(null); const anchorUserScrollGenerationRef = useRef(0); const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); + // Manual navigation stops live-follow without removing anchored end space. + // Collapsing that space during a gesture clamps the viewport back to the end. const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; @@ -3547,11 +3543,6 @@ function ChatViewContent(props: ChatViewProps) { positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, @@ -3607,7 +3598,6 @@ function ChatViewContent(props: ChatViewProps) { }, [composerOverlayHeight], ); - // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { @@ -3619,7 +3609,12 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - void legendListRef.current?.scrollToEnd?.({ animated }); + setTimelineAnchor((current) => + current.messageId === null ? current : { ...current, messageId: null }, + ); + requestAnimationFrame(() => { + void legendListRef.current?.scrollToEnd?.({ animated }); + }); }, []); useEffect(() => { let removeListeners: (() => void) | null = null; @@ -3758,75 +3753,23 @@ function ChatViewContent(props: ChatViewProps) { } return; } - const scrollNode = list.getScrollableNode(); - let finished = false; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); + void list + .scrollToIndex({ + index: anchorIndex, + animated: true, + viewPosition: 0, + viewOffset: CHAT_LIST_ANCHOR_OFFSET, + }) + .then(() => { + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + settledTimelineAnchorRef.current = messageId; + }); }); }; requestAnimationFrame(() => positionAnchor(12)); }, []); - const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }, []); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( @@ -3852,6 +3795,9 @@ function ChatViewContent(props: ChatViewProps) { } }, []); + // Anchored end space intentionally disables LegendList's normal end-follow so + // the sent message can stay near the top. T3 only owns streaming adjustments + // during that mode; LegendList owns ordinary end-follow everywhere else. useEffect(() => { if (!activeThread?.id) { return; @@ -3859,6 +3805,9 @@ function ChatViewContent(props: ChatViewProps) { if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { return; } + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } let secondFrame: number | null = null; const frame = requestAnimationFrame(() => { @@ -3880,28 +3829,13 @@ function ChatViewContent(props: ChatViewProps) { return; } - if (timelineScrollModeRef.current === "anchoring-new-turn") { - const metrics = getActiveTimelineTurnMetrics(list); - if (!metrics) { - return; - } - if (metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - - const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; - void list.scrollToOffset({ offset: nextOffset, animated: false }); - return; - } - - if (timelineScrollModeRef.current !== "following-end") { - return; - } - if (!timelineRealContentOverflowsViewport(list)) { + const metrics = getActiveTimelineTurnMetrics(list); + if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) { return; } - void list.scrollToEnd?.({ animated: false }); + const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; + void list.scrollToOffset({ offset: nextOffset, animated: false }); }); }); @@ -3911,12 +3845,7 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(secondFrame); } }; - }, [ - activeThread?.id, - timelineEntries, - getActiveTimelineTurnMetrics, - timelineRealContentOverflowsViewport, - ]); + }, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]); useEffect(() => { setPullRequestDialogState(null); @@ -6118,7 +6047,6 @@ function ChatViewContent(props: ChatViewProps) { skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} - onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0f6832f40638..f22130906ebf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -18,7 +18,6 @@ vi.mock("@legendapp/list/react", async () => { anchorMaxSize?: number; anchorOffset?: number; onReady?: (info: { anchorIndex: number }) => void; - onSizeChanged?: (size: number) => void; }; contentInsetEndAdjustment?: number; className?: string; @@ -42,7 +41,6 @@ vi.mock("@legendapp/list/react", async () => { ref?: Ref; }) => { if (props.anchoredEndSpace) { - props.anchoredEndSpace.onSizeChanged?.(240); props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); } return ( @@ -90,6 +88,11 @@ vi.mock("@legendapp/list/react", async () => { ? props.maintainVisibleContentPosition.size : undefined } + data-maintain-visible-content-position-restore={ + typeof props.maintainVisibleContentPosition === "object" + ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition) + : undefined + } > {props.ListHeaderComponent} {props.data.map((item) => ( @@ -192,7 +195,6 @@ function buildProps() { workspaceRoot: undefined, anchorMessageId: null, onAnchorReady: () => {}, - onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, liveFollowEnabled: true, onIsAtEndChange: () => {}, @@ -386,7 +388,6 @@ describe("MessagesTimeline", () => { it("anchors a sent attachment message using its measured height", () => { const onAnchorReady = vi.fn(); - const onAnchorSizeChanged = vi.fn(); const firstEntry = buildUserTimelineEntry("First prompt."); const secondEntry = { ...buildUserTimelineEntry("Newest prompt."), @@ -411,7 +412,6 @@ describe("MessagesTimeline", () => { {...buildProps()} anchorMessageId={secondEntry.message.id} onAnchorReady={onAnchorReady} - onAnchorSizeChanged={onAnchorSizeChanged} contentInsetEndAdjustment={144} timelineEntries={[firstEntry, secondEntry]} />, @@ -426,10 +426,10 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); expect(markup).toContain('data-maintain-visible-content-position="object"'); expect(markup).toContain('data-maintain-visible-content-position-data="true"'); - expect(markup).toContain('data-maintain-visible-content-position-size="false"'); + expect(markup).toContain('data-maintain-visible-content-position-size="true"'); + expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); expect(onAnchorReady).toHaveBeenCalledOnce(); expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); - expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240); }); it("renders collapse controls for long user messages", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ec9ef1bf708a..c6e28dcef5c5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,7 +29,6 @@ import { type MouseEvent, type ReactNode, } from "react"; -import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { @@ -142,7 +141,7 @@ interface TimelineRowSharedState { onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; - onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; + onToggleWorkGroup: (groupId: string, anchorKey: string) => void; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; } @@ -189,6 +188,14 @@ function TimelineLoadEarlierHeader({ } const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const TIMELINE_MAINTAIN_SCROLL_AT_END = { + animated: false, + on: { + dataChange: true, + itemLayout: true, + layout: true, + }, +} as const; // --------------------------------------------------------------------------- // Props (public API) @@ -220,7 +227,6 @@ interface MessagesTimelineProps { skills?: ReadonlyArray>; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; - onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; /** * Whether the timeline should keep pinning to the live edge as content @@ -267,7 +273,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills = EMPTY_TIMELINE_SKILLS, anchorMessageId, onAnchorReady, - onAnchorSizeChanged, contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, @@ -278,51 +283,85 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); + const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); + const disclosureAnchorKeyRef = useRef(null); + const disclosureSettleFrameRef = useRef(null); + const disclosureSettleSecondFrameRef = useRef(null); - const onToggleTurnFold = useCallback((turnId: TurnId) => { - setExpandedTurnIds((existing) => { - const next = new Set(existing); - if (next.has(turnId)) { - next.delete(turnId); - } else { - next.add(turnId); + useEffect(() => { + return () => { + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); } - return next; - }); + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + } + }; }, []); - const onToggleWorkGroup = useCallback( - (groupId: string, anchorElement?: HTMLElement) => { - const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null; - flushSync(() => { - setExpandedWorkGroupIds((existing) => { - const next = new Set(existing); - if (next.has(groupId)) { - next.delete(groupId); - } else { - next.add(groupId); - } - return next; - }); + const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => { + disclosureAnchorKeyRef.current = anchorKey; + setDisclosureToggleSettling(true); + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); + } + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + } + disclosureSettleFrameRef.current = requestAnimationFrame(() => { + disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + disclosureAnchorKeyRef.current = null; + setDisclosureToggleSettling(false); + disclosureSettleFrameRef.current = null; + disclosureSettleSecondFrameRef.current = null; }); + }); + }, []); - if (anchorBottomBeforeToggle === null || !anchorElement) { - return; - } + const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { + const disclosureAnchorKey = disclosureAnchorKeyRef.current; + return disclosureAnchorKey === null || row.id === disclosureAnchorKey; + }, []); - const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle; - if (Math.abs(delta) < 0.5) { - return; - } + const maintainVisibleContentPosition = useMemo( + () => ({ + data: true, + size: true, + shouldRestorePosition: shouldRestoreVisibleContentPosition, + }), + [shouldRestoreVisibleContentPosition], + ); - const list = listRef.current; - const currentScroll = list?.getState?.().scroll; - if (list && typeof currentScroll === "number") { - list.scrollToOffset({ offset: currentScroll + delta, animated: false }); - } + const onToggleTurnFold = useCallback( + (turnId: TurnId) => { + suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`); + setExpandedTurnIds((existing) => { + const next = new Set(existing); + if (next.has(turnId)) { + next.delete(turnId); + } else { + next.add(turnId); + } + return next; + }); + }, + [suspendEndScrollMaintenanceForDisclosure], + ); + const onToggleWorkGroup = useCallback( + (groupId: string, anchorKey: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey); + setExpandedWorkGroupIds((existing) => { + const next = new Set(existing); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); }, - [listRef], + [suspendEndScrollMaintenanceForDisclosure], ); // An in-session interrupt leaves its turn expanded so the user keeps their @@ -394,22 +433,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, [anchorMessageId, onAnchorReady], ); - const handleAnchorSizeChanged = useCallback( - (size: number) => { - if (anchorMessageId !== null) { - onAnchorSizeChanged(anchorMessageId, size); - } - }, - [anchorMessageId, onAnchorSizeChanged], - ); const anchoredEndSpace = useMemo(() => { const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => row.kind === "message" ? row.message.id : null, ); - return config - ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } - : undefined; - }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); + return config ? { ...config, onReady: handleAnchorReady } : undefined; + }, [anchorMessageId, handleAnchorReady, rows]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -554,21 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace || !liveFollowEnabled + anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling ? false - : { - animated: false, - on: { - dataChange: true, - itemLayout: true, - layout: true, - }, - } + : TIMELINE_MAINTAIN_SCROLL_AT_END } - maintainVisibleContentPosition={{ - data: true, - size: false, - }} + maintainVisibleContentPosition={maintainVisibleContentPosition} onScroll={handleScroll} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", @@ -1381,11 +1400,7 @@ function WorkGroupToggleTimelineRow({ type="button" className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" aria-expanded={row.expanded} - onClick={(event) => { - const anchorElement = - event.currentTarget.closest("[data-timeline-row-id]") ?? event.currentTarget; - ctx.onToggleWorkGroup(row.groupId, anchorElement); - }} + onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} > =12'} - '@legendapp/list@3.2.0': - resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==} - peerDependencies: - react: '*' - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - '@legendapp/list@3.3.3': resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} peerDependencies: @@ -12998,13 +12986,6 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) - optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 @@ -13013,6 +12994,13 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + '@lexical/clipboard@0.41.0': dependencies: '@lexical/html': 0.41.0 From 7963cc70f3c9873768a5eaad7ca0a20a23d67aed Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 06:16:11 -0400 Subject: [PATCH 3/4] feat(server): record runtime mode per turn and on mode changes (#5593) Co-authored-by: Claude Opus 5 (1M context) --- .../providerService.integration.test.ts | 138 ++++++++++++++---- .../src/provider/Layers/ProviderService.ts | 20 +++ 2 files changed, 129 insertions(+), 29 deletions(-) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index e703af4b1f45..c57d289f9929 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; @@ -54,35 +55,58 @@ interface IntegrationFixture { readonly layer: Layer.Layer; } -const makeIntegrationFixture = Effect.gen(function* () { - const cwd = yield* makeWorkspaceDirectory; - const harness = yield* makeTestProviderAdapterHarness(); - - const registry = makeAdapterRegistryMock({ - [ProviderDriverKind.make("codex")]: harness.adapter, - }); +interface RecordedAnalyticsEvent { + readonly event: string; + readonly properties: Readonly> | undefined; +} - const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntime.layer), +/** + * Analytics layer that keeps captured events in memory so tests can assert on + * telemetry payloads. `AnalyticsService.layerTest` discards them. + */ +const makeRecordingAnalytics = Effect.gen(function* () { + const recorded = yield* Ref.make>([]); + const layer = Layer.succeed( + AnalyticsService, + AnalyticsService.of({ + record: (event, properties) => + Ref.update(recorded, (current) => [...current, { event, properties }]), + flush: Effect.void, + }), ); - - const shared = Layer.mergeAll( - directoryLayer, - Layer.succeed(ProviderAdapterRegistry, registry), - ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), - AnalyticsService.layerTest, - Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), - ).pipe(Layer.provide(SqlitePersistenceMemory)); - - const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); - - return { - cwd, - harness, - layer, - } satisfies IntegrationFixture; + return { layer, get: Ref.get(recorded) } as const; }); +const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer }) => + Effect.gen(function* () { + const cwd = yield* makeWorkspaceDirectory; + const harness = yield* makeTestProviderAdapterHarness(); + + const registry = makeAdapterRegistryMock({ + [ProviderDriverKind.make("codex")]: harness.adapter, + }); + + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer), + ); + + const shared = Layer.mergeAll( + directoryLayer, + Layer.succeed(ProviderAdapterRegistry, registry), + ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), + options?.analytics ?? AnalyticsService.layerTest, + Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), + ).pipe(Layer.provide(SqlitePersistenceMemory)); + + const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); + + return { + cwd, + harness, + layer, + } satisfies IntegrationFixture; + }); + const collectEventsDuring = ( stream: Stream.Stream, count: number, @@ -126,7 +150,7 @@ const runTurn = (input: { it.live("replays typed runtime fixture events", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); yield* Effect.gen(function* () { const provider = yield* ProviderService; @@ -161,7 +185,7 @@ it.live("replays typed runtime fixture events", () => it.live("replays file-changing fixture turn events", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); const { join } = yield* Path.Path; const { writeFileString } = yield* FileSystem.FileSystem; @@ -198,7 +222,7 @@ it.live("replays file-changing fixture turn events", () => it.live("runs multi-turn tool/approval flow", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); const { join } = yield* Path.Path; const { writeFileString } = yield* FileSystem.FileSystem; @@ -250,7 +274,7 @@ it.live("runs multi-turn tool/approval flow", () => it.live("rolls back provider conversation state only", () => Effect.gen(function* () { - const fixture = yield* makeIntegrationFixture; + const fixture = yield* makeIntegrationFixture(); const { join } = yield* Path.Path; const { writeFileString, readFileString } = yield* FileSystem.FileSystem; @@ -302,3 +326,59 @@ it.live("rolls back provider conversation state only", () => }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.live("reports runtime mode per turn and on mode transitions", () => + Effect.gen(function* () { + const analytics = yield* makeRecordingAnalytics; + const fixture = yield* makeIntegrationFixture({ analytics: analytics.layer }); + const threadId = ThreadId.make("thread-integration-runtime-mode"); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const startSession = (runtimeMode: "approval-required" | "full-access") => + provider.startSession(threadId, { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + cwd: fixture.cwd, + runtimeMode, + }); + + yield* startSession("approval-required"); + yield* runTurn({ + provider, + harness: fixture.harness, + threadId, + userText: "supervised turn", + response: { events: codexTurnTextFixture }, + }); + + // Toggling the mode restarts the session, which is the only place the + // transition is observable. + yield* startSession("full-access"); + yield* runTurn({ + provider, + harness: fixture.harness, + threadId, + userText: "full access turn", + response: { events: codexTurnTextFixture }, + }); + + const recorded = yield* analytics.get; + + assert.deepEqual( + recorded + .filter((entry) => entry.event === "provider.turn.sent") + .map((entry) => entry.properties?.runtimeMode), + ["approval-required", "full-access"], + ); + + assert.deepEqual( + recorded + .filter((entry) => entry.event === "provider.runtime_mode.changed") + .map((entry) => [entry.properties?.from, entry.properties?.to]), + [["approval-required", "full-access"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index ecf26a914c13..d0acc1039c30 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -459,6 +459,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter, instanceId, threadId: input.threadId, + runtimeMode: binding.runtimeMode, isActive: true, } as const; } @@ -468,6 +469,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter, instanceId, threadId: input.threadId, + runtimeMode: binding.runtimeMode, isActive: false, } as const; } @@ -480,6 +482,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( adapter: recovered.adapter, instanceId, threadId: input.threadId, + runtimeMode: recovered.session.runtimeMode, isActive: true, } as const; }); @@ -629,6 +632,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( input.modelSelection.model.trim().length > 0, }); + // Changing runtime mode restarts the session, so the transition is only + // observable here, by diffing against the mode the previous session for + // this thread was bound to. Recording it separately is what makes the + // "started supervised, switched to full access" funnel answerable. + const previousRuntimeMode = persistedBinding?.runtimeMode; + if (previousRuntimeMode !== undefined && previousRuntimeMode !== input.runtimeMode) { + yield* analytics.record("provider.runtime_mode.changed", { + provider: sessionWithInstance.provider, + from: previousRuntimeMode, + to: input.runtimeMode, + }); + } + return sessionWithInstance; }).pipe( withMetrics({ @@ -703,6 +719,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: routed.adapter.provider, model: input.modelSelection?.model, interactionMode: input.interactionMode, + // Session-start events alone skew runtime mode toward users who toggle + // often, since every toggle restarts the session. Recording it per turn + // gives a usage-weighted view and lets it cross with interactionMode. + runtimeMode: routed.runtimeMode, attachmentCount: input.attachments.length, hasInput: typeof input.input === "string" && input.input.trim().length > 0, }); From b2ee17d7c1b1bdff543ffff43929d01b92a902f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 06:18:12 -0400 Subject: [PATCH 4/4] feat(web): thread actions from the chat header title (#5592) Co-authored-by: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/SidebarV2.tsx | 72 +---- .../src/components/chat/ChatHeader.test.ts | 23 +- apps/web/src/components/chat/ChatHeader.tsx | 186 ++++++++++- .../components/threadActionMenu.logic.test.ts | 66 ++++ .../src/components/threadActionMenu.logic.ts | 105 ++++++ apps/web/src/hooks/useThreadActionMenu.ts | 299 ++++++++++++++++++ apps/web/src/state/entities.ts | 9 + 8 files changed, 692 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/components/threadActionMenu.logic.test.ts create mode 100644 apps/web/src/components/threadActionMenu.logic.ts create mode 100644 apps/web/src/hooks/useThreadActionMenu.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 06aceffad657..8b2b6f61357f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5979,6 +5979,8 @@ function ChatViewContent(props: ChatViewProps) { activeThreadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} + isServerThread={isServerThread} + changeRequestState={activeThreadPr?.state ?? null} activeProjectName={activeProject?.title} activeProjectCwd={activeProject?.workspaceRoot ?? null} openInCwd={gitCwd} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 89419d428f64..590ca9cb583d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -108,6 +108,7 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { buildBulkTitleRegenerationContextMenuItem, formatWorkingDurationLabel, @@ -2539,62 +2540,21 @@ export default function SidebarV2() { const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); const clicked = await settlePromise(() => api.contextMenu.show( - [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - label: `New thread on ${thread.branch}`, - }, - ] - : []), - ...(supportsPinning - ? [ - isPinned - ? { id: "unpin", label: "Unpin thread" } - : { id: "pin", label: "Pin thread" }, - ] - : []), - // Both lifecycle actions stay available on pinned threads: - // settling clears the pin ("done" beats "keep on top"), and - // snoozing hides the card until wake with the pin intact. - ...(supportsSettlement - ? [ - isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, - ] - : []), - ...(supportsSnooze - ? [ - isSnoozed - ? { id: "unsnooze", label: "Wake thread" } - : { - id: "snooze", - label: "Snooze", - disabled: !canSnooze(thread, { now: new Date().toISOString() }), - children: snoozePresets.map((preset) => ({ - id: `snooze:${preset.id}`, - label: `${preset.label} (${preset.whenLabel})`, - })), - }, - ] - : []), - { id: "rename", label: "Rename thread" }, - ...(supportsTitleRegeneration - ? [ - { - id: "regenerate-title", - label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title", - disabled: isRegeneratingTitle, - }, - ] - : []), - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy path", icon: "copy" }, - ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []), - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, - ], + buildThreadActionMenuItems({ + branch: thread.branch ?? null, + isPinned, + isSettled, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + isRegeneratingTitle, + supports: { + settlement: supportsSettlement, + snooze: supportsSnooze, + pinning: supportsPinning, + titleRegeneration: supportsTitleRegeneration, + }, + snoozePresets, + }), position, ), ); diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index d716092fc3eb..94fe070ee3dc 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { shouldShowOpenInPicker } from "./ChatHeader"; +import { resolveRenameCommit, shouldShowOpenInPicker } from "./ChatHeader"; describe("shouldShowOpenInPicker", () => { const primaryEnvironmentId = EnvironmentId.make("environment-primary"); @@ -46,3 +46,24 @@ describe("shouldShowOpenInPicker", () => { ).toBe(false); }); }); + +describe("resolveRenameCommit", () => { + it("commits a trimmed changed title", () => { + expect(resolveRenameCommit({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "commit", + title: "New title", + }); + }); + + it("rejects empty and whitespace-only titles", () => { + expect(resolveRenameCommit({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + }); + + it("no-ops when the trimmed title is unchanged", () => { + expect(resolveRenameCommit({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b11e2136770d..68e1743bccb6 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,10 +6,25 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { memo } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; +import { ChevronDownIcon } from "lucide-react"; +import { + memo, + useCallback, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, @@ -17,6 +32,9 @@ import ProjectScriptsControl, { import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; +import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; @@ -25,6 +43,10 @@ interface ChatHeaderProps { activeThreadId: ThreadId; draftId?: DraftId; activeThreadTitle: string; + /** Drafts have no server thread yet, so the title carries no action menu. */ + isServerThread: boolean; + /** PR state feeding the settled classification, resolved by ChatView. */ + changeRequestState: ChangeRequestStateLike | null; activeProjectName: string | undefined; activeProjectCwd: string | null; openInCwd: string | null; @@ -44,6 +66,20 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; } +/** + * Rename commit rule shared with the sidebar's inline rename: trim, reject + * empty (the caller toasts), and skip the mutation when nothing changed. + */ +export function resolveRenameCommit(input: { + readonly title: string; + readonly originalTitle: string; +}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { + const trimmed = input.title.trim(); + if (trimmed.length === 0) return { action: "reject-empty" }; + if (trimmed === input.originalTitle) return { action: "noop" }; + return { action: "commit", title: trimmed }; +} + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -61,6 +97,8 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadId, draftId, activeThreadTitle, + isServerThread, + changeRequestState, activeProjectName, activeProjectCwd, openInCwd, @@ -86,8 +124,91 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, primaryEnvironmentId, }); + const activeThreadRef = useMemo( + () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), + [activeThreadEnvironmentId, activeThreadId], + ); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + // Inline rename, keyed by thread: navigating away drops an in-progress + // rename instead of committing stale text. Cleared on thread change (not + // just hidden) so returning to the thread doesn't revive the old draft. + const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null); + if (renaming !== null && renaming.threadId !== activeThreadId) { + setRenaming(null); + } + const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; + const renameCommittedRef = useRef(false); + const startRename = useCallback(() => { + renameCommittedRef.current = false; + setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); + }, [activeThreadId, activeThreadTitle]); + const commitRename = useCallback( + (title: string) => { + setRenaming(null); + const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + if (resolution.action === "reject-empty") { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (resolution.action === "noop") return; + void updateThreadMetadata({ + environmentId: activeThreadEnvironmentId, + input: { threadId: activeThreadId, title: resolution.title }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }); + }, + [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], + ); + const { openMenu } = useThreadActionMenu({ + threadRef: isServerThread ? activeThreadRef : null, + projectCwd: activeProjectCwd, + changeRequestState, + onStartRename: startRename, + }); + const titleButtonRef = useRef(null); + const openMenuFromTitle = useCallback(() => { + const rect = titleButtonRef.current?.getBoundingClientRect(); + if (!rect) return; + openMenu({ x: rect.left, y: rect.bottom + 4 }); + }, [openMenu]); + const handleHeaderContextMenu = useCallback( + (event: ReactMouseEvent) => { + if (!isServerThread || renamingTitle !== null) return; + // The right-side controls (git, scripts, open-in) keep their own + // behavior; only the breadcrumb area opens the thread menu. + if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; + event.preventDefault(); + openMenu({ x: event.clientX, y: event.clientY }); + }, + [isServerThread, openMenu, renamingTitle], + ); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter") { + renameCommittedRef.current = true; + commitRename(event.currentTarget.value); + } else if (event.key === "Escape") { + renameCommittedRef.current = true; + setRenaming(null); + } + }, + [commitRename], + ); return ( -
+
{/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone @@ -119,19 +240,58 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - - + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( + + + } + > +

{activeThreadTitle}

- } - /> - {activeThreadTitle} -
+ +
+ {activeThreadTitle} +
+ ) : ( + + + {activeThreadTitle} + + } + /> + {activeThreadTitle} + + )}
item.id); +} + +describe("buildThreadActionMenuItems", () => { + it("hides lifecycle items when the environment lacks the capabilities", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toEqual(["rename", "mark-unread", "copy-path", "delete"]); + }); + + it("includes branch items only for threads with a branch", () => { + const withBranch = ids({ ...baseState, branch: "feat/menu" }); + expect(withBranch).toContain("new-thread-on-branch"); + expect(withBranch).toContain("copy-branch"); + expect(ids(baseState)).not.toContain("new-thread-on-branch"); + expect(ids(baseState)).not.toContain("copy-branch"); + }); + + it("flips lifecycle labels with thread state", () => { + expect(ids({ ...baseState, isPinned: true, isSettled: true, isSnoozed: true })).toEqual( + expect.arrayContaining(["unpin", "unsettle", "unsnooze"]), + ); + expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"])); + }); + + it("disables snooze when the thread cannot snooze, keeping presets visible", () => { + const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find( + (item) => item.id === "snooze", + ); + expect(snooze?.disabled).toBe(true); + expect(snooze?.children?.map((child) => child.id)).toEqual(["snooze:hour"]); + }); + + it("disables title regeneration while one is in flight", () => { + const item = buildThreadActionMenuItems({ ...baseState, isRegeneratingTitle: true }).find( + (candidate) => candidate.id === "regenerate-title", + ); + expect(item).toMatchObject({ label: "Regenerating…", disabled: true }); + }); + + it("marks delete as destructive and keeps it last", () => { + const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); + expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); + }); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts new file mode 100644 index 000000000000..66aaf3debf54 --- /dev/null +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -0,0 +1,105 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; + +/** + * Ids for the per-thread action menu. Snooze presets are dispatched as + * `snooze:` so the union stays closed while the preset list + * remains data-driven. + */ +export type ThreadActionMenuId = + | "new-thread-on-branch" + | "pin" + | "unpin" + | "settle" + | "unsettle" + | "snooze" + | `snooze:${string}` + | "unsnooze" + | "rename" + | "regenerate-title" + | "mark-unread" + | "copy-path" + | "copy-branch" + | "delete"; + +export interface ThreadActionMenuState { + readonly branch: string | null; + readonly isPinned: boolean; + readonly isSettled: boolean; + readonly isSnoozed: boolean; + readonly canSnoozeNow: boolean; + readonly isRegeneratingTitle: boolean; + readonly supports: { + readonly settlement: boolean; + readonly snooze: boolean; + readonly pinning: boolean; + readonly titleRegeneration: boolean; + }; + readonly snoozePresets: ReadonlyArray; +} + +/** + * Single source for the per-thread action menu: the sidebar row's right-click + * menu and the chat header menu both render exactly this list, so labels, + * ordering, and capability gating cannot drift between the two surfaces. + */ +export function buildThreadActionMenuItems( + state: ThreadActionMenuState, +): ReadonlyArray> { + return [ + ...(state.branch + ? [ + { + id: "new-thread-on-branch" as const, + label: `New thread on ${state.branch}`, + }, + ] + : []), + ...(state.supports.pinning + ? [ + state.isPinned + ? { id: "unpin" as const, label: "Unpin thread" } + : { id: "pin" as const, label: "Pin thread" }, + ] + : []), + // Both lifecycle actions stay available on pinned threads: settling + // clears the pin ("done" beats "keep on top"), and snoozing hides the + // card until wake with the pin intact. + ...(state.supports.settlement + ? [ + state.isSettled + ? { id: "unsettle" as const, label: "Un-settle thread" } + : { id: "settle" as const, label: "Settle thread" }, + ] + : []), + ...(state.supports.snooze + ? [ + state.isSnoozed + ? { id: "unsnooze" as const, label: "Wake thread" } + : { + id: "snooze" as const, + label: "Snooze", + disabled: !state.canSnoozeNow, + children: state.snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: `${preset.label} (${preset.whenLabel})`, + })), + }, + ] + : []), + { id: "rename", label: "Rename thread" }, + ...(state.supports.titleRegeneration + ? [ + { + id: "regenerate-title" as const, + label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + disabled: state.isRegeneratingTitle, + }, + ] + : []), + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy path", icon: "copy" }, + ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ]; +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts new file mode 100644 index 000000000000..85ffde776b43 --- /dev/null +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -0,0 +1,299 @@ +import { scopeProjectRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + canSnooze, + effectiveSettled, + effectiveSnoozed, + type ChangeRequestStateLike, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; +import { + buildThreadActionMenuItems, + type ThreadActionMenuId, +} from "../components/threadActionMenu.logic"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + readEnvironmentSupportsPinning, + readEnvironmentSupportsSettlement, + readEnvironmentSupportsSnooze, + readEnvironmentSupportsTitleRegeneration, + readThreadShell, +} from "../state/entities"; +import { readLocalApi } from "../localApi"; +import { useUiStateStore } from "../uiStateStore"; +import { useCopyToClipboard } from "./useCopyToClipboard"; +import { useNewThreadHandler } from "./useHandleNewThread"; +import { useClientSettings } from "./useSettings"; +import { useThreadActions } from "./useThreadActions"; + +function failureToast(title: string, error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** + * The per-thread action menu (pin, settle, snooze, rename, copy, delete…) as + * a self-contained hook, for surfaces other than the sidebar row — today the + * chat header. Renders through the native context-menu bridge and dispatches + * through the same mutations the sidebar uses. + * + * Unlike the sidebar, settle and snooze here never navigate away: the caller + * is acting on the thread they are reading, and ChatView's parked-thread + * banner already offers the way back. + */ +export function useThreadActionMenu(input: { + readonly threadRef: ScopedThreadRef | null; + /** Fallback for "Copy path" when the thread has no worktree. */ + readonly projectCwd: string | null; + /** PR state feeding auto-settle classification, as resolved by the caller. */ + readonly changeRequestState: ChangeRequestStateLike | null; + readonly onStartRename: () => void; +}) { + const { threadRef, projectCwd, changeRequestState, onStartRename } = input; + const { + settleThread, + unsettleThread, + snoozeThread, + unsnoozeThread, + pinThread, + unpinThread, + deleteThread, + } = useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const handleNewThread = useNewThreadHandler(); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const timestampFormat = useClientSettings((s) => s.timestampFormat); + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ + onCopy: ({ path }) => { + toastManager.add({ type: "success", title: "Path copied", description: path }); + }, + onError: (error) => failureToast("Failed to copy path", error), + }); + const { copyToClipboard: copyBranchToClipboard } = useCopyToClipboard<{ branch: string }>({ + target: "branch name", + onCopy: ({ branch }) => { + toastManager.add({ type: "success", title: "Branch copied", description: branch }); + }, + onError: (error) => failureToast("Failed to copy branch", error), + }); + + const openMenu = useCallback( + (position: { x: number; y: number }) => { + if (threadRef === null) return; + void (async () => { + const api = readLocalApi(); + if (!api) return; + // Snapshot at open time — the menu is modal, so state read now is + // what the user is looking at. + const thread = readThreadShell(threadRef); + if (!thread) return; + const now = new Date(); + const supports = { + settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), + snooze: readEnvironmentSupportsSnooze(threadRef.environmentId), + pinning: readEnvironmentSupportsPinning(threadRef.environmentId), + titleRegeneration: readEnvironmentSupportsTitleRegeneration(threadRef.environmentId), + }; + const isRegeneratingTitle = thread.titleRegeneration != null; + const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const items = buildThreadActionMenuItems({ + branch: thread.branch ?? null, + isPinned: thread.pinnedAt != null, + isSettled: + supports.settlement && + effectiveSettled(thread, { + // Minute-quantized like useNowMinute, so this classification + // can never disagree with the sidebar partition or ChatView's + // parked-thread banner within the same minute. + now: `${now.toISOString().slice(0, 16)}:00.000Z`, + autoSettleAfterDays, + changeRequestState, + }), + isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), + canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), + isRegeneratingTitle, + supports, + snoozePresets, + }); + const clicked = await settlePromise(() => api.contextMenu.show(items, position)); + if (clicked._tag === "Failure" || clicked.value === null) return; + const action: ThreadActionMenuId = clicked.value; + if (action.startsWith("snooze:")) { + const preset = snoozePresets.find((candidate) => `snooze:${candidate.id}` === action); + if (!preset) return; + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + failureToast("Failed to snooze thread", squashAtomCommandFailure(result)); + } + return; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + void unsnoozeThread(threadRef).then((undone) => { + if (undone._tag === "Failure" && !isAtomCommandInterrupted(undone)) { + failureToast("Failed to wake thread", squashAtomCommandFailure(undone)); + } + }); + }, + }, + }), + ); + return; + } + const reportFailure = async ( + title: string, + run: () => Promise>, + ) => { + const result = await run(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast(title, squashAtomCommandFailure(result)); + } + }; + switch (action) { + case "new-thread-on-branch": { + // Explicit branch carry-over: reuse the thread's worktree when it + // has one, otherwise its branch on the local checkout. + const result = await settlePromise(() => + handleNewThread(scopeProjectRef(threadRef.environmentId, thread.projectId), { + branch: thread.branch, + worktreePath: thread.worktreePath, + envMode: thread.worktreePath ? "worktree" : "local", + startFromOrigin: false, + }), + ); + if (result._tag === "Failure") { + failureToast("Could not create thread", squashAtomCommandFailure(result)); + } + return; + } + case "settle": + await reportFailure("Failed to settle thread", () => settleThread(threadRef)); + return; + case "unsettle": + await reportFailure("Failed to un-settle thread", () => unsettleThread(threadRef)); + return; + case "unsnooze": + await reportFailure("Failed to wake thread", () => unsnoozeThread(threadRef)); + return; + case "pin": + await reportFailure("Failed to pin thread", () => pinThread(threadRef)); + return; + case "unpin": + await reportFailure("Failed to unpin thread", () => unpinThread(threadRef)); + return; + case "rename": + onStartRename(); + return; + case "regenerate-title": + if (isRegeneratingTitle) return; + await reportFailure("Failed to regenerate thread title", () => + updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, regenerateTitle: true }, + }), + ); + return; + case "mark-unread": + markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt); + return; + case "copy-path": { + const workspacePath = thread.worktreePath ?? projectCwd; + if (!workspacePath) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Path unavailable", + description: "This thread does not have a workspace path to copy.", + }), + ); + return; + } + copyPathToClipboard(workspacePath, { path: workspacePath }); + return; + } + case "copy-branch": + if (thread.branch) { + copyBranchToClipboard(thread.branch, { branch: thread.branch }); + } + return; + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const deleted = await deleteThread(threadRef); + if ( + deleted._tag === "Failure" && + !isAtomCommandInterrupted(deleted) && + // A failure with the thread already gone is worktree cleanup + // failing after a successful delete — deleteThread has toasted + // that itself, and "Failed to delete thread" would be a lie. + readThreadShell(threadRef) !== null + ) { + failureToast("Failed to delete thread", squashAtomCommandFailure(deleted)); + } + return; + } + default: + return; + } + })(); + }, + [ + autoSettleAfterDays, + changeRequestState, + confirmThreadDelete, + copyBranchToClipboard, + copyPathToClipboard, + deleteThread, + handleNewThread, + markThreadUnread, + onStartRename, + pinThread, + projectCwd, + settleThread, + snoozeThread, + threadRef, + timestampFormat, + unpinThread, + unsettleThread, + unsnoozeThread, + updateThreadMetadata, + ], + ); + + return { openMenu }; +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 3f82973045cc..c0018b24935c 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -250,6 +250,15 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo ); } +/** Whether the environment's server understands thread title regeneration. + Same version-skew contract as settlement. */ +export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadTitleRegeneration === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); }