From 7e03dd0eea7ba995a361023a9ece872fbbdb0b2b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:37:17 -0700 Subject: [PATCH 001/354] chore(ov2): preserve the integration base for replay Restore the web composer and grouping that main reverted. Defer the mobile port until the V2 commits have been replayed so it can be adapted once to V2 run and queue data. --- .../src/features/threads/ThreadComposer.tsx | 492 ++-- .../features/threads/ThreadDetailScreen.tsx | 349 ++- .../src/features/threads/ThreadFeed.tsx | 553 +++-- .../threads/floating-working-control.tsx | 255 -- apps/mobile/src/lib/threadActivity.test.ts | 1008 +------- apps/mobile/src/lib/threadActivity.ts | 658 ++--- apps/web/src/appearanceFonts.test.ts | 15 + apps/web/src/appearanceFonts.ts | 6 + .../web/src/components/ChatView.logic.test.ts | 8 - apps/web/src/components/ChatView.logic.ts | 281 +-- apps/web/src/components/ChatView.tsx | 1320 +++-------- apps/web/src/components/Sidebar.logic.test.ts | 39 + apps/web/src/components/Sidebar.logic.ts | 8 + apps/web/src/components/chat/ChatComposer.tsx | 2109 ++++------------- .../chat/ComposerActivityStatus.tsx | 106 +- .../src/components/chat/ComposerBanner.tsx | 28 +- .../components/chat/ComposerBannerStack.tsx | 43 +- .../chat/ComposerPendingUserInputPanel.tsx | 2 +- .../chat/ComposerServerUpdateStatus.tsx | 15 - .../components/chat/ComposerStashBadge.tsx | 22 + .../src/components/chat/ComposerStashMenu.tsx | 2 +- .../components/chat/ComposerTasksBadge.tsx | 53 +- .../chat/MessagesTimeline.logic.test.ts | 579 +---- .../components/chat/MessagesTimeline.logic.ts | 368 +-- .../components/chat/MessagesTimeline.test.tsx | 210 +- .../src/components/chat/MessagesTimeline.tsx | 1211 ++++------ apps/web/src/pendingUserInput.test.ts | 24 + apps/web/src/pendingUserInput.ts | 11 + apps/web/src/providerUpdateDismissal.test.ts | 23 + apps/web/src/providerUpdateDismissal.ts | 57 +- apps/web/src/session-logic.test.ts | 3 +- apps/web/src/threadSync.test.ts | 9 +- apps/web/src/timestampFormat.test.ts | 39 + apps/web/src/timestampFormat.ts | 31 + apps/web/src/versionSkew.test.ts | 9 + apps/web/src/versionSkew.ts | 14 + packages/client-runtime/package.json | 24 - .../src/work-log/commandLabel.ts | 1369 ----------- .../src/work-log/presentation.ts | 531 ----- patches/@legendapp__list@3.3.5.patch | 794 +------ ...t-native-keyboard-controller@1.21.13.patch | 526 +--- pnpm-lock.yaml | 6 +- 42 files changed, 3005 insertions(+), 10205 deletions(-) delete mode 100644 apps/mobile/src/features/threads/floating-working-control.tsx create mode 100644 apps/web/src/providerUpdateDismissal.test.ts delete mode 100644 packages/client-runtime/src/work-log/commandLabel.ts delete mode 100644 packages/client-runtime/src/work-log/presentation.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index af3359ec8c79..8c55a7085630 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -10,32 +10,15 @@ import type { } from "@t3tools/contracts"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type RefObject, -} from "react"; -import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; -import { - composerAttachmentUploadBlockReason, - composerAttachmentUploadsAtom, -} from "../../state/composer-attachment-uploads"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { ActivityIndicator, Image, Platform, Pressable, View, type ViewStyle } from "react-native"; +import ImageViewing from "react-native-image-viewing"; import Animated, { FadeIn, FadeInDown, FadeOut, FadeOutDown, LinearTransition, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; @@ -54,17 +37,14 @@ import { ComposerActionButton, ComposerInlineControl, ComposerToolbarRow, + ComposerToolbarScroller, } from "../../components/ComposerToolbar"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerAttachment, DraftComposerFileAttachment, } from "../../lib/composerImages"; -import { - buildModelOptions, - groupByProvider, - isModelSelectionUnavailable, -} from "../../lib/modelOptions"; +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; @@ -110,8 +90,13 @@ export interface ThreadComposerProps { readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; readonly environmentLabel: string | null; + /** + * Message sync phase for the selected thread (drives the status pill): + * "loading" = first fetch, nothing to show yet; "syncing" = cached messages + * are on screen while they reconcile with the server. + */ + readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; - readonly hasCompactableConversation: boolean; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly environmentId: EnvironmentId; @@ -138,24 +123,14 @@ export interface ThreadComposerProps { * iOS 26+ devices and keeps the existing opaque fallback elsewhere. * Exported so NewTaskDraftScreen can render the same composer chrome. */ -// The bottom-anchored dock position and clipped surface height use the same -// transition so the card grows upward without exposing its final-size content. +// One timing for every piece of the expanded↔compact morph so the surface, +// toolbar, and siblings move together instead of popping between layouts. // Android gets NO layout transition: the composer rides the keyboard via // KeyboardStickyView (frame-synced to the IME), and a time-based morph // running alongside that translate reads as jitter. Snapping the layout and // letting the keyboard-synced slide be the only motion looks native there. -export const COMPOSER_TRANSITION_DURATION_MS = 220; -export const COMPOSER_LAYOUT_TRANSITION = - Platform.OS === "android" - ? undefined - : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System); - -const COMPOSER_ATTACHMENT_ENTERING = - Platform.OS === "android" - ? FadeIn.duration(160) - : FadeIn.delay(COMPOSER_TRANSITION_DURATION_MS).duration(160).reduceMotion(ReduceMotion.System); - -const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); +const COMPOSER_LAYOUT_TRANSITION = + Platform.OS === "android" ? undefined : LinearTransition.duration(220); export function ComposerSurface(props: { readonly children: ReactNode; @@ -163,64 +138,35 @@ export function ComposerSurface(props: { /** Morphs between the compact and expanded composer layouts. */ readonly animateLayout?: boolean; }) { - const targetBorderRadius = - typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; - const animatedBorderRadius = useSharedValue(targetBorderRadius); - const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android"; - useLayoutEffect(() => { - animatedBorderRadius.value = shouldAnimate - ? withTiming(targetBorderRadius, { - duration: COMPOSER_TRANSITION_DURATION_MS, - reduceMotion: ReduceMotion.System, - }) - : targetBorderRadius; - }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]); - const animatedShapeStyle = useAnimatedStyle(() => ({ - borderRadius: animatedBorderRadius.value, - })); - const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined; - - // Each native frame follows the same transition. Animating only the outer - // clip leaves the glass and content at their final height on the first frame. + // A box shadow follows the rounded surface even when the glass is transparent. + // Keep it outside the clipped content so the shadow can extend past the edge. return ( - {null} - - {props.children} - + ); } type ComposerStatusPillState = { - readonly kind: "unavailable" | "reconnecting"; + readonly kind: "unavailable" | "reconnecting" | "syncing"; readonly label: string; }; @@ -228,6 +174,7 @@ function composerConnectionStatus(input: { readonly connectionError: string | null; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; + readonly threadSyncPhase?: "loading" | "syncing" | null; }): ComposerStatusPillState | null { const environmentLabel = input.environmentLabel ?? "Environment"; @@ -253,6 +200,18 @@ function composerConnectionStatus(input: { case "available": return { kind: "unavailable", label: `${environmentLabel} is not connected` }; case "connected": + break; + } + + // Connected: the pill is the single loading/sync indicator. One stable + // label per open — "Loading" when starting from scratch, "Syncing" when + // cached messages are already visible. + switch (input.threadSyncPhase) { + case "loading": + return { kind: "syncing", label: "Loading messages..." }; + case "syncing": + return { kind: "syncing", label: "Syncing messages..." }; + default: return null; } } @@ -261,7 +220,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( readonly onPress: () => void; readonly status: ComposerStatusPillState; }) { - const isReconnecting = props.status.kind === "reconnecting"; + const isReconnecting = props.status.kind !== "unavailable"; return ( (null); const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; + // Opening and presentation count as active so the composer stays expanded + // while focus moves between its native editor and the settings picker. + const isExpanded = isFocused || settingsSheetPresentation.isActive; + const canSend = hasContent; + + // Notify the parent from the derived value, not focus events: the parent + // sizes the feed inset from this, and blur-during-sheet would otherwise + // report collapsed while the composer still renders expanded. + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); + + const onPressImage = useCallback( + (uri: string) => { + wasExpandedBeforePreviewRef.current = isFocused; + setPreviewImageUri(uri); + }, + [isFocused], + ); + + const closePreview = useCallback(() => { + setPreviewImageUri(null); + if (wasExpandedBeforePreviewRef.current) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [inputRef]); + + const onEditorFocusChange = props.onEditorFocusChange; + const handleFocus = useCallback(() => { + setIsFocused(true); + onEditorFocusChange?.(true); + }, [onEditorFocusChange]); + + const handleBlur = useCallback(() => { + setIsFocused(false); + onEditorFocusChange?.(false); + }, [onEditorFocusChange]); const showStopAction = !hasContent && (props.selectedThread.session?.status === "running" || @@ -319,13 +315,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; - const modelUnavailable = - props.connectionState === "connected" && - isModelSelectionUnavailable(props.serverConfig, currentModelSelection); const connectionStatus = composerConnectionStatus({ connectionError: props.connectionError, connectionState: props.connectionState, environmentLabel: props.environmentLabel, + threadSyncPhase: props.threadSyncPhase, }); const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; @@ -344,12 +338,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer projectCwd: props.projectCwd, selectedProviderStatus, hasThread: true, - hasCompactableConversation: props.hasCompactableConversation, onChangeDraftMessage: props.onChangeDraftMessage, - onUpdateInteractionMode: - selectedProviderStatus?.showInteractionModeToggle === false - ? undefined - : props.onUpdateInteractionMode, + onUpdateInteractionMode: props.onUpdateInteractionMode, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -375,11 +365,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer serverConfig: props.serverConfig, states: uploadStates, }); - const canSend = - hasContent && - !voiceInput.blocksSubmission && - attachmentBlockReason === null && - !modelUnavailable; + const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null; // Keep the feed inset aligned with the card or compact dictation strip. useEffect(() => { @@ -492,7 +478,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer () => ({ ownerId: settingsOwnerId, environmentId: props.environmentId, - providerInstanceId: currentModelSelection.instanceId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(option.selection), @@ -557,7 +542,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return ( {!voiceInput.isBusy && composerMenu.trigger && composerMenu.items.length > 0 ? ( @@ -592,12 +579,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : null} - {modelUnavailable ? ( - - Model unavailable. Open model settings. - - ) : null} - @@ -630,204 +613,119 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickMedia={props.onPickDraftMedia} onPickFiles={props.onPickDraftFiles} /> - ) : null} - {isExpanded && props.draftAttachments.length > 0 ? ( - - undefined : props.onRemoveDraftImage} - onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} - onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} - /> - - ) : null} - - void props.onNativePasteImages(uris)} - placeholder={props.placeholder} - onFocus={handleFocus} - onBlur={handleBlur} - onSubmit={handleSend} - scrollEnabled={isExpanded} - // Android: collapsed single line centers natively (gravity) in - // a pill-height box matching the send button; iOS keeps insets. - singleLineCentered={!isExpanded} - contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6} - style={ - isExpanded - ? { - minHeight: 72, - maxHeight: 160, - paddingVertical: 4, - } - : { - height: 36, - } - } - textStyle={{ - ...bodyText, - color: foregroundColor, - }} - /> - {!isExpanded && props.draftAttachments.length > 0 ? ( - - {props.draftAttachments.slice(0, 3).map((attachment) => ( - + void props.onNativePasteImages(uris)} + placeholder={props.placeholder} + onFocus={handleFocus} + onBlur={handleBlur} + onSubmit={handleSend} + scrollEnabled={isExpanded} + // Android: collapsed single line centers natively (gravity) in + // a pill-height box matching the send button; iOS keeps insets. + singleLineCentered={!isExpanded} + contentInsetVertical={isExpanded || Platform.OS === "android" ? 0 : 6} + style={ + isExpanded + ? { + minHeight: 72, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + } + : { + height: 36, + } + } + textStyle={{ + ...bodyText, + color: foregroundColor, + }} + /> + + {!isExpanded && props.draftAttachments.length > 0 ? ( + + {props.draftAttachments.slice(0, 3).map((image) => ( + onPressImage(image.previewUri)}> + - ))} - {props.draftAttachments.length > 3 ? ( - - - +{props.draftAttachments.length - 3} - - - ) : null} - - ) : null} - {!isExpanded ? ( - - + ))} + {props.draftAttachments.length > 3 ? ( + + + +{props.draftAttachments.length - 3} + + + ) : null} + + ) : null} + {!isExpanded ? ( + + {showStopAction ? ( + + ) : ( + + )} + + ) : null} + {isExpanded ? ( + + + void props.onPickDraftImages()} + showChevron={false} + /> + + } + label={currentModelOption?.label ?? currentModelSelection.model} + maxWidth={152} + onPress={openSettings} /> {showStopAction ? ( - - ) : ( - - )} - - ) : null} - {isExpanded ? : null} - - - - - - {isVoiceInputPresented ? ( - - ) : ( - - - - - } - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth={152} - onPress={openSettings} - /> - - - )} - - - {showStopAction ? ( - - ) : voicePresentation.showsSend ? ( - - ) : null} - - - - + ) : null} + + + + ) : null} {/* Queue count */} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d0e553ebdcf9..fc887b7995a9 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -5,7 +5,6 @@ import { } from "@t3tools/client-runtime/codex-artifact-templates"; import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; -import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; import { HeaderHeightContext } from "@react-navigation/elements"; import type { @@ -32,6 +31,7 @@ import { useRef, useState, } from "react"; +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { AppState, Keyboard, @@ -49,13 +49,13 @@ import Animated, { Easing, FadeInDown, FadeOut, - ReduceMotion, useAnimatedReaction, useSharedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ControlPill } from "../../components/ControlPill"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; @@ -71,11 +71,6 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; -import { - FLOATING_WORKING_CONTROL_COVERAGE, - FloatingWorkingControl, - type FloatingWorkingStatus, -} from "./floating-working-control"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, @@ -84,8 +79,6 @@ import { import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, - COMPOSER_LAYOUT_TRANSITION, - COMPOSER_TRANSITION_DURATION_MS, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; @@ -100,7 +93,6 @@ export interface ThreadDetailScreenProps { readonly environmentLabel: string | null; readonly selectedThreadFeed: ReadonlyArray; readonly activeWorkStartedAt: string | null; - readonly isCompacting: boolean; readonly activePendingApproval: PendingApproval | null; readonly respondingApprovalId: ApprovalRequestId | null; readonly activePendingUserInput: PendingUserInput | null; @@ -141,7 +133,7 @@ export interface ThreadDetailScreenProps { readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, question: UserInputQuestion, - value: string, + label: string, ) => void; readonly onChangeUserInputCustomAnswer: ( requestId: ApprovalRequestId, @@ -303,51 +295,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". - const threadSyncLabel = (() => { + const threadSyncPhase = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": case "synchronizing": if (contentPresentationKind === "ready") { - return "Syncing messages..."; + return "syncing" as const; } - return contentPresentationKind === "loading" ? "Loading messages..." : null; + return contentPresentationKind === "loading" ? ("loading" as const) : null; default: return null; } })(); - // One floating pill above the composer: it reads the sync state while - // messages load, then the working timer once the feed is settled. - const floatingStatus = ((): FloatingWorkingStatus | null => { - if ( - props.connectionStateLabel !== "connected" || - props.activePendingApproval !== null || - props.activePendingUserInput !== null - ) { - return null; - } - if (threadSyncLabel !== null) { - return { kind: "syncing", label: threadSyncLabel }; - } - if (props.isCompacting && contentPresentationKind === "ready") { - return { kind: "compacting" }; - } - if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { - return { kind: "working", startedAt: props.activeWorkStartedAt }; - } - return null; - })(); - const showWorkingControl = floatingStatus !== null; const selectedThreadFeed = props.selectedThreadFeed; - const hasCompactableConversation = - selectedThreadFeed.some( - (entry) => - entry.type === "message" && - entry.message.role === "user" && - ((entry.message.attachments?.length ?? 0) > 0 || - entry.message.text.trim().toLowerCase() !== "/compact"), - ) || - (Boolean(props.loadEarlier) && props.selectedThread.latestUserMessageAt !== null); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; // While a user-input request is pending, the questionnaire owns the @@ -400,7 +361,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerOverlayRef, Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), -nativeInsetOvercount, - Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0, ); // The expanded questionnaire is an absolute overlay on iOS, so it never // changes the measured overlay height (that constancy is what keeps the @@ -414,15 +374,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const userInputCardProgress = useSharedValue(1); const userInputInsetProgress = useSharedValue(1); const userInputCardCoverage = useSharedValue(0); - const floatingControlCoverage = useSharedValue( - showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0, - ); - useEffect(() => { - floatingControlCoverage.value = withTiming( - showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0, - { duration: 180, reduceMotion: ReduceMotion.System }, - ); - }, [floatingControlCoverage, showWorkingControl]); // Android renders the expanded card in-flow (it cannot hit-test the iOS // overlay outside the bar's bounds), so its measured overlay height already // includes the card — the coverage extra is iOS-only. @@ -433,7 +384,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useAnimatedReaction( () => contentInsetEndAdjustment.value + - floatingControlCoverage.value + (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), (value) => { combinedContentInsetEndAdjustment.value = value; @@ -443,24 +393,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const endFollowEnabledRef = useRef(true); endFollowEnabledRef.current = endFollowEnabled; - const overlayRepinTimerRef = useRef | null>(null); - const previousWorkingControlStateRef = useRef({ - threadKey: selectedThreadKey, - visible: false, - }); + const userInputRepinTimerRef = useRef | null>(null); // The list's own corrections for these inset changes drift on short // content (and the error compounds across toggles), so deterministically // re-pin the end once a toggle settles: a no-op when the resting position // is already right, corrective when it is not. Follow state is re-checked // inside the callback — the user may grab the list during the settle // window, and yanking them back would override a live gesture. - const scheduleOverlayRepin = useCallback( + const scheduleUserInputRepin = useCallback( (delayMs: number) => { - if (overlayRepinTimerRef.current !== null) { - clearTimeout(overlayRepinTimerRef.current); + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); } - overlayRepinTimerRef.current = setTimeout(() => { - overlayRepinTimerRef.current = null; + userInputRepinTimerRef.current = setTimeout(() => { + userInputRepinTimerRef.current = null; if (!endFollowEnabledRef.current) { return; } @@ -473,29 +419,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); useEffect( () => () => { - if (overlayRepinTimerRef.current !== null) { - clearTimeout(overlayRepinTimerRef.current); + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); } }, [], ); - useEffect(() => { - const previous = previousWorkingControlStateRef.current; - const threadChanged = previous.threadKey !== selectedThreadKey; - const visibilityChanged = previous.visible !== showWorkingControl; - previousWorkingControlStateRef.current = { - threadKey: selectedThreadKey, - visible: showWorkingControl, - }; - if ((!threadChanged && !visibilityChanged) || (threadChanged && !showWorkingControl)) { - return; - } - // LegendList applies the larger inset but does not re-anchor short - // followed conversations when this floating coverage changes after the - // initial load. Re-pin after the finite inset transition; the callback - // checks follow state again so a user who scrolled up stays put. - scheduleOverlayRepin(230); - }, [scheduleOverlayRepin, selectedThreadKey, showWorkingControl]); const handleToggleUserInputCollapsed = useCallback(() => { if (activeUserInputRequestId === null) { return; @@ -505,7 +434,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); setCollapsedUserInputRequestId(null); - scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); + scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); } else { // Collapsing hides the custom-answer inputs; release the keyboard with // them instead of leaving it up over a dead responder. @@ -516,11 +445,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // anchor. userInputInsetProgress.value = 0; setCollapsedUserInputRequestId(activeUserInputRequestId); - scheduleOverlayRepin(60); + scheduleUserInputRepin(60); } }, [ activeUserInputRequestId, - scheduleOverlayRepin, + scheduleUserInputRepin, userInputCardProgress, userInputCollapsed, userInputInsetProgress, @@ -536,14 +465,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const selectedProviderSkills = useMemo(() => { - const provider = props.serverConfig?.providers.find( - (candidate) => candidate.instanceId === selectedInstanceId, - ); - return provider - ? resolveProviderSkillsForCwd(provider, props.threadCwd ?? props.projectWorkspaceRoot) - : []; - }, [props.projectWorkspaceRoot, props.serverConfig, props.threadCwd, selectedInstanceId]); + const selectedProviderSkills = useMemo( + () => + props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) + ?.skills ?? [], + [props.serverConfig, selectedInstanceId], + ); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; @@ -731,9 +658,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} - contentBottomInset={ - estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0) - } + contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} @@ -751,111 +676,137 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( - {/* The fixed sticky host gives this bottom-anchored child a stable - coordinate space. Its top and height can then animate together - instead of the auto-sized host jumping to Yoga's destination. */} - - {/* No paddingTop here: the overlay's measured height becomes the - list's bottom inset, so any padding above the pill/composer - pushes the resting content floor up by the same amount. */} - - - - {props.activePendingApproval || props.activePendingUserInput ? ( - + {showScrollToEndButton ? ( + + {isLiquidGlassSupported ? ( + - {props.activePendingApproval ? ( - - ) : null} - {props.activePendingUserInput ? ( - - ) : null} - - ) : null} - - - {/* Hidden (not unmounted) while a user-input request owns the + + + ) : ( + + )} + + ) : null} + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + + + {/* Hidden (not unmounted) while a user-input request owns the composer slot, so composer drafts and editor state survive. */} - - - + + - + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 360b980edc95..f841103d3e46 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -28,8 +28,8 @@ import { splitCodexArtifactTemplateMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; -import { videoMimeType } from "@t3tools/shared/video"; -import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; +import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; +import { SymbolView } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { @@ -72,12 +72,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP import { isPdfFile } from "../../lib/filePreview"; import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { - FadeIn, - FadeInUp, - LinearTransition, - type SharedValue, -} from "react-native-reanimated"; +import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; @@ -90,7 +85,6 @@ import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, - type MarkdownFileContextMenu, type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, @@ -102,7 +96,6 @@ import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { - attachmentVideoPreviewSource, mediaVideoPreviewUri, mediaVideoThumbnailKey, type MediaVideoPreviewSource, @@ -130,7 +123,9 @@ import { import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, + scaledTypographyLineHeight, } from "../../lib/appearancePreferences"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; @@ -141,7 +136,6 @@ import { } from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, - isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; @@ -156,7 +150,6 @@ import { ThreadDisclosureChevron, ThreadWorkGroupToggle, ThreadWorkLog, - THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; @@ -175,12 +168,7 @@ import { isAbsolutePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; -import { - ThreadMarkdownImage, - ThreadMarkdownImageUnavailable, - ThreadMarkdownImageView, -} from "./ThreadMarkdownImage"; +import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // Native iOS blockquotes and adjacent selectable text are separate layout @@ -202,14 +190,15 @@ function formatMessageTime(input: string): string { return MESSAGE_TIME_FORMATTER.format(timestamp); } -// Fixed heights mirror renderFeedEntry's classNames and are only used while -// text fits at the current font settings. Larger accessibility text is measured. -const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem -const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); -// Let neighboring rows move out of the new rows' space before showing their text. -const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( - THREAD_DISCLOSURE_TRANSITION_MS, -).duration(140); +// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's +// classNames. The fold row's min-h-11 (44px) stays taller than its single +// text-sm line at every supported base font size (26px at the 22pt maximum), +// so its height is a constant; a drifted value costs one correction on +// measure, not a persistent offset. +const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12) +// The working row has no min-height clamp — its height follows the scaled +// text-xs line height (see workingRowHeight in ThreadFeed). +const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16) // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for @@ -254,21 +243,14 @@ function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; - readonly mimeType: string; readonly className: string; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); - const resource = useMemo( - () => ({ - _tag: "attachment" as const, - attachmentId: props.attachmentId, - fileName: props.name, - mimeType: props.mimeType, - }), - [props.attachmentId, props.name, props.mimeType], - ); - const uri = useAssetUrl(props.environmentId, resource); + const uri = useAssetUrl(props.environmentId, { + _tag: "attachment", + attachmentId: props.attachmentId, + }); if (uri === null) { return ( @@ -284,20 +266,7 @@ function MessageAttachmentImage(props: { accessibilityRole="imagebutton" accessibilityLabel={`Open ${props.name}`} onPress={() => - // The viewer mints its own URL from the resource so the image survives a refresh. - props.onPressPreview({ - kind: "image", - environmentId: props.environmentId, - resource, - name: props.name, - sourceIdentifier, - actionsSource: { - name: props.name, - mimeType: props.mimeType, - environmentId: props.environmentId, - resource, - }, - }) + props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) } > @@ -412,18 +381,14 @@ function MessageAttachmentFile(props: { }; if (videoType !== null) { - const sourceIdentifier = `attachment:${props.environmentId}:${attachment.id}`; return ( props.onPressVideo(attachment, sourceIdentifier)} + onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} className="my-1 rounded-2xl" style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} /> @@ -506,6 +471,173 @@ function MessageAttachmentUnknown(props: { readonly name: string }) { ); } +function ThreadMarkdownImageView(props: { + readonly uri: string | null; + readonly sourceKey: string; + readonly unavailable: boolean; + readonly alt: string | null; + readonly actionsSource?: MediaActionsSource; + readonly onPressPreview: (source: FilePreviewSource) => void; +}) { + const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); + const [availableWidth, setAvailableWidth] = useState(0); + const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const [failedUri, setFailedUri] = useState(null); + + useEffect(() => { + setSourceSize(null); + }, [props.sourceKey]); + + useEffect(() => { + setFailedUri(null); + }, [props.uri]); + + const displaySize = + sourceSize === null + ? null + : resolveMarkdownImageDisplaySize({ + sourceWidth: sourceSize.width, + sourceHeight: sourceSize.height, + availableWidth, + }); + const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const placeholderWidth: ViewStyle["width"] = + availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; + const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; + + return ( + setAvailableWidth(event.nativeEvent.layout.width)} + style={{ alignSelf: "stretch", gap: 6 }} + > + {props.uri === null || failed ? ( + + {failed ? ( + Image unavailable + ) : ( + + )} + {props.actionsSource ? ( + + + + ) : null} + + ) : ( + + + + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + + {props.actionsSource ? ( + + + + ) : null} + + + )} + {props.alt ? ( + + {props.alt} + + ) : null} + + ); +} + +function ThreadMarkdownImageRequest(props: { + readonly uri: string; + readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onError: () => void; +}) { + const [loaded, setLoaded] = useState(false); + + return ( + <> + { + setLoaded(true); + props.onLoad(event.nativeEvent.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : ( + + Loading image… + + )} + + ); +} + +/** Environment-hosted image that loads through a signed asset URL. */ +function ThreadMarkdownImage(props: { + readonly environmentId: EnvironmentId; + readonly resource: Extract; + readonly alt: string | null; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; + readonly onPressPreview: (source: FilePreviewSource) => void; +}) { + const assetUrl = useAssetUrlState(props.environmentId, props.resource); + + return ( + + ); +} + const ThreadMediaVisibleContext = createContext(false); // LegendList only computes hook visibility when the list has a viewability config. const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; @@ -518,7 +650,10 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { return {props.children}; } -function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { +function ThreadMarkdownVideo(props: { + readonly source: MediaVideoPreviewSource; + readonly onExpand: (source: MediaVideoPreviewSource) => void; +}) { const { source } = props; const visible = useContext(ThreadMediaVisibleContext); const thumbnailKey = mediaVideoThumbnailKey(source); @@ -545,6 +680,7 @@ function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource } thumbnailVisible={visible} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} + onExpand={() => props.onExpand(source)} /> ); } @@ -721,17 +857,10 @@ function ArtifactTemplateCard(props: { ); } -/** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ -interface MarkdownLinkHandlers { - readonly onLinkPress: (href: string) => void; - readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; - readonly onFileContextMenuAction: (href: string, actionId: string) => void; -} - const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly markdownStyles: MarkdownStyleSet; - readonly linkHandlers: MarkdownLinkHandlers; + readonly onLinkPress: (href: string) => void; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; @@ -760,7 +889,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { markdown={markdown} skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} - {...props.linkHandlers} + onLinkPress={props.onLinkPress} renderImage={props.renderImage} /> ) : ( @@ -1321,7 +1450,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; - readonly markdownLinkHandlers: MarkdownLinkHandlers; + readonly onMarkdownLinkPress: (href: string) => void; readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; @@ -1329,13 +1458,16 @@ function renderFeedEntry( readonly markdownStyles: MarkdownStyleSets; readonly reviewCommentColors: ReviewCommentColors; readonly reviewCommentBubbleWidth: number; - readonly themeAppearance: "light" | "dark"; readonly userBubbleMaxWidth: number; }, ) { const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "working") { + return ; + } + if (entry.type === "turn-fold") { return ( props.onToggleWorkGroup(entry.groupId, entry.id)} + onlyToolActivities={entry.onlyToolActivities} + onToggle={() => props.onToggleWorkGroup(entry.groupId)} /> ); } - if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) { - const label = entry.activities[0]!.summary; - return ( - - - - - {label} - - - - ); - } - if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; @@ -1457,7 +1558,7 @@ function renderFeedEntry( markdownStyles={styles} reviewCommentColors={props.reviewCommentColors} skills={props.skills} - linkHandlers={props.markdownLinkHandlers} + onLinkPress={props.onMarkdownLinkPress} renderImage={props.renderMarkdownImage} /> ) : null} @@ -1468,7 +1569,6 @@ function renderFeedEntry( environmentId={props.environmentId} attachmentId={attachment.id} name={attachment.name} - mimeType={attachment.mimeType} className="aspect-[1.3] w-full rounded-[14px] bg-white/15" onPressPreview={props.onPressPreview} /> @@ -1519,7 +1619,7 @@ function renderFeedEntry( @@ -1572,14 +1671,12 @@ function renderFeedEntry( // Anchors/details live in ThreadFeed and survive this group-only remount. key={`${entry.id}:${props.workRowSizing.textSizeKey}`} activities={entry.activities} - environmentId={props.environmentId} anchorKey={entry.id} copiedRowId={props.copiedRowId} expandedRows={props.expandedWorkRows} rowSizing={props.workRowSizing} scrollPositions={props.workGroupScrollPositions} iconSubtleColor={iconSubtleColor} - themeAppearance={props.themeAppearance} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} renderImage={props.renderViewedImage} @@ -1587,12 +1684,38 @@ function renderFeedEntry( ); } +const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const intervalId = setInterval(() => { + setNowMs(Date.now()); + }, 1_000); + return () => clearInterval(intervalId); + }, [props.startedAt]); + + const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; + + return ( + + + + + + + + Working for {durationLabel} + + + ); +}); + function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; readonly skills?: ReadonlyArray; - readonly linkHandlers: MarkdownLinkHandlers; + readonly onLinkPress: (href: string) => void; readonly renderImage: MarkdownImageRenderer; }) { const segments = parseReviewCommentMessageSegments(props.text); @@ -1605,7 +1728,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - {...props.linkHandlers} + onLinkPress={props.onLinkPress} renderImage={props.renderImage} /> ); @@ -1647,7 +1770,7 @@ function UserMessageContent(props: { skills={props.skills} textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks - {...props.linkHandlers} + onLinkPress={props.onLinkPress} renderImage={props.renderImage} /> ) : ( @@ -1859,30 +1982,15 @@ function ThreadFeedPlaceholder(props: { export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const navigation = useNavigation(); - const { themeAppearance } = useAppearancePreferences(); const copyFeedbackTimeoutRef = useRef | null>(null); - const disclosureSettleFrameRef = useRef(null); - const disclosureSettleSecondFrameRef = useRef(null); + const foldSettleFrameRef = useRef(null); + const foldSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const userScrollSettleTimerRef = useRef | null>(null); - const { width: windowWidth, fontScale } = useWindowDimensions(); + const { width: windowWidth } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); - const workRowSizing = useMemo( - () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }), - [appearance.baseFontSize, fontScale], - ); - const previousTextSize = useRef(workRowSizing.textSizeKey); - useLayoutEffect(() => { - if (previousTextSize.current === workRowSizing.textSizeKey) { - return; - } - previousTextSize.current = workRowSizing.textSizeKey; - // Text-size changes invalidate the outer list's fixed-height cache too. - // This never runs for scrolling, streamed output, or disclosure toggles. - props.listRef.current?.clearCaches({ mode: "sizes" }); - }, [workRowSizing.textSizeKey, props.listRef]); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); @@ -2060,31 +2168,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); - const markdownLinkHandlers = useMemo( - () => ({ - onLinkPress: onMarkdownLinkPress, - fileContextMenu: (href) => { - const target = resolveFileChipTarget(href, props.workspaceRoot); - return target ? fileChipMenu(target) : undefined; - }, - onFileContextMenuAction: (href, actionId) => { - const target = resolveFileChipTarget(href, props.workspaceRoot); - if (!target) return; - switch (actionId as FileChipAction) { - case "copy-full-path": - if (target.fullPath) copyTextWithHaptic(target.fullPath); - return; - case "copy-relative-path": - if (target.relativePath) copyTextWithHaptic(target.relativePath); - return; - case "open-file": - onMarkdownLinkPress(href); - return; - } - }, - }), - [onMarkdownLinkPress, props.workspaceRoot], - ); const renderMarkdownImage = useCallback( (image) => { const media = resolveMarkdownMediaPreview(image.href, { @@ -2098,6 +2181,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedVideo((current) => current ?? source)} /> ); } @@ -2178,7 +2262,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { iconSubtleColor, markdownStyles, reviewCommentColors, - themeAppearance, userBubbleColor, viewportWidth, }), @@ -2189,7 +2272,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { iconSubtleColor, markdownStyles, reviewCommentColors, - themeAppearance, userBubbleColor, viewportWidth, ], @@ -2342,10 +2424,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); - // The empty↔filled key below remounts the list and resets its imperative - // content-inset override. Seed the fresh instance synchronously with the - // current overlay height before the scroll integration's next reaction; - // on Android the declarative contentInset floor covers this same window. + + // The empty↔filled key below remounts the list, which resets its imperative + // content-inset override — and useKeyboardChatComposerInset (mounted above + // the remount boundary) deduplicates by height, so it never re-reports the + // composer inset to the fresh instance. Re-report the measured overlay height + // (composer plus any pending approval / user-input card) so the remounted + // list's scroll math gets the true value; on Android the declarative + // contentInset floor below covers the window before this effect lands. const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; @@ -2410,62 +2496,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } - if (disclosureSettleFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleFrameRef.current); + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); } - if (disclosureSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); } }; }, []); - const settleDisclosureAfterLayout = useCallback(() => { - if (disclosureSettleFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleFrameRef.current); + const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { + disclosureAnchorKeyRef.current = anchorKey; + setDisclosureToggleSettling(true); + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); } - if (disclosureSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); } - disclosureSettleFrameRef.current = requestAnimationFrame(() => { - disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - // A disclosure can leave the reader above the end without a drag. - // Reconcile follow before a later layout or resume can re-pin it. - const listState = props.listRef.current?.getState(); - if (listState) { - transitionEndFollow({ - type: "disclosure-settled", - isAtEnd: listState.isAtEnd, - userScrollSessionActive: userScrollSessionRef.current, - }); - } + foldSettleFrameRef.current = requestAnimationFrame(() => { + foldSettleSecondFrameRef.current = requestAnimationFrame(() => { disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); - disclosureSettleFrameRef.current = null; - disclosureSettleSecondFrameRef.current = null; + foldSettleFrameRef.current = null; + foldSettleSecondFrameRef.current = null; }); }); - }, [props.listRef, transitionEndFollow]); - - const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { - disclosureAnchorKeyRef.current = anchorKey; - setDisclosureToggleSettling(true); }, []); - // Start the quiet-frame countdown after React has committed the disclosure. - // Every measured item-size change restarts it, so end maintenance cannot - // wake between the data mutation and LegendList's final layout correction. - useLayoutEffect(() => { - if (disclosureAnchorKeyRef.current !== null) { - settleDisclosureAfterLayout(); - } - }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]); - - const handleItemSizeChanged = useCallback(() => { - if (disclosureAnchorKeyRef.current !== null) { - settleDisclosureAfterLayout(); - } - }, [settleDisclosureAfterLayout]); - const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || entry.id === disclosureAnchorKey; @@ -2548,8 +2606,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (attachment: ChatFileAttachment, sourceIdentifier: string) => { setExpandedVideo( (current) => - current ?? - attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), + current ?? { + type: "remote", + environmentId: props.environmentId, + attachment, + sourceIdentifier, + }, ); }, [props.environmentId], @@ -2560,7 +2622,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // scrolling up through unmeasured content corrects each row's height as it // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows // exact; message rows stay undefined and use LegendList's per-type running - // average once one of their type has been measured. + // average once one of their type has been measured. Text-driven heights + // follow the configurable base font size via scaledTypographyLineHeight. + const workingRowHeight = + WORKING_ROW_VERTICAL_EXTRAS + + scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize); const getFixedItemSize = useCallback( (entry: ThreadFeedEntry) => { if (workRowSizing.fixedRowHeight === undefined) { @@ -2571,64 +2637,48 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return TURN_FOLD_HEIGHT; case "work-toggle": return WORK_GROUP_TOGGLE_HEIGHT; + case "working": + return workingRowHeight; case "activity-group": - if (isContextCompactionActivityGroup(entry)) { - return undefined; - } // Expanded rows append a variable detail block — fall back to // measurement for those groups. return entry.activities.some((activity) => expandedWorkRows[activity.id]) ? undefined - : collapsedWorkLogHeight(entry.activities); + : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize); default: return undefined; } }, - [expandedWorkRows, workRowSizing.fixedRowHeight], + [expandedWorkRows, workingRowHeight, appearance.baseFontSize], ); // Disclosures can mount existing offscreen rows as well as new work rows. // Fade those in after movement; never retain removed rows over replacements. const renderItem = useCallback( - (info: { item: ThreadFeedEntry; index: number }) => ( - - - {renderFeedEntry(info, { - environmentId: props.environmentId, - copiedRowId, - expandedWorkRows, - workRowSizing, - workGroupScrollPositions, - terminalAssistantMessageIds, - unsettledTurnId, - onCopyWorkRow, - onToggleWorkGroup, - onToggleWorkRow, - onToggleTurnFold, - onPressPreview, - onPressVideo, - markdownLinkHandlers, - renderMarkdownImage, - renderViewedImage, - iconSubtleColor, - userBubbleColor, - markdownStyles, - reviewCommentColors, - reviewCommentBubbleWidth, - themeAppearance, - userBubbleMaxWidth, - skills: props.skills, - onUseArtifactTemplate: props.onUseArtifactTemplate, - })} - - - ), + (info: { item: ThreadFeedEntry; index: number }) => + renderFeedEntry(info, { + environmentId: props.environmentId, + copiedRowId, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, + onCopyWorkRow, + onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, + onPressImage, + onMarkdownLinkPress, + renderMarkdownImage, + iconSubtleColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + userBubbleMaxWidth, + skills: props.skills, + }), [ copiedRowId, - disclosureToggleSettling, expandedWorkRows, workRowSizing, workGroupScrollPositions, @@ -2639,10 +2689,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, - themeAppearance, userBubbleMaxWidth, onCopyWorkRow, - markdownLinkHandlers, + onMarkdownLinkPress, onPressPreview, onPressVideo, onToggleTurnFold, @@ -2701,7 +2750,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - // Patched LegendList prop (patches/@legendapp__list@3.3.5.patch): + // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): // lets its scroll math clamp programmatic scrolls to -headerInset // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short // content rest below the transparent header rather than at frame top. @@ -2757,8 +2806,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} - itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION} - onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual // corrections land offscreen instead of under the user's finger. drawDistance={500} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx deleted file mode 100644 index 0d7f7d88f014..000000000000 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { GlassContainer, GlassView } from "expo-glass-effect"; -import { useEffect, useState } from "react"; -import { ActivityIndicator, Text as SystemText, View } from "react-native"; -import Animated, { - Easing, - FadeIn, - FadeOut, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { withUniwind } from "uniwind"; - -import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; -import { ControlPill } from "../../components/ControlPill"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; - -const CONTROL_HEIGHT = 44; -const CONTROL_COMPOSER_GAP = 8; -const GLASS_MERGE_SPACING = 12; -const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); -const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); -const CONTROL_TIMING = { - duration: 240, - easing: Easing.out(Easing.cubic), - reduceMotion: ReduceMotion.System, -} as const; -const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; - -// Expo reapplies glass after native layout and window reattachment, when UIKit -// can otherwise leave the label visible but lose the material behind it. -const UniwindGlassView = withUniwind(GlassView, { - style: { fromClassName: "className" }, -}); -const UniwindGlassContainer = withUniwind(GlassContainer, { - style: { fromClassName: "className" }, -}); -const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); - -export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; - -/** - * What the floating pill says. Syncing and working share one element so the - * label swaps in place instead of one pill fading out for another. - */ -export type FloatingWorkingStatus = - | { readonly kind: "working"; readonly startedAt: string } - | { readonly kind: "syncing"; readonly label: string } - | { readonly kind: "compacting" }; - -export function FloatingWorkingControl(props: { - readonly colorScheme: "light" | "dark"; - readonly status: FloatingWorkingStatus | null; - readonly showScrollToEnd: boolean; - readonly onScrollToEnd: () => void; -}) { - const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); - - useEffect(() => { - separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); - }, [props.showScrollToEnd, separationProgress]); - - const timerStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], - })); - const arrowTransformStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }], - })); - const arrowContentStyle = useAnimatedStyle(() => ({ - opacity: separationProgress.value, - })); - - if (props.status === null && !props.showScrollToEnd) { - return null; - } - - return ( - - {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( - - - - - - - - - - - - ) : props.status !== null ? ( - - - - - - - - - - ) : NATIVE_LIQUID_GLASS_SUPPORTED ? ( - - - - ) : ( - - )} - - ); -} - -function CompactingLabel() { - return ( - - - Compacting… - - ); -} - -function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) { - if (props.status.kind === "syncing") { - return ( - - - {props.status.label} - - ); - } - if (props.status.kind === "compacting") { - return ; - } - return ; -} - -function WorkingDuration(props: { readonly startedAt: string }) { - const [nowMs, setNowMs] = useState(() => Date.now()); - - useEffect(() => { - setNowMs(Date.now()); - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, [props.startedAt]); - - const duration = formatWorkingDuration(props.startedAt, nowMs); - const label = `Working for ${duration}`; - - return ( - - Working for - - {duration} - - - ); -} - -function formatWorkingDuration(startedAt: string, nowMs: number): string { - const startedAtMs = Date.parse(startedAt); - if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) { - return "0s"; - } - - const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000); - if (totalSeconds < 60) { - return `${totalSeconds}s`; - } - - const minutes = Math.floor(totalSeconds / 60); - const seconds = String(totalSeconds % 60).padStart(2, "0"); - return `${minutes}m ${seconds}s`; -} - -function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) { - return ( - - ); -} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index df3dd3197d0d..92ba3ef108a5 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -16,7 +16,6 @@ import { buildPendingUserInputAnswers, buildThreadFeed, derivePendingApprovals, - derivePendingUserInputs, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -75,71 +74,15 @@ const multiSelectQuestion = { multiSelect: true, } as const; -const nativeQuestion = { - id: "choice", - header: "File", - question: "Which file should be used?", - options: [ - { label: "Use this", description: "First file", value: " choice " }, - { label: "Use this", description: "Second file", value: "choice" }, - ], - multiSelect: false, - allowCustomAnswer: false, -} as const; - describe("pending user input answers", () => { - it("accepts free-text answers to async questions without options", () => { - const question = { - id: "0", - header: "Question", - question: "What should it be named?", - options: [], - allowCustomAnswer: true, - multiSelect: false, - }; - const requested = makeActivity({ - id: EventId.make("async-question"), - kind: "user-input.requested", - summary: "User input requested", - createdAt: "2026-09-03T00:00:00.000Z", - payload: { requestId: "async-1", responseMode: "message", questions: [question] }, - }); - const questions = derivePendingUserInputs([requested])[0]?.questions; - expect(questions).toEqual([question]); - expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({ - "0": "Example", - }); - }); - - it("preserves native choice values and custom-answer rules from activities", () => { - const requested = makeActivity({ - id: EventId.make("native-question"), - kind: "user-input.requested", - summary: "User input requested", - createdAt: "2026-09-02T00:00:00.000Z", - payload: { - requestId: "interaction_1", - questions: [nativeQuestion, singleSelectQuestion], - }, - }); - - expect(derivePendingUserInputs([requested])).toEqual([ - { - requestId: "interaction_1", - createdAt: requested.createdAt, - questions: [nativeQuestion, singleSelectQuestion], - }, - ]); - }); - it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( singleSelectQuestion, - { selectedOptionValues: ["Go"] }, + { selectedOptionLabels: ["Go"] }, "Node.js", ), - ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] }); + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] }); const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); const ordersAndListings = togglePendingUserInputOptionSelection( @@ -149,18 +92,18 @@ describe("pending user input answers", () => { ); expect(ordersAndListings).toEqual({ customAnswer: "", - selectedOptionValues: ["Orders", "Listings"], + selectedOptionLabels: ["Orders", "Listings"], }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), - ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] }); + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); const paddedOrders = togglePendingUserInputOptionSelection( multiSelectQuestion, undefined, " Orders ", ); - expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] }); + expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), ).toEqual({ customAnswer: "" }); @@ -169,8 +112,8 @@ describe("pending user input answers", () => { it("builds array answers for multi-select questions", () => { expect( buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { - runtime: { selectedOptionValues: ["Go"] }, - scope: { selectedOptionValues: ["Orders", "Listings"] }, + runtime: { selectedOptionLabels: ["Go"] }, + scope: { selectedOptionLabels: ["Orders", "Listings"] }, }), ).toEqual({ runtime: "Go", @@ -181,85 +124,23 @@ describe("pending user input answers", () => { it("clears selected options while a custom answer is active", () => { expect( setPendingUserInputCustomAnswer( - multiSelectQuestion, - { selectedOptionValues: ["Orders", "Listings"] }, + { selectedOptionLabels: ["Orders", "Listings"] }, "Orders first", ), ).toEqual({ customAnswer: "Orders first" }); }); - it("matches selected options against normalized legacy labels", () => { + it("matches selected chips against normalized option labels", () => { expect( - isPendingUserInputOptionSelected( - multiSelectQuestion, - { selectedOptionValues: ["Orders"] }, - " Orders ", - ), + isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "), ).toBe(true); expect( isPendingUserInputOptionSelected( - multiSelectQuestion, - { selectedOptionValues: ["Orders"], customAnswer: "Orders first" }, + { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" }, " Orders ", ), ).toBe(false); }); - - it("keeps custom answers enabled for legacy questions", () => { - expect( - buildPendingUserInputAnswers([singleSelectQuestion], { - runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " }, - }), - ).toEqual({ runtime: "Use Bun" }); - }); - - it("keeps duplicate labels and whitespace-sensitive native values separate", () => { - const first = togglePendingUserInputOptionSelection(nativeQuestion, undefined, " choice "); - expect(isPendingUserInputOptionSelected(nativeQuestion, first, " choice ")).toBe(true); - expect(isPendingUserInputOptionSelected(nativeQuestion, first, "choice")).toBe(false); - expect(buildPendingUserInputAnswers([nativeQuestion], { choice: first })).toEqual({ - choice: " choice ", - }); - - const second = togglePendingUserInputOptionSelection(nativeQuestion, first, "choice"); - expect(isPendingUserInputOptionSelected(nativeQuestion, second, " choice ")).toBe(false); - expect(isPendingUserInputOptionSelected(nativeQuestion, second, "choice")).toBe(true); - expect(buildPendingUserInputAnswers([nativeQuestion], { choice: second })).toEqual({ - choice: "choice", - }); - }); - - it("keeps exact native values in multi-select answers", () => { - const question = { ...nativeQuestion, multiSelect: true }; - const first = togglePendingUserInputOptionSelection(question, undefined, " choice "); - const both = togglePendingUserInputOptionSelection(question, first, "choice"); - expect(buildPendingUserInputAnswers([question], { choice: both })).toEqual({ - choice: [" choice ", "choice"], - }); - - const second = togglePendingUserInputOptionSelection(question, both, " choice "); - expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({ - choice: ["choice"], - }); - }); - - it("ignores custom answers when a question only accepts choices", () => { - const draft = { selectedOptionValues: [" choice "], customAnswer: "Other" }; - expect(setPendingUserInputCustomAnswer(nativeQuestion, draft, "Custom text")).toBe(draft); - expect(isPendingUserInputOptionSelected(nativeQuestion, draft, " choice ")).toBe(true); - expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toEqual({ - choice: " choice ", - }); - }); - - it.each([ - { customAnswer: "Other" }, - { selectedOptionValues: ["Use this"] }, - { selectedOptionValues: ["not offered"] }, - { selectedOptionValues: [" choice "] }, - ])("requires an offered value for a choice-only question: %j", (draft) => { - expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toBeNull(); - }); }); describe("pending approvals", () => { @@ -415,200 +296,6 @@ function makeThread( } describe("buildThreadFeed", () => { - it("reuses unchanged feed and presentation rows during an assistant text update", () => { - const completedTurnId = TurnId.make("completed-turn"); - const activeTurnId = TurnId.make("active-turn"); - const thread = makeThread({ - id: ThreadId.make("feed-reuse"), - projectId: ProjectId.make("project-1"), - title: "Feed reuse", - messages: [ - { - id: MessageId.make("completed-message"), - role: "assistant", - text: "Completed response", - turnId: completedTurnId, - streaming: false, - createdAt: "2026-04-01T00:00:01.000Z", - updatedAt: "2026-04-01T00:00:01.000Z", - }, - { - id: MessageId.make("streaming-message"), - role: "assistant", - text: "Current response", - turnId: activeTurnId, - streaming: true, - createdAt: "2026-04-01T00:00:04.000Z", - updatedAt: "2026-04-01T00:00:04.000Z", - }, - ], - activities: [ - makeActivity({ - id: EventId.make("completed-tool"), - kind: "tool.completed", - summary: "Read files", - createdAt: "2026-04-01T00:00:02.000Z", - turnId: completedTurnId, - payload: { itemType: "file_read", status: "completed" }, - }), - makeActivity({ - id: EventId.make("active-tool"), - kind: "tool.updated", - summary: "Run checks", - createdAt: "2026-04-01T00:00:03.000Z", - turnId: activeTurnId, - payload: { itemType: "command_execution", command: "vp test", status: "inProgress" }, - }), - ], - }); - const latestTurn = { - turnId: activeTurnId, - state: "running" as const, - startedAt: "2026-04-01T00:00:03.000Z", - completedAt: null, - }; - const expandedTurns = new Set([completedTurnId]); - const expandedGroups = new Set(["work-group:completed-tool", "work-group:active-tool"]); - const previousFeed = buildThreadFeed(thread); - const previousRows = deriveThreadFeedPresentation( - previousFeed, - latestTurn, - expandedTurns, - expandedGroups, - latestTurn.startedAt, - ); - const updatedMessage = { - ...thread.messages[1]!, - text: "Current response with more text", - updatedAt: "2026-04-01T00:00:05.000Z", - }; - const nextFeed = buildThreadFeed({ - ...thread, - messages: [thread.messages[0]!, updatedMessage], - }); - const nextRows = deriveThreadFeedPresentation( - nextFeed, - latestTurn, - expandedTurns, - expandedGroups, - latestTurn.startedAt, - ); - - expect(nextFeed).toHaveLength(previousFeed.length); - expect(nextRows).toHaveLength(previousRows.length); - for (const [before, after] of [ - [previousFeed, nextFeed], - [previousRows, nextRows], - ] as const) { - for (const [index, row] of after.entries()) { - if (row.id === updatedMessage.id) { - expect(row).not.toBe(before[index]); - expect(row).toMatchObject({ message: updatedMessage }); - } else { - expect(row).toBe(before[index]); - } - } - } - expect(nextRows.some((row) => row.type === "turn-fold")).toBe(true); - expect(nextRows.some((row) => row.type === "activity-group")).toBe(true); - }); - - it("regroups cached activities for message changes and pagination", () => { - const messages = [2, 4].map((second) => ({ - id: MessageId.make(`message-${second}`), - role: "assistant" as const, - text: second === 2 ? "" : "Response", - streaming: false, - turnId: null, - createdAt: `2026-04-01T00:00:0${second}.000Z`, - updatedAt: `2026-04-01T00:00:0${second}.000Z`, - })); - const thread = makeThread({ - id: ThreadId.make("feed-regroup"), - projectId: ProjectId.make("project-1"), - title: "Feed grouping", - messages, - activities: [1, 3, 5].map((second) => - makeActivity({ - id: EventId.make(`work-${second}`), - kind: "runtime.warning", - summary: `Notice ${second}`, - createdAt: `2026-04-01T00:00:0${second}.000Z`, - }), - ), - }); - const initial = buildThreadFeed(thread); - expect(initial.map((row) => row.id)).toEqual(["work-1", "message-4", "work-5"]); - const split = buildThreadFeed({ - ...thread, - messages: [{ ...messages[0]!, text: "Now visible" }, messages[1]!], - }); - expect(split.map((row) => row.id)).toEqual([ - "work-1", - "message-2", - "work-3", - "message-4", - "work-5", - ]); - expect(split[0]).not.toBe(initial[0]); - expect(split.at(-1)).toBe(initial.at(-1)); - expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }] }); - - const reordered = buildThreadFeed({ - ...thread, - messages: [messages[0]!, { ...messages[1]!, createdAt: "2026-04-01T00:00:06.000Z" }], - }); - expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-4"]); - expect(reordered[0]).toMatchObject({ - activities: [{ id: "work-1" }, { id: "work-3" }, { id: "work-5" }], - }); - const olderMessage = { - ...messages[1]!, - id: MessageId.make("older-message"), - createdAt: "2026-04-01T00:00:00.000Z", - }; - const page = buildThreadFeed(thread, { - loadedMessages: [messages[1]!], - localMessages: [olderMessage], - }); - expect(page.map((row) => row.id)).toEqual(["older-message", "message-4", "work-5"]); - const prepended = buildThreadFeed(thread, { loadedMessages: [olderMessage, ...messages] }); - expect(prepended.map((row) => row.id)).toEqual([ - "older-message", - "work-1", - "message-4", - "work-5", - ]); - expect(prepended.at(-1)).toBe(page.at(-1)); - }); - - it("keeps context compaction as a standalone timeline row", () => { - const thread = makeThread({ - id: ThreadId.make("thread-context-compaction"), - projectId: ProjectId.make("project-1"), - title: "Context compaction", - activities: [ - makeActivity({ - id: EventId.make("context-compaction"), - kind: "context-compaction", - tone: "info", - summary: "Compacted context 899K → 19K tokens", - createdAt: "2026-09-01T00:00:00.000Z", - turnId: TurnId.make("turn-context-compaction"), - }), - ], - }); - - const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set()); - expect(presented).toMatchObject([ - { - type: "activity-group", - id: "context-compaction", - activities: [{ summary: "Compacted context 899K → 19K tokens" }], - }, - ]); - }); - it("keeps long Claude commands expandable without repeating them in full detail", () => { const command = `printf 'first line\nsecond line'\n&& printf done`; const thread = makeThread({ @@ -1080,8 +767,8 @@ describe("buildThreadFeed", () => { expect(group.activities).toHaveLength(1); expect(group.activities[0]).toMatchObject({ - id: "tool-updated", - createdAt: "2026-04-01T00:00:01.000Z", + id: "tool-completed", + createdAt: "2026-04-01T00:00:02.000Z", turnId: "turn-1", summary: "Run tests", detail: "bun run test", @@ -1176,20 +863,6 @@ describe("buildThreadFeed", () => { payload: { title: "Call repository tool", itemType: "mcp_tool_call", - toolSurface: "computer", - toolIcon: { - _tag: "native-app", - app: { _tag: "app-id", appId: "com.example.Editor" }, - }, - toolSource: { - key: "native-app:com.example.editor", - name: "Computer Use", - kind: "computer", - icon: { - _tag: "native-app", - app: { _tag: "app-id", appId: "com.example.Editor" }, - }, - }, detail: "repository.search", status: "completed", data: { @@ -1210,21 +883,7 @@ describe("buildThreadFeed", () => { return; } - expect(group.activities[0]?.icon).toBe("computer"); - expect(group.activities[0]?.workEntry.toolSurface).toBe("computer"); - expect(group.activities[0]?.workEntry.toolIcon).toEqual({ - _tag: "native-app", - app: { _tag: "app-id", appId: "com.example.Editor" }, - }); - expect(group.activities[0]?.workEntry.toolSource).toEqual({ - key: "native-app:com.example.editor", - name: "Computer Use", - kind: "computer", - icon: { - _tag: "native-app", - app: { _tag: "app-id", appId: "com.example.Editor" }, - }, - }); + expect(group.activities[0]?.icon).toBe("wrench"); expect(group.activities[0]?.getFullDetail()).toContain('"query": "work log"'); expect(group.activities[0]?.getFullDetail()).toContain("repository.search"); }); @@ -1272,9 +931,8 @@ describe("buildThreadFeed", () => { title: "Call MCP tool", item: { server: "t3-code", tool: "preview_click" }, status: undefined, - displayName: "Clicking in the preview browser", + displayName: "Click in the preview browser", liveDisplayName: "Clicking in the preview browser", - settledDisplayName: "Clicked in the preview browser", icon: "browser", }, { @@ -1283,14 +941,13 @@ describe("buildThreadFeed", () => { title: "Call MCP tool", item: { server: "t3-code", tool: "task_status" }, status: undefined, - displayName: "Getting delegated task status", + displayName: "Get delegated task status", liveDisplayName: "Getting delegated task status", - settledDisplayName: "Got delegated task status", icon: "t3-code", }, ])( "uses friendly row and running labels from $source", - ({ label, title, item, status, displayName, liveDisplayName, settledDisplayName, icon }) => { + ({ label, title, item, status, displayName, liveDisplayName, icon }) => { const turnId = TurnId.make("turn-friendly-mcp"); const rawCommand = "node mcp-call.js"; const rawDetail = '{"provider":"raw MCP output"}'; @@ -1355,23 +1012,6 @@ describe("buildThreadFeed", () => { live: true, }, ]); - if (settledDisplayName) { - const settledRows = deriveThreadFeedPresentation( - feed, - { - ...thread.latestTurn!, - state: "completed", - completedAt: "2026-04-01T00:00:03.000Z", - }, - new Set([turnId]), - new Set(), - ); - expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({ - summary: settledDisplayName, - summaryToolIcon: icon, - live: false, - }); - } }, ); @@ -1443,7 +1083,7 @@ describe("buildThreadFeed", () => { ).toMatchObject([ { type: "work-toggle", - summary: "Clicking in the preview browser", + summary: "Clicked in the preview browser", summaryToolIcon: "browser", live: true, }, @@ -1454,20 +1094,18 @@ describe("buildThreadFeed", () => { { status: "completed", displayName: "Clicked in the preview browser", - liveDisplayName: "Clicking in the preview browser", detail: "Clicked Continue", hasFailure: false, }, { status: "failed", displayName: "Failed to click in the preview browser", - liveDisplayName: "Failed to click in the preview browser", detail: "Timed out waiting for Continue", hasFailure: true, }, ])( - "uses the browser call label once its action settles as $status", - ({ status, displayName, liveDisplayName, detail, hasFailure }) => { + "keeps a browser group expanded as its action changes from active to $status", + ({ status, displayName, detail, hasFailure }) => { const turnId = TurnId.make("turn-preview-lifecycle"); const toolCallId = "preview-click"; const groupId = `work-group:tool:${turnId}:${toolCallId}`; @@ -1562,7 +1200,7 @@ describe("buildThreadFeed", () => { groupId, hiddenCount: 1, expanded: true, - summary: liveDisplayName, + summary: displayName, summaryToolIcon: "browser", hasFailure, live: true, @@ -1601,7 +1239,7 @@ describe("buildThreadFeed", () => { groupId, hiddenCount: 1, expanded: true, - summary: displayName, + summary: "Used browser 1 time", summaryKind: "browser", hasFailure, live: false, @@ -1783,7 +1421,7 @@ describe("buildThreadFeed", () => { expect(expanded.map((entry) => entry.id)).toEqual([ "assistant-first", "turn-fold:turn-1", - "work-toggle:work-group:tool-completed", + "tool-completed", "assistant-final", ]); @@ -1960,20 +1598,6 @@ describe("buildThreadFeed", () => { assistantMessageId: null, }, activities: [ - makeActivity({ - id: EventId.make("tool-succeeded"), - kind: "tool.completed", - tone: "tool", - summary: "Run command", - createdAt: "2026-04-01T00:00:04.000Z", - turnId, - payload: { - title: "Run command", - itemType: "command_execution", - detail: "done", - status: "completed", - }, - }), makeActivity({ id: EventId.make("tool-failed"), kind: "tool.completed", @@ -1992,35 +1616,25 @@ describe("buildThreadFeed", () => { }); const feed = buildThreadFeed(thread); - expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toMatchObject([ - { - type: "work-toggle", - summary: "Ran 2 commands", - hiddenCount: 2, - hasFailure: true, - }, - ]); + expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); expect(feed[0]).toMatchObject({ type: "activity-group", - activities: [{ status: "success" }, { status: "failure" }], + activities: [{ status: "failure" }], }); - const expanded = deriveThreadFeedPresentation( - feed, - thread.latestTurn, - new Set(), - new Set(["work-group:tool-succeeded"]), - ); - expect(expanded.map((entry) => entry.id)).toEqual([ - "work-toggle:work-group:tool-succeeded", - "work-details:work-group:tool-succeeded", + }); + + it("appends active work as a normal timeline row", () => { + const startedAt = "2026-04-01T00:00:01.000Z"; + const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); + + expect(presented).toEqual([ + { + type: "working", + id: "working-indicator-row", + createdAt: startedAt, + }, ]); - expect(expanded[1]).toMatchObject({ - type: "activity-group", - activities: [ - { id: "tool-succeeded", status: "success", groupedToolDetail: true }, - { id: "tool-failed", status: "failure", groupedToolDetail: true }, - ], - }); + expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]); }); it("keeps expanded work in one group with stable row identities", () => { @@ -2028,8 +1642,6 @@ describe("buildThreadFeed", () => { id: string, createdAt: string, status: ThreadFeedActivity["status"] = "success", - toolSurface?: "browser" | "computer", - toolIcon?: import("@t3tools/contracts").ToolActivityIcon, ): ThreadFeedActivity => ({ id, createdAt, @@ -2042,16 +1654,6 @@ describe("buildThreadFeed", () => { icon: "command", toolLike: true, status, - workEntry: { - id, - createdAt, - turnId: null, - label: `Tool ${id}`, - command: `command ${id}`, - tone: "tool", - ...(toolSurface ? { toolSurface } : {}), - ...(toolIcon ? { toolIcon } : {}), - }, }); const feed: ThreadFeedEntry[] = [ { @@ -2062,41 +1664,29 @@ describe("buildThreadFeed", () => { activities: [ activity("activity-1", "2026-04-01T00:00:01.000Z"), activity("activity-neutral", "2026-04-01T00:00:02.000Z", "neutral"), - activity("activity-2", "2026-04-01T00:00:03.000Z", "success", "browser"), - activity("activity-3", "2026-04-01T00:00:04.000Z", "success", "computer", { - _tag: "native-app", - app: { _tag: "app-id", appId: "com.example.Editor" }, - }), + activity("activity-2", "2026-04-01T00:00:03.000Z"), + activity("activity-3", "2026-04-01T00:00:04.000Z"), ], }, ]; const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); - expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-1"]); - expect(collapsed[0]).toMatchObject({ + expect(collapsed.map((entry) => entry.id)).toEqual(["activity-3", "work-toggle:work-group-1"]); + expect(collapsed[1]).toMatchObject({ type: "work-toggle", - groupId: "work-group:activity-1", - hiddenCount: 3, + groupId: "work-group-1", + hiddenCount: 2, expanded: false, - summary: "Ran 3 commands", - toolSurface: "computer", - toolIcon: { - _tag: "native-app", - app: { _tag: "app-id", appId: "com.example.Editor" }, - }, }); - const expanded = deriveThreadFeedPresentation( - feed, - null, - new Set(), - new Set(["work-group:activity-1"]), - ); + const expanded = deriveThreadFeedPresentation(feed, null, new Set(), new Set(["work-group-1"])); expect(expanded.map((entry) => entry.id)).toEqual([ - "work-toggle:work-group:activity-1", - "work-details:work-group:activity-1", + "activity-1", + "activity-2", + "activity-3", + "work-toggle:work-group-1", ]); - expect(expanded[0]).toMatchObject({ + expect(expanded.at(-1)).toMatchObject({ type: "work-toggle", expanded: true, }); @@ -2118,504 +1708,9 @@ describe("buildThreadFeed", () => { expect(unchanged[1]).toBe(expanded[1]); expect(deriveThreadFeedPresentation(feed, null, new Set())).toEqual(collapsed); }); - - it.each( - [ - "sudo -u root pnpm test", - "/bin/zsh -lc 'sudo -u root pnpm test'", - "/bin/bash -lc 'sudo -u root pnpm test'", - ].flatMap((command) => - ( - [ - { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, - { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false }, - { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, - { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, - { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, - ] as const - ).map((state) => ({ command, ...state })), - ), - )( - "keeps the command summary in sync with $lifecycleStatus: $command", - ({ command, lifecycleStatus, summary, shimmer }) => { - const turnId = TurnId.make("turn-live-tools"); - const activity = ( - id: string, - status: ThreadFeedActivity["status"], - lifecycleStatus: ThreadFeedActivity["lifecycleStatus"], - tone: "tool" | "error" = "tool", - command?: string, - ): ThreadFeedActivity => ({ - id, - createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, - turnId, - summary: `Tool ${id}`, - detail: lifecycleStatus === "stopped" ? "Exit code 130" : null, - canExpand: false, - getFullDetail: () => null, - getCopyText: () => id, - icon: "command", - toolLike: true, - status, - lifecycleStatus, - workEntry: { - id, - createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, - turnId, - label: `Tool ${id}`, - tone, - toolLifecycleStatus: lifecycleStatus, - ...(lifecycleStatus === "stopped" ? { detail: "Exit code 130" } : {}), - ...(command ? { command, itemType: "command_execution" as const } : {}), - }, - }); - const feed: ThreadFeedEntry[] = [ - { - type: "activity-group", - id: "activity-1", - createdAt: "2026-04-01T00:00:01.000Z", - turnId, - activities: [ - activity("activity-1", "success", "completed"), - activity("activity-2", "failure", "failed", "error"), - activity( - "activity-3", - lifecycleStatus === "inProgress" - ? "neutral" - : lifecycleStatus === "completed" - ? "success" - : "failure", - lifecycleStatus, - "tool", - command, - ), - ...(lifecycleStatus === "inProgress" - ? [activity("activity-4", "success", "completed", "tool", "printf done")] - : []), - ], - }, - ]; - const latestTurn = { - turnId, - state: "running" as const, - requestedAt: "2026-04-01T00:00:00.000Z", - startedAt: "2026-04-01T00:00:00.000Z", - completedAt: null, - assistantMessageId: null, - }; - - const rows = deriveThreadFeedPresentation( - feed, - latestTurn, - new Set(), - new Set(), - latestTurn.startedAt, - ); - expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ - ["work-toggle:work-group:activity-1", "work-toggle"], - ["activity-2", "activity-group"], - ["work-live:work-group:activity-3", "work-toggle"], - ]); - expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ - false, - false, - true, - ]); - expect(rows[2]).toMatchObject({ - summary, - summaryKind: "command", - live: true, - shimmer, - }); - expect(rows[0]).toMatchObject({ live: false, shimmer: false }); - - const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); - expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ - { live: false, shimmer: false }, - { - live: false, - shimmer: false, - summary: lifecycleStatus === "inProgress" ? "printf done" : command, - }, - ]); - - const completedRows = deriveThreadFeedPresentation( - feed, - { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, - new Set([turnId]), - new Set(), - latestTurn.startedAt, - ); - expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ - { live: false, shimmer: false }, - { live: false, shimmer: false }, - ]); - }, - ); - - it("preserves serialized shell wrappers with non-matching boundary quotes", () => { - const turnId = TurnId.make("turn-serialized-shell-wrapper"); - const command = - "/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\""; - const thread = makeThread({ - id: ThreadId.make("thread-serialized-shell-wrapper"), - projectId: ProjectId.make("project-1"), - title: "Serialized shell wrapper", - latestTurn: { - turnId, - state: "running", - requestedAt: "2026-04-01T00:00:00.000Z", - startedAt: "2026-04-01T00:00:00.000Z", - completedAt: null, - assistantMessageId: null, - }, - activities: [ - makeActivity({ - id: EventId.make("serialized-shell-wrapper"), - kind: "tool.updated", - tone: "tool", - summary: "Ran command", - createdAt: "2026-04-01T00:00:01.000Z", - turnId, - payload: { - itemType: "command_execution", - status: "inProgress", - data: { item: { command } }, - }, - }), - ], - }); - - const feed = buildThreadFeed(thread); - expect(feed[0]).toMatchObject({ - type: "activity-group", - activities: [{ workEntry: { command } }], - }); - if (feed[0]?.type === "activity-group") { - expect(feed[0].activities[0]?.workEntry.rawCommand).toBeUndefined(); - } - }); - - it.each([ - ["inProgress", true], - ["completed", false], - ["failed", false], - ["declined", false], - ["stopped", false], - ] as const)("respects the %s lifecycle of trailing task progress", (status, shimmer) => { - const turnId = TurnId.make("turn-task-progress"); - const thread = makeThread({ - id: ThreadId.make("thread-task-progress"), - projectId: ProjectId.make("project-1"), - title: "Task lifecycle", - latestTurn: { - turnId, - state: "running", - requestedAt: "2026-04-01T00:00:00.000Z", - startedAt: "2026-04-01T00:00:01.000Z", - completedAt: null, - assistantMessageId: null, - }, - activities: [ - makeActivity({ - id: EventId.make("task-progress"), - kind: "task.progress", - summary: "Task progress", - createdAt: "2026-04-01T00:00:02.000Z", - turnId, - payload: { taskId: "task-1", status }, - }), - ], - }); - - const rows = deriveThreadFeedPresentation( - buildThreadFeed(thread), - thread.latestTurn, - new Set(), - new Set(), - thread.latestTurn!.startedAt, - ); - expect(rows.some((entry) => entry.type === "work-toggle" && entry.shimmer)).toBe(shimmer); - }); - - it("does not revive cached in-progress tools after work stops", () => { - const turnId = TurnId.make("turn-stale-tool"); - const feed: ThreadFeedEntry[] = [ - { - type: "activity-group", - id: "stale-tool", - createdAt: "2026-04-01T00:00:01.000Z", - turnId, - activities: [ - { - id: "stale-tool", - createdAt: "2026-04-01T00:00:01.000Z", - turnId, - summary: "Running tests", - detail: null, - canExpand: false, - getFullDetail: () => null, - getCopyText: () => "", - icon: "command", - toolLike: true, - status: "neutral", - lifecycleStatus: "inProgress", - workEntry: { - id: "stale-tool", - createdAt: "2026-04-01T00:00:01.000Z", - turnId, - label: "Running tests", - tone: "tool", - toolLifecycleStatus: "inProgress", - }, - }, - ], - }, - ]; - const latestTurn = { - turnId, - state: "running" as const, - requestedAt: "2026-04-01T00:00:00.000Z", - startedAt: "2026-04-01T00:00:00.000Z", - completedAt: null, - assistantMessageId: null, - }; - - expect(deriveThreadFeedPresentation(feed, latestTurn, new Set())).toEqual([]); - expect( - deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), latestTurn.startedAt), - ).toMatchObject([{ type: "work-toggle", live: true, shimmer: true }]); - }); - - it("collapses interleaved tool lifecycles by call identity", () => { - const turnId = TurnId.make("turn-parallel-tools"); - const toolActivity = ( - id: string, - toolCallId: string, - kind: "tool.updated" | "tool.completed", - status: "inProgress" | "completed", - detail: string, - nestedId = false, - ) => - makeActivity({ - id: EventId.make(id), - kind, - tone: "tool", - summary: `Run ${toolCallId} command`, - createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, - turnId, - payload: { - ...(nestedId ? { data: { toolCallId } } : { toolCallId }), - itemType: "command_execution", - status, - detail, - }, - }); - const thread = makeThread({ - id: ThreadId.make("thread-parallel-tools"), - projectId: ProjectId.make("project-1"), - title: "Parallel tools", - activities: [ - toolActivity("call-a-1", "call-a", "tool.updated", "inProgress", "starting"), - toolActivity("call-b-2", "call-b", "tool.updated", "inProgress", "starting", true), - toolActivity("call-a-3", "call-a", "tool.completed", "completed", "first output"), - toolActivity("call-b-4", "call-b", "tool.completed", "completed", "second output", true), - ], - }); - - const feed = buildThreadFeed(thread); - const activityGroup = feed.find((entry) => entry.type === "activity-group"); - expect(activityGroup).toMatchObject({ - type: "activity-group", - activities: [ - { id: "call-a-1", lifecycleStatus: "completed", detail: "first output" }, - { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" }, - ], - }); - expect( - deriveThreadFeedPresentation(feed, null, new Set([turnId])).find( - (entry) => entry.type === "work-toggle", - ), - ).toMatchObject({ - type: "work-toggle", - hiddenCount: 2, - summary: "Ran 2 commands", - live: false, - }); - - const groupId = `work-group:tool:${turnId}:call-a`; - const startedAt = "2026-04-01T00:00:00.000Z"; - const runningRows = deriveThreadFeedPresentation( - buildThreadFeed({ ...thread, activities: thread.activities.slice(0, 2) }), - { turnId, state: "running", startedAt, completedAt: null }, - new Set(), - new Set([groupId]), - startedAt, - ); - expect(runningRows.find((entry) => entry.type === "activity-group")).toMatchObject({ - id: `work-details:${groupId}`, - activities: [ - { id: "call-a-1", lifecycleStatus: "inProgress", groupedToolDetail: true, live: false }, - { id: "call-b-2", lifecycleStatus: "inProgress", groupedToolDetail: true, live: true }, - ], - }); - - const completedRows = deriveThreadFeedPresentation( - feed, - null, - new Set([turnId]), - new Set([groupId]), - ); - expect(completedRows.find((entry) => entry.type === "activity-group")).toMatchObject({ - id: `work-details:${groupId}`, - activities: [ - { id: "call-a-1", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, - { id: "call-b-2", lifecycleStatus: "completed", groupedToolDetail: true, live: false }, - ], - }); - - const correctedFeed = buildThreadFeed({ - ...thread, - activities: thread.activities.map((activity) => - activity.id === "call-a-3" - ? { - ...activity, - tone: "error", - payload: { - toolCallId: "call-a", - itemType: "command_execution", - status: "failed", - detail: "Corrected failure output", - }, - } - : activity, - ), - }); - const correctedGroup = correctedFeed.find((entry) => entry.type === "activity-group"); - expect(correctedGroup).toMatchObject({ - activities: [ - { id: "call-a-1", lifecycleStatus: "failed", detail: "Corrected failure output" }, - { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" }, - ], - }); - expect(correctedGroup?.activities[0]?.getCopyText()).toContain("Corrected failure output"); - expect(activityGroup?.activities[0]?.getCopyText()).toContain("first output"); - const correctedRows = deriveThreadFeedPresentation( - correctedFeed, - null, - new Set([turnId]), - new Set([groupId]), - ); - expect(correctedRows.find((entry) => entry.type === "activity-group")).toMatchObject({ - id: "call-a-1", - activities: [{ status: "failure", workEntry: { tone: "error" } }], - }); - }); }); describe("quiet timeline: nested agents", () => { - it.each(["task.updated", "task.progress"] as const)( - "does not mark an ordinary task complete when it resumes through %s", - (resumeKind) => { - const thread = makeThread({ - id: ThreadId.make("resumed-agent"), - projectId: ProjectId.make("project-1"), - title: "Resumed agent", - activities: ( - [ - ["task.progress", "running", "Review"], - ["task.updated", "idle", "Task idle"], - [resumeKind, "running", "Review resumed"], - ] as const - ).map(([kind, status, summary], index) => - makeActivity({ - id: EventId.make(`resumed-${index}`), - kind, - summary, - createdAt: `2026-04-01T00:00:0${index + 1}.000Z`, - payload: { - taskId: "agent-1", - agentKind: "agent", - title: "Reviewer", - status, - detail: summary, - }, - }), - ), - }); - const rows = buildThreadFeed(thread).flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - ); - expect(rows).toMatchObject([ - { - lifecycleStatus: "inProgress", - summary: "Reviewer", - workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" }, - }, - ]); - }, - ); - - it.each(["cancelled", "failed", "interrupted", "idle"] as const)( - "replaces Antigravity batch progress with %s", - (status) => { - const detail = - status === "idle" - ? "Turn ended. Individual agent status is unavailable." - : "Antigravity process stopped."; - const thread = makeThread({ - id: ThreadId.make("antigravity-agents"), - projectId: ProjectId.make("project-1"), - title: "Antigravity subagents", - activities: [ - ...["trajectory:4", "trajectory:5"].map((taskId, index) => - makeActivity({ - id: EventId.make(`progress-${index}`), - kind: "task.progress", - summary: "Antigravity subagent batch", - createdAt: `2026-04-01T00:00:0${index + 1}.000Z`, - payload: { - taskId, - taskType: "subagent_batch", - agentKind: "agent", - title: "Antigravity subagent batch", - detail: "Antigravity subagent batch", - status: "running", - }, - }), - ), - makeActivity({ - id: EventId.make("agent-stopped"), - kind: "task.updated", - summary: `Task ${status}`, - createdAt: "2026-04-01T00:00:03.000Z", - payload: { - taskId: "trajectory:4", - taskType: "subagent_batch", - agentKind: "agent", - title: "Antigravity subagent batch", - status, - ...(status === "idle" ? { detail, timelineBypass: true } : { error: detail }), - }, - }), - ], - }); - const rows = buildThreadFeed(thread).flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - ); - expect(rows).toHaveLength(2); - expect(rows[0]).toMatchObject({ - lifecycleStatus: status === "failed" ? "failed" : "stopped", - detail, - workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" }, - }); - expect(rows[1]).toMatchObject({ - lifecycleStatus: "inProgress", - workEntry: { taskId: "trajectory:5" }, - }); - }, - ); - it("keeps a nested agent's terminal row but hides its background work", () => { const thread = makeThread({ id: ThreadId.make("thread-nested"), @@ -2648,8 +1743,5 @@ describe("quiet timeline: nested agents", () => { ); expect(ids).toContain("nested-done"); expect(ids).not.toContain("shell-done"); - expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([ - { type: "activity-group", id: "nested-done" }, - ]); }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ef75fce56af4..eabf55ded5ac 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -13,21 +13,6 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import { - commandDetailRepeatsCommand, - extractCommandOutputText, - isWorktreeSetupActivity, - liveActivityToolStatus, - normalizeCompactToolLabel, - omitSupersededLifecycleMarkers, - resolveWorkEntryToolPresentation, - summarizeToolGroup, - toolGroupAction, - toolGroupSummaryKind, - type ToolGroupSummaryKind, -} from "@t3tools/client-runtime/work-log/presentation"; -import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; -import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -52,7 +37,7 @@ export interface PendingUserInput { } export interface PendingUserInputDraftAnswer { - readonly selectedOptionValues?: ReadonlyArray; + readonly selectedOptionLabels?: ReadonlyArray; readonly customAnswer?: string; } @@ -68,10 +53,8 @@ export interface ThreadFeedActivity { readonly icon: | "agent" | "alert" - | "browser" | "check" | "command" - | "computer" | "edit" | "eye" | "globe" @@ -82,15 +65,13 @@ export interface ThreadFeedActivity { | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; - readonly lifecycleStatus?: WorkLogToolLifecycleStatus; - readonly workEntry: WorkLogEntry; - readonly groupedToolDetail?: boolean; - readonly live?: boolean; } +const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; + type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; -export interface WorkLogEntry { +interface WorkLogEntry { id: string; createdAt: string; turnId: TurnId | null; @@ -102,20 +83,14 @@ export interface WorkLogEntry { changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; - toolSurface?: import("@t3tools/contracts").ToolActivitySurface; - toolIcon?: import("@t3tools/contracts").ToolActivityIcon; - toolSource?: import("@t3tools/contracts").ToolActivitySource; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; - sourceActivityKind?: OrchestrationThreadActivity["kind"]; - toolCallId?: string; - agentSpawn?: boolean; toolData?: unknown; } interface DerivedWorkLogEntry extends WorkLogEntry { - sourceActivityKind: OrchestrationThreadActivity["kind"]; + activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; @@ -138,6 +113,11 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | Extract + | { + readonly type: "working"; + readonly id: string; + readonly createdAt: string; + } | { readonly type: "activity-group"; readonly id: string; @@ -153,14 +133,7 @@ export type ThreadFeedEntry = readonly groupId: string; readonly hiddenCount: number; readonly expanded: boolean; - readonly summary: string; - readonly summaryKind: ToolGroupSummaryKind; - readonly toolSurface?: WorkLogEntry["toolSurface"]; - readonly toolIcon?: WorkLogEntry["toolIcon"]; - readonly summaryToolIcon?: "browser" | "t3-code"; - readonly hasFailure: boolean; - readonly live: boolean; - readonly shimmer: boolean; + readonly onlyToolActivities: boolean; } | { readonly type: "turn-fold"; @@ -176,41 +149,6 @@ export type ThreadFeedLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; -type ThreadFeedActivityGroup = Extract; - -// These keys are immutable inputs. Weak caches release old histories with their source data. -const activityEntriesCache = new WeakMap< - ReadonlyArray, - ReadonlyArray> ->(); -const messageEntriesCache = new WeakMap< - OrchestrationThread["messages"][number], - Extract ->(); -const activityGroupsCache = new WeakMap(); -const presentedActivityGroupsCache = new WeakMap< - ThreadFeedActivityGroup, - { - readonly unsettledTurnId: TurnId | null; - readonly isWorking: boolean; - readonly activeTail: boolean; - readonly rows: ReadonlyArray; - } ->(); -const turnFoldRowsCache = new WeakMap< - ThreadFeedEntry, - Extract ->(); - -export function isContextCompactionActivityGroup( - entry: Extract, -): boolean { - return ( - entry.activities.length === 1 && - entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction" - ); -} - function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { switch (requestType) { case "command_execution_approval": @@ -276,11 +214,10 @@ function parseUserInputQuestions( return { label: record.label, description: record.description, - ...(typeof record.value === "string" ? { value: record.value } : {}), }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0 && question.allowCustomAnswer === false) { + if (options.length === 0) { return null; } return { @@ -289,9 +226,6 @@ function parseUserInputQuestions( question: question.question, options, multiSelect: question.multiSelect === true, - ...(typeof question.allowCustomAnswer === "boolean" - ? { allowCustomAnswer: question.allowCustomAnswer } - : {}), }; }) .filter((question): question is UserInputQuestion => question !== null); @@ -307,23 +241,7 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function resolvePendingUserInputOptionValue( - question: UserInputQuestion, - value: string, -): string | null { - if (question.options.some((option) => option.value === value)) { - return value; - } - - const label = value.trim(); - return label.length > 0 && - question.options.some((option) => option.value === undefined && option.label.trim() === label) - ? label - : null; -} - -function normalizeSelectedOptionValues( - question: UserInputQuestion, +function normalizeSelectedOptionLabels( value: ReadonlyArray | undefined, ): ReadonlyArray { if (!Array.isArray(value)) { @@ -331,11 +249,7 @@ function normalizeSelectedOptionValues( } return Array.from( - new Set( - value - .map((entry) => resolvePendingUserInputOptionValue(question, entry)) - .filter((entry): entry is string => entry !== null), - ), + new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), ); } @@ -343,28 +257,29 @@ function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, ): string | ReadonlyArray | null { - const customAnswer = - question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); + const customAnswer = normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } - const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); if (question.multiSelect) { - return selectedOptionValues.length > 0 ? selectedOptionValues : null; + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; } - return selectedOptionValues[0] ?? null; + return selectedOptionLabels[0] ?? null; } -/** Some providers settle agents through task.updated instead of task.completed. */ +/** Codex children settle via task.updated (idle/failed/interrupted), never + * task.completed — these rows are mobile's only terminal signal for them. */ const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet = new Set([ + "idle", "completed", "failed", "cancelled", "interrupted", ]); -function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { +function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "task.updated") { return false; } @@ -373,9 +288,9 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { ? (activity.payload as Record) : null; return ( - typeof payload?.status === "string" && - (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) || - (payload.timelineBypass === true && payload.status === "idle")) + payload?.timelineBypass === true && + typeof payload.status === "string" && + MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) ); } @@ -384,7 +299,8 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { * activity lives in the Agents sheet, not the work log. Terminal rows are * kept — with no Agents surface on mobile they are the terminal signal * (a surface that hides rows must keep its own terminal signal). That means - * task.completed and terminal task.updated, including Antigravity cancellation. + * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose + * children never emit task.completed — review finding). */ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { const payload = @@ -394,7 +310,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean if (!payload) { return false; } - const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity); + const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity); if (payload.timelineBypass === true && !isTerminalTaskRow) { return true; } @@ -419,7 +335,8 @@ function deriveWorkLogEntries( if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; - if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; + // Terminal bypassed updates pass: Codex children's only terminal signal. + if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; @@ -462,8 +379,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); - const toolPresentation = extractToolActivityPresentation(payload); - // Terminal task updates carry identity so they replace each child's progress row. + // task.updated included: terminal bypassed updates (Codex children's only + // terminal signal) must carry task identity so they collapse per child + // instead of stacking anonymous "Task idle" rows. const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed" || @@ -497,16 +415,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : activity.tone === "approval" ? "info" : activity.tone, - sourceActivityKind: activity.kind, + activityKind: activity.kind, }; - const toolCallId = - asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId); - if (toolCallId) { - entry.toolCallId = toolCallId; - } - if (isTaskActivity && payload?.agentKind === "agent") { - entry.agentSpawn = true; - } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); @@ -528,9 +438,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }); if (detail && detail !== title && !repeatsCommand) entry.detail = detail; } - if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) { - entry.detail = payload.error; - } if (viewedImagePath) { entry.viewedImagePath = viewedImagePath; } @@ -546,15 +453,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } - if (toolPresentation.toolSurface) { - entry.toolSurface = toolPresentation.toolSurface; - } - if (toolPresentation.toolIcon) { - entry.toolIcon = toolPresentation.toolIcon; - } - if (toolPresentation.toolSource) { - entry.toolSource = toolPresentation.toolSource; - } if (itemType === "mcp_tool_call") { const data = asRecord(payload?.data); const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; @@ -589,13 +487,12 @@ function collapseDerivedWorkLogEntries( // Subagent rows collapse by identity, not adjacency (quiet-timeline // guarantee; mirrors web's session-logic). const taskRowIndex = new Map(); - const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && - (entry.sourceActivityKind === "task.progress" || - entry.sourceActivityKind === "task.completed" || - entry.sourceActivityKind === "task.updated"); + (entry.activityKind === "task.progress" || + entry.activityKind === "task.completed" || + entry.activityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { const existingIndex = taskRowIndex.get(entry.taskId); if (existingIndex !== undefined) { @@ -606,78 +503,30 @@ function collapseDerivedWorkLogEntries( collapsed.push(entry); continue; } - const lifecycleKey = toolLifecycleCollapseMapKey(entry); - if (lifecycleKey !== undefined) { - const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey); - const matchingEntry = matchingIndex === undefined ? undefined : collapsed[matchingIndex]; - if ( - matchingIndex !== undefined && - matchingEntry && - shouldCollapseToolLifecycleEntries(matchingEntry, entry) - ) { - collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry); - continue; - } - toolLifecycleRowIndex.delete(lifecycleKey); - } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - const previousIndex = collapsed.length - 1; - const previousKey = toolLifecycleCollapseMapKey(previous); - if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); - const merged = mergeDerivedWorkLogEntries(previous, entry); - collapsed[previousIndex] = merged; - const mergedKey = toolLifecycleCollapseMapKey(merged); - if (mergedKey !== undefined) toolLifecycleRowIndex.set(mergedKey, previousIndex); + collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); continue; } collapsed.push(entry); - if (lifecycleKey !== undefined) { - toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); - } } return collapsed; } -function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { - if ( - entry.sourceActivityKind !== "tool.updated" && - entry.sourceActivityKind !== "tool.completed" - ) { - return undefined; - } - return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined; -} - function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if ( - previous.sourceActivityKind !== "tool.updated" && - previous.sourceActivityKind !== "tool.completed" - ) { - return false; - } - if (next.sourceActivityKind !== "tool.updated" && next.sourceActivityKind !== "tool.completed") { + if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { return false; } - if (previous.turnId !== next.turnId) { + if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { return false; } - if (previous.sourceActivityKind === "tool.completed") { + if (previous.activityKind === "tool.completed") { return false; } - if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { - return true; - } - return ( - previous.toolCallId !== undefined && - next.toolCallId === undefined && - previous.itemType === next.itemType && - normalizeCompactToolLabel(previous.toolTitle ?? previous.label) === - normalizeCompactToolLabel(next.toolTitle ?? next.label) - ); + return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; } function mergeDerivedWorkLogEntries( @@ -690,34 +539,24 @@ function mergeDerivedWorkLogEntries( const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; - const toolSurface = next.toolSurface ?? previous.toolSurface; - const toolIcon = next.toolIcon ?? previous.toolIcon; - const toolSource = next.toolSource ?? previous.toolSource; const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; - const toolCallId = next.toolCallId ?? previous.toolCallId; const toolData = next.toolData ?? previous.toolData; return { ...previous, ...next, - id: previous.id, - createdAt: previous.createdAt, ...(detail ? { detail } : {}), ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), ...(toolTitle ? { toolTitle } : {}), - ...(toolSurface ? { toolSurface } : {}), - ...(toolIcon ? { toolIcon } : {}), - ...(toolSource ? { toolSource } : {}), ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), - ...(toolCallId ? { toolCallId } : {}), ...(toolData !== undefined ? { toolData } : {}), }; } @@ -734,15 +573,9 @@ function mergeChangedFiles( } function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { - if ( - entry.sourceActivityKind !== "tool.updated" && - entry.sourceActivityKind !== "tool.completed" - ) { + if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } - if (entry.toolCallId) { - return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; - } const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); const detail = entry.detail?.trim() ?? ""; const itemType = entry.itemType ?? ""; @@ -752,6 +585,10 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un return [itemType, normalizedLabel, detail].join("\u001f"); } +function normalizeCompactToolLabel(value: string): string { + return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); +} + function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { return true; @@ -828,13 +665,12 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if ( - entry.sourceActivityKind === "user-input.requested" || - entry.sourceActivityKind === "user-input.resolved" + entry.activityKind === "user-input.requested" || + entry.activityKind === "user-input.resolved" ) { return "message"; } - if (entry.sourceActivityKind === "runtime.warning") return "warning"; - if (entry.toolSurface) return entry.toolSurface; + if (entry.activityKind === "runtime.warning") return "warning"; if (entry.requestKind === "command") return "command"; if (entry.requestKind === "file-read") return "eye"; if (entry.requestKind === "file-change") return "edit"; @@ -922,13 +758,6 @@ function workEntryHeading(workEntry: WorkLogEntry): string { return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } -function singleToolCallLabel(activity: ThreadFeedActivity): string { - const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed"); - if (presentation) return presentation.displayName; - const command = activity.workEntry.command?.trim(); - return command || activity.summary; -} - function asRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } @@ -1137,11 +966,6 @@ function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { const status = payload?.status; - // The parent turn ended, so batch tracking is inactive. The detail explains - // that child status is unavailable; do not retain the earlier running marker. - if (status === "idle" && payload?.taskType === "subagent_batch") return "stopped"; - if (status === "pending" || status === "running" || status === "waiting") return "inProgress"; - if (status === "cancelled" || status === "interrupted") return "stopped"; if ( status === "inProgress" || status === "completed" || @@ -1330,14 +1154,9 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th continue; } - const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction"; - if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) { - flushGroup(); - } - firstActivityEntry ??= entry; - openGroupActivities.push(entry.activity); - if (isCompaction) { - flushGroup(); + if (openGroupActivities !== null && openGroupTurnId === entry.turnId) { + openGroupActivities.push(entry.activity); + continue; } } flushGroup(); @@ -1510,14 +1329,10 @@ export function deriveThreadFeedPresentation( activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", - ); - const activeTailGroup = sourceFeed.findLast( - (entry) => entry.type !== "message" || !isEmptyMessage(entry), + (entry) => + entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", ); const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); - const unsettledTurnId = deriveUnsettledTurnId(latestTurn); - const isWorking = activeWorkStartedAt !== null; const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedTurnIds.has(fold.turnId)) { @@ -1529,13 +1344,6 @@ export function deriveThreadFeedPresentation( const result: ThreadFeedEntry[] = []; for (const entry of sourceFeed) { - const isActiveTailGroup = - isWorking && - unsettledTurnId !== null && - entry.type === "activity-group" && - activeTailGroup?.type === "activity-group" && - activeTailGroup.id === entry.id && - entry.turnId === unsettledTurnId; const fold = foldsByAnchorId.get(entry.id); if (fold) { const expanded = expandedTurnIds.has(fold.turnId); @@ -1560,104 +1368,49 @@ export function deriveThreadFeedPresentation( result.push(row); } if (!collapsedEntryIds.has(entry.id)) { - appendPresentedFeedEntry( - result, - entry, - expandedWorkGroupIds, - unsettledTurnId, - isWorking, - isActiveTailGroup, - ); + appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); } } + if (activeWorkStartedAt !== null) { + result.push({ + type: "working", + id: "working-indicator-row", + createdAt: activeWorkStartedAt, + }); + } return result; } function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, - unsettledTurnId: TurnId | null, - isWorking: boolean, - activeTail: boolean, ): void { if (entry.type !== "activity-group") { result.push(entry); return; } - if (isContextCompactionActivityGroup(entry)) { - result.push(entry); - return; - } - let cached = presentedActivityGroupsCache.get(entry); - if ( - !cached || - cached.unsettledTurnId !== unsettledTurnId || - cached.isWorking !== isWorking || - cached.activeTail !== activeTail || - cached.rows.some( - (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, - ) - ) { - const rows: ThreadFeedEntry[] = []; - appendActivityGroupRows( - rows, - entry, - expandedWorkGroupIds, - unsettledTurnId, - isWorking, - activeTail, - ); - cached = { unsettledTurnId, isWorking, activeTail, rows }; - presentedActivityGroupsCache.set(entry, cached); - } - for (const row of cached.rows) { - result.push(row); - } -} - -function appendActivityGroupRows( - result: ThreadFeedEntry[], - entry: ThreadFeedActivityGroup, - expandedWorkGroupIds: ReadonlySet, - unsettledTurnId: TurnId | null, - isWorking: boolean, - activeTail: boolean, -): void { - const activities = omitSupersededLifecycleMarkers( - entry.activities.filter( - (activity) => - !(activity.toolLike && activity.status === "neutral") || - (isWorking && - activity.lifecycleStatus === "inProgress" && - activity.turnId === unsettledTurnId), - ), - (activity) => activity.workEntry, + const activities = entry.activities.filter( + (activity) => !(activity.toolLike && activity.status === "neutral"), ); if (activities.length === 0) { return; } - let groupableRun: ThreadFeedActivity[] = []; - const flushGroupableRun = (isTrailingRun: boolean) => { - if (groupableRun.length === 0) return; - appendToolGroupRows( - result, - entry, - groupableRun, - expandedWorkGroupIds, - unsettledTurnId, - isWorking, - activeTail && isTrailingRun, - ); - groupableRun = []; - }; - for (const activity of activities) { - if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) { - groupableRun.push(activity); - continue; - } - flushGroupableRun(false); + if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { + result.push({ + ...entry, + activities, + }); + return; + } + + const groupId = entry.id; + const expanded = expandedWorkGroupIds.has(groupId); + const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES); + + for (const activity of visibleActivities) { result.push({ type: "activity-group", id: activity.id, @@ -1666,138 +1419,18 @@ function appendActivityGroupRows( activities: [activity], }); } - flushGroupableRun(true); -} - -function appendToolGroupRows( - result: ThreadFeedEntry[], - sourceGroup: Extract, - activities: ReadonlyArray, - expandedWorkGroupIds: ReadonlySet, - unsettledTurnId: TurnId | null, - isWorking: boolean, - activeTail: boolean, -): void { - const firstEntry = activities[0]!.workEntry; - const identity = firstEntry.toolCallId - ? `tool:${firstEntry.turnId ?? "no-turn"}:${firstEntry.toolCallId}` - : activities[0]!.id; - const groupId = `work-group:${identity}`; - const expanded = expandedWorkGroupIds.has(groupId); - const latestActiveActivity = activities.findLast( - (activity) => - isWorking && - activity.turnId === unsettledTurnId && - (activity.lifecycleStatus === "inProgress" || - (activeTail && - activity.lifecycleStatus === undefined && - (activity.workEntry.sourceActivityKind === "task.progress" || activity.toolLike))), - ); - const active = latestActiveActivity !== undefined; - const live = activeTail || active; - const latestActivity = latestActiveActivity ?? activities.at(-1)!; - const singleActivity = activities.length === 1 ? latestActivity : null; - const summary = live - ? liveToolActivitySummary(latestActivity, live) - : singleActivity !== null && - singleActivity.toolLike && - toolGroupAction(singleActivity.workEntry) !== "edit" - ? singleToolCallLabel(singleActivity) - : singleActivity !== null && !singleActivity.toolLike - ? singleActivity.workEntry.label - : summarizeToolGroup(activities.map((activity) => activity.workEntry)); - const primarySourceActivity = activities.find( - (activity) => activity.workEntry.toolSource !== undefined, - ); - const primarySourceKey = primarySourceActivity?.workEntry.toolSource?.key; - const primarySourceIcon = primarySourceKey - ? (activities.find( - (activity) => - activity.workEntry.toolSource?.key === primarySourceKey && - activity.workEntry.toolIcon !== undefined, - )?.workEntry.toolIcon ?? primarySourceActivity?.workEntry.toolSource?.icon) - : undefined; - const groupToolSurface = - primarySourceActivity?.workEntry.toolSurface ?? - latestActivity.workEntry.toolSurface ?? - activities.findLast((activity) => activity.workEntry.toolSurface !== undefined)?.workEntry - .toolSurface; - const groupToolIcon = - primarySourceIcon ?? - latestActivity.workEntry.toolIcon ?? - activities.findLast((activity) => activity.workEntry.toolIcon !== undefined)?.workEntry - .toolIcon; - const summaryToolIcon = live - ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon - : singleActivity !== null && - singleActivity.toolLike && - toolGroupAction(singleActivity.workEntry) !== "edit" - ? resolveWorkEntryToolPresentation(singleActivity.workEntry, "completed")?.icon - : undefined; result.push({ type: "work-toggle", - id: `${live ? "work-live" : "work-toggle"}:${groupId}`, - createdAt: sourceGroup.createdAt, - turnId: sourceGroup.turnId, + id: `work-toggle:${groupId}`, + createdAt: entry.createdAt, + turnId: entry.turnId, groupId, - hiddenCount: activities.length, + hiddenCount, expanded, - summary, - summaryKind: toolGroupSummaryKind( - (live ? [latestActivity] : activities).map((activity) => activity.workEntry), - ), - ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), - ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), - ...(summaryToolIcon ? { summaryToolIcon } : {}), - hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", - live, - shimmer: active, - }); - if (!expanded) { - return; - } - result.push({ - type: "activity-group", - id: `work-details:${groupId}`, - createdAt: activities[0]!.createdAt, - turnId: activities[0]!.turnId, - activities: activities.map((activity) => ({ - ...activity, - groupedToolDetail: true, - live: - isWorking && - activity.id === latestActivity.id && - activity.lifecycleStatus === "inProgress" && - activity.turnId === unsettledTurnId, - })), + onlyToolActivities: activities.every((activity) => activity.toolLike), }); } -function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string { - const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense); - const presentation = resolveWorkEntryToolPresentation({ - ...activity.workEntry, - toolLifecycleStatus: status, - }); - if (presentation) return presentation.displayName; - const command = activity.workEntry.command?.trim(); - if (command) { - const program = commandProgramName(command); - const verb = - status === "inProgress" - ? "Running" - : status === "failed" - ? "Failed" - : status === "declined" - ? "Declined" - : status === "stopped" - ? "Stopped" - : "Ran"; - return `${verb} ${program ?? "command"}`; - } - return activity.detail ?? activity.summary; -} - /** * Sorts activities into lifecycle order. `derivePendingApprovals` and * `derivePendingUserInputs` both expect this ordering; sorting once and @@ -1909,72 +1542,54 @@ export function derivePendingUserInputs( } export function setPendingUserInputCustomAnswer( - question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - if (question.allowCustomAnswer === false) { - return draft ?? {}; - } - - const selectedOptionValues = + const selectedOptionLabels = customAnswer.trim().length > 0 ? undefined - : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); return { customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), }; } export function isPendingUserInputOptionSelected( - question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, + optionLabel: string, ): boolean { - if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { + if (normalizeDraftAnswer(draft?.customAnswer)) { return false; } - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - return ( - resolvedOptionValue !== null && - normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( - resolvedOptionValue, - ) - ); + return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); } export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, + optionLabel: string, ): PendingUserInputDraftAnswer { - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - if (resolvedOptionValue === null) { - return draft ?? {}; - } + const normalizedOptionLabel = optionLabel.trim(); if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues( - question, - draft?.selectedOptionValues, - ); - const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) - ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) - : [...selectedOptionValues, resolvedOptionValue]; + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) + ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) + : [...selectedOptionLabels, normalizedOptionLabel]; return { customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } + ...(nextSelectedOptionLabels.length > 0 + ? { selectedOptionLabels: nextSelectedOptionLabels } : {}), }; } return { customAnswer: "", - selectedOptionValues: [resolvedOptionValue], + selectedOptionLabels: [normalizedOptionLabel], }; } @@ -1986,7 +1601,7 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { + if (!answer) { return null; } answers[question.id] = answer; @@ -2011,18 +1626,65 @@ export function buildThreadFeed( const activityEntries = getThreadFeedActivityEntries(thread.activities); const entries = Arr.sortWith( [ - ...messages.map((message) => { - let entry = messageEntriesCache.get(message); - if (!entry) { - entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; - messageEntriesCache.set(message, entry); - } - return entry; - }), - ...activityEntries.filter( - (entry) => - oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, - ), + ...messages.map((message) => ({ + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + })), + ...workLogEntries + .filter((entry) => { + if (options?.loadedMessages === undefined) { + return true; + } + return ( + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt + ); + }) + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); + const getCopyText = memoizeValue(() => { + const copyLabel = capitalizePhrase( + normalizeCompactToolLabel(entry.toolTitle || entry.label), + ); + const fullDetail = getFullDetail(); + if (entry.command) { + const normalizedCommand = + entry.rawCommand && copyLabel.trim() !== entry.command.trim() + ? entry.command + : null; + return [copyLabel, normalizedCommand, fullDetail ?? entry.command] + .filter((value): value is string => Boolean(value)) + .join("\n"); + } + return [copyLabel, detail, fullDetail] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"); + }); + return { + type: "activity", + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + activity: { + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + summary, + detail, + canExpand: workEntryHasExpandedBody(entry), + getFullDetail, + getCopyText, + icon: workEntryIcon(entry), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 0e3ebb208346..31a2f1d779c5 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -5,6 +5,9 @@ import { clampCodeFontSize, clampInterfaceFontSize, clampPromptFontSize, + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + appearanceFontStack, cssFontFamilies, resolveDefaultFamilyLabel, resolveTerminalFontPreference, @@ -55,6 +58,18 @@ describe("resolveDefaultFamilyLabel", () => { }); }); +describe("appearanceFontStack", () => { + it("prepends the custom family to the default stack", () => { + expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( + `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, + ); + }); + + it("falls back to the default stack when unset", () => { + expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); + }); +}); + describe("resolveTerminalFontPreference", () => { it("inherits the code font in simple mode", () => { expect( diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 74bb88a06c31..6053e5fb0dd4 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -71,6 +71,12 @@ export function cssFontFamilies(input: string): string | null { return families.length > 0 ? families.join(", ") : null; } +/** The full stack a preference resolves to: custom families before the default. */ +export function appearanceFontStack(custom: string, defaultStack: string): string { + const families = cssFontFamilies(custom); + return families === null ? defaultStack : `${families}, ${defaultStack}`; +} + export interface AppearanceFontPreferences { readonly sans: string; readonly code: string; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c67ea3f3d5c3..b4f3cac7de0f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -375,14 +375,6 @@ const helloWorldTemplate: CodexArtifactTemplate = { skillName: "artifact-template-hello-world", }; -describe("artifact template composer insertion", () => { - it("does not insert an already-present prompt", () => { - const prompt = "Create a document using this $artifact-template-hello-world about…"; - - expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); - }); -}); - describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 10c2fd4710ee..0cb0bc2eb53e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,5 +1,4 @@ import { - ANTIGRAVITY_DEFAULT_MODEL, type AssetCreateUrlInput, type AssetCreateUrlResult, type ChatFileAttachment, @@ -9,8 +8,7 @@ import { type MessageId, type ModelSelection, type ProviderInteractionMode, - ProviderDriverKind, - type ProviderInstanceId, + type ProviderDriverKind, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, @@ -34,7 +32,6 @@ import { type SessionPhase, type Thread, type ThreadShell, - type TurnDiffSummary, } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; @@ -48,13 +45,6 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; -import type { DesktopPreviewOverlay } from "../previewStateStore"; -import type { RightPanelSurface } from "../rightPanelStore"; -import { - NO_PROVIDER_MODEL_SELECTION, - resolveSelectableProviderInstanceEntry, - type ProviderInstanceEntry, -} from "../providerInstances"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -63,91 +53,6 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); -export function agentControlledBrowserCloseConfirmation( - surfaces: readonly RightPanelSurface[], - desktopByTabId: Readonly | undefined>>, -): string | null { - const activeBrowserCount = surfaces.filter( - (surface) => - surface.kind === "preview" && - surface.resourceId !== null && - desktopByTabId[surface.resourceId]?.controller === "agent", - ).length; - if (activeBrowserCount === 0) return null; - if (activeBrowserCount === 1) { - return [ - "Close browser while the agent is using it?", - "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", - ].join("\n"); - } - return [ - `Close ${activeBrowserCount} browsers while the agent is using them?`, - "The agent is actively controlling these browsers. Closing them may interrupt the current browser actions.", - ].join("\n"); -} - -export function shouldRenderPreviewMiniPlayer( - miniPlayerTabId: string | null, - renderedRightPanelSurface: RightPanelSurface | null, -): boolean { - return ( - miniPlayerTabId !== null && - !( - renderedRightPanelSurface?.kind === "preview" && - renderedRightPanelSurface.resourceId === miniPlayerTabId - ) - ); -} - -export function shouldOpenProactivePullRequest( - previousTargetKey: string | null | undefined, - targetKey: string | null, -): boolean { - return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey; -} - -export function shouldOpenProactiveTurnDiff(input: { - previousRunningTurnId: TurnId | null | undefined; - runningTurnId: TurnId | null; - settledTurnId: TurnId | null; - turnCompleted: boolean; -}): boolean { - return ( - input.previousRunningTurnId !== undefined && - input.previousRunningTurnId !== null && - input.runningTurnId === null && - input.turnCompleted && - input.settledTurnId === input.previousRunningTurnId - ); -} - -export function resolveProactiveTurnDiffAction(input: { - checkpoint: Pick | undefined; - isGitRepo: boolean | undefined; - activeSurfaceKind: RightPanelSurface["kind"] | null; -}): "defer" | "ignore" | "open" { - if (input.activeSurfaceKind === "pull-request") return "ignore"; - if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; - if (input.isGitRepo === undefined) return "defer"; - if ( - !input.isGitRepo || - input.checkpoint.status !== "ready" || - input.checkpoint.files.length === 0 - ) { - return "ignore"; - } - return "open"; -} - -export function codexArtifactTemplatePromptToAppend( - currentDraft: string, - template: CodexArtifactTemplate, -): string | null { - return appendCodexArtifactTemplateUsePrompt(currentDraft, template) === currentDraft - ? null - : codexArtifactTemplateUsePrompt(template); -} - export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; @@ -356,154 +261,6 @@ export function buildThreadTurnInterruptInput(thread: Pick; - candidateInstanceIds: ReadonlyArray; - lockedProvider: ProviderDriverKind | null; - lockedInstanceId: ProviderInstanceId | null | undefined; -}) { - const requestedInstanceId = input.candidateInstanceIds.find( - (candidate) => candidate != null && candidate !== NO_PROVIDER_MODEL_SELECTION.instanceId, - ); - const requestedDriverKind = - input.lockedProvider ?? - input.entries.find((entry) => entry.instanceId === requestedInstanceId)?.driverKind ?? - input.entries[0]?.driverKind ?? - ProviderDriverKind.make("unconfigured"); - const lockedContinuationGroupKey = input.lockedProvider - ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId) - ?.continuationGroupKey ?? null) - : null; - // Missing metadata must not move Antigravity history into another Google profile. - const requiresExactInstance = - input.lockedProvider === "antigravity" && - input.lockedInstanceId != null && - lockedContinuationGroupKey === null; - const compatibleEntries = input.entries.filter( - (entry) => - (!input.lockedProvider || entry.driverKind === input.lockedProvider) && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey) && - (!requiresExactInstance || entry.instanceId === input.lockedInstanceId), - ); - const selectedProviderEntry = - input.candidateInstanceIds - .map((candidate) => - compatibleEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, - ), - ) - .find((entry) => entry !== undefined) ?? - resolveSelectableProviderInstanceEntry( - compatibleEntries.filter((entry) => entry.driverKind === requestedDriverKind), - undefined, - ) ?? - resolveSelectableProviderInstanceEntry(compatibleEntries, undefined); - const unavailableProviderInstanceId = selectedProviderEntry - ? undefined - : input.lockedProvider - ? (input.lockedInstanceId ?? requestedInstanceId) - : requestedInstanceId; - return { - selectedProviderEntry, - requestedDriverKind, - lockedContinuationGroupKey, - unavailableProviderInstanceId, - }; -} - -/** Keep restored drafts and every plan control on the selected instance's supported mode. */ -export function resolveComposerInteractionMode(input: { - planModeEnabled: boolean; - provider: Pick | null | undefined; - interactionMode: ProviderInteractionMode; -}): { enabled: boolean; interactionMode: ProviderInteractionMode } { - const enabled = - input.planModeEnabled && - input.provider != null && - input.provider.showInteractionModeToggle !== false; - return { - enabled, - interactionMode: enabled ? input.interactionMode : "default", - }; -} - -export function getAntigravitySendBlockReason( - provider: - | Pick - | null - | undefined, - model: string, -): string | null { - if (provider?.driver !== "antigravity") return null; - if (!provider.installed) { - return "Install Antigravity in provider settings before sending."; - } - if (provider.auth.status === "unauthenticated") { - return "Sign in to Antigravity in provider settings before sending."; - } - const slug = model.trim(); - if (slug.length === 0) return "Choose an Antigravity model before sending."; - // A restart clears the account status and catalog. Session startup checks - // saved credentials and validates the model before sending the prompt. - if (provider.auth.status === "unknown") return null; - if (provider.models.length === 0) { - return "Refresh Antigravity models in provider settings before sending."; - } - // A saved model that left the catalog is kept in the picker as unavailable - // so the user sees what the thread used. The server rejects it at turn - // start, so block here unless the provider is in an error state, where a - // retry with the same model is the right move. - if ( - provider.status === "ready" && - slug !== ANTIGRAVITY_DEFAULT_MODEL && - !provider.models.some((entry) => entry.slug === slug || entry.aliases?.includes(slug)) - ) { - return "That Antigravity model is no longer available. Choose another model."; - } - return null; -} - -export function buildRevertTurnCountByUserMessageId(input: { - supportsConversationRollback: boolean; - timelineEntries: ReadonlyArray; - turnDiffSummaryByAssistantMessageId: ReadonlyMap; - inferredCheckpointTurnCountByTurnId: Readonly>; -}) { - const byUserMessageId = new Map(); - if (!input.supportsConversationRollback) { - return byUserMessageId; - } - for (let index = 0; index < input.timelineEntries.length; index += 1) { - const entry = input.timelineEntries[index]; - if (!entry || entry.kind !== "message" || entry.message.role !== "user") { - continue; - } - - for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) { - const nextEntry = input.timelineEntries[nextIndex]; - if (!nextEntry || nextEntry.kind !== "message") { - continue; - } - if (nextEntry.message.role === "user") { - break; - } - const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); - if (!summary) { - continue; - } - const turnCount = - summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof turnCount !== "number") { - break; - } - byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); - break; - } - } - return byUserMessageId; -} - export function reconcileMountedTerminalThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; @@ -586,6 +343,15 @@ export async function resolveFileAttachmentUrl(input: { return url; } +export function isVideoPreviewRequestCurrent( + requestThreadKey: string, + currentThreadKey: string, + requestId: number, + currentRequestId: number, +): boolean { + return requestThreadKey === currentThreadKey && requestId === currentRequestId; +} + export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; @@ -916,24 +682,6 @@ export interface LocalDispatchSnapshot { latestTurnCompletedAt: string | null; sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; - latestTurnStartFailureId: string | null; -} - -export function latestTurnStartFailureId( - activeThread: Thread | undefined, - latestUserMessageId: ChatMessage["id"] | null, -): string | null { - if (latestUserMessageId === null) return null; - return ( - activeThread?.activities.findLast((activity) => { - if (activity.kind !== "provider.turn.start.failed") return false; - const payload = - typeof activity.payload === "object" && activity.payload !== null - ? (activity.payload as { readonly requestId?: unknown }) - : null; - return payload?.requestId === latestUserMessageId; - })?.id ?? null - ); } export function createLocalDispatchSnapshot( @@ -957,7 +705,6 @@ export function createLocalDispatchSnapshot( latestTurnCompletedAt: latestTurn?.completedAt ?? null, sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, - latestTurnStartFailureId: latestTurnStartFailureId(activeThread, latestUserMessage?.id ?? null), }; } @@ -969,7 +716,6 @@ export function hasServerAcknowledgedLocalDispatch(input: { session: Thread["session"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; - latestTurnStartFailureId?: string | null; threadError: string | null | undefined; }): boolean { if (!input.localDispatch) { @@ -978,13 +724,6 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } - if ( - input.latestTurnStartFailureId !== undefined && - input.latestTurnStartFailureId !== null && - input.latestTurnStartFailureId !== input.localDispatch.latestTurnStartFailureId - ) { - return true; - } if (input.phase === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbeb3aa7f72..57463f229b92 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,7 @@ import { type ApprovalRequestId, type ChatFileAttachment, DEFAULT_MODEL, + defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, @@ -21,7 +22,6 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, ProviderDriverKind, - resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; @@ -62,7 +62,6 @@ import { Suspense, useCallback, useEffect, - useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -136,8 +135,8 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, - isBrowserPreviewAttachment, isImageAttachment, + videoMimeType, type SessionPhase, type Thread, type TurnDiffSummary, @@ -154,6 +153,7 @@ import { selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, + updatePullRequestTabStatus, useRightPanelStore, } from "../rightPanelStore"; import { @@ -190,6 +190,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + InfoIcon, Minimize2Icon, PaperclipIcon, WifiOffIcon, @@ -207,12 +208,11 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities } from "../providerModels"; +import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, - sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettings, @@ -220,7 +220,6 @@ import { useEnvironmentSettings, } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; -import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -267,6 +266,7 @@ import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; +import { linkedPullRequestDetailAtom } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -293,21 +293,18 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; -import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; -import { resolveComposerTimelineInset } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; -import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; import { - type EnvironmentOption, resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, @@ -327,13 +324,14 @@ import { } from "./chat/ThreadErrorBanner"; import { resolveDisplayedThreadPr, + threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { - hasAvailableCompactionProvider, + hasAvailableClaudeCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; @@ -348,19 +346,16 @@ import { } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, - buildRevertTurnCountByUserMessageId, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, - latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -368,9 +363,6 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, - shouldOpenProactivePullRequest, - shouldOpenProactiveTurnDiff, - shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -380,10 +372,9 @@ import { deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, + isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, - resolveComposerInteractionMode, - resolveComposerProviderSelection, resolveDraftHeroState, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, @@ -427,10 +418,7 @@ import { } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ServerUpdateAction } from "./ServerUpdateAction"; -import { - ComposerServerUpdateIcon, - ComposerServerUpdateStatus, -} from "./chat/ComposerServerUpdateStatus"; +import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus"; import { buildVersionMismatchDismissalKey, dismissServerUpdateFailure, @@ -576,24 +564,14 @@ function eventPathContainsSelector(event: Event, selector: string): boolean { return path.some((target) => target instanceof Element && target.closest(selector)); } -/** - * Whether input that landed outside any editable or interactive element - * should be redirected into the composer. Shared by type-to-focus and - * paste-to-focus so both honour the same surfaces. - */ -function shouldRedirectInputToComposer(event: Event): boolean { - if (event.defaultPrevented) return false; - if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; - if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; - if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; - return true; -} - function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { - if (event.isComposing) return false; + if (event.defaultPrevented || event.isComposing) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; - if (!shouldRedirectInputToComposer(event)) return false; + + if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; + if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; + if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; // The right-panel surface launcher claims its shortcut letters while it is // visible (data attribute set in RightPanelTabs); those keys open surfaces @@ -606,17 +584,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } -/** - * Plain text pasted with nothing editable focused, such as after the resting - * composer blurred. Files are left to the composer's own paste handler. - */ -function pasteTextToFocusComposer(event: ClipboardEvent): string | null { - if (!event.clipboardData || event.clipboardData.files.length > 0) return null; - if (!shouldRedirectInputToComposer(event)) return null; - const text = event.clipboardData.getData("text/plain"); - return text.length > 0 ? text : null; -} - function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -631,11 +598,6 @@ function formatOutgoingPrompt(params: { const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; -function isCompactCommandMessage(message: ChatMessage): boolean { - const text = message.text.trim().toLowerCase(); - return message.role === "user" && text === "/compact" && !message.attachments?.length; -} - type ChatViewProps = | { environmentId: EnvironmentId; @@ -679,10 +641,6 @@ function useLocalDispatchState(input: { (message) => message.role === "user", ); const latestUserMessageId = latestUserMessage?.id ?? null; - const currentTurnStartFailureId = - localDispatch === null - ? null - : latestTurnStartFailureId(input.activeThread, latestUserMessageId); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -698,7 +656,6 @@ function useLocalDispatchState(input: { session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, - latestTurnStartFailureId: currentTurnStartFailureId, threadError: input.threadError, }), [ @@ -709,7 +666,6 @@ function useLocalDispatchState(input: { input.phase, input.threadError, latestUserMessageId, - currentTurnStartFailureId, localDispatch, ], ); @@ -785,7 +741,7 @@ function serverTerminalIdsStrictSubsetOfClient( interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; - active: boolean; + visible: boolean; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; @@ -799,7 +755,7 @@ interface PersistentThreadTerminalDrawerProps { const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ threadRef, threadId, - active, + visible, launchContext, focusRequestId, splitShortcutLabel, @@ -823,7 +779,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); - const visible = active && terminalUiState.terminalOpen; const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId, @@ -1108,51 +1063,41 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra [onAddTerminalContext, visible], ); - if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { + if (!project || !terminalUiState.terminalOpen || !cwd) { return null; } return ( -
-
- -
+
+
); }); @@ -1537,9 +1482,6 @@ export default function ChatView(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; - const [restingComposerControlsHost, setRestingComposerControlsHost] = - useState(null); - const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = useCallback( (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => { const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; @@ -1556,13 +1498,19 @@ export default function ChatView(props: ChatViewProps) { [composerRef], ); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + const videoPreviewRequestIdRef = useRef(0); + const cancelVideoPreviewRequest = useCallback(() => { + videoPreviewRequestIdRef.current += 1; + }, []); + const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); useEffect(() => { const item = expandedImage?.images[expandedImage.index]; - if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return; - const src = item.src; - return () => revokeBlobPreviewUrl(src); + if (item?.type !== "video" || !item.src.startsWith("blob:")) return; + return () => revokeBlobPreviewUrl(item.src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< @@ -1630,22 +1578,10 @@ export default function ChatView(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); - const getTimelineScrollableNode = useCallback( - () => legendListRef.current?.getScrollableNode() ?? null, - [], - ); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); - const composerOverlayHeightRef = useRef(0); - // Space the timeline keeps clear above its end. Tracks the overlay while the - // composer is expanded and holds that height while it rests, so the resting - // composer never exposes rows that its expansion will cover. - const [composerTimelineInset, setComposerTimelineInset] = useState(0); - const composerTimelineInsetRef = useRef(0); - const composerRestingRef = useRef(false); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); - const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); @@ -1772,6 +1708,13 @@ export default function ChatView(props: ChatViewProps) { // the branch mismatch banner. const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + // Plan mode is legacy (Settings → Beta). With the flag off the effective + // mode is forced to "default" — even for threads with a stored plan mode — + // so nobody is trapped in plan mode while its toggle is hidden. The next + // send persists "default" back to the thread. + const interactionMode = settings.planModeEnabled + ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) + : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; @@ -1818,7 +1761,6 @@ export default function ChatView(props: ChatViewProps) { [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -1838,10 +1780,25 @@ export default function ChatView(props: ChatViewProps) { const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); + const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< + Record + >({}); + // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so + // the tab is found again whether or not that surface was opened with an environment on it. + const activePullRequestSurfaceId = + activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; + const updatePullRequestTabStatusFromPanel = useCallback( + (status: PullRequestTabStatus) => { + const id = activePullRequestSurfaceId; + if (id === undefined) return; + setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); + }, + [activePullRequestSurfaceId], + ); const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const sidebarPrRefreshKeyRef = useRef(null); - const threadPrRelinkKeysRef = useRef(new Map()); - const threadPrRelinkWriteRef = useRef(Promise.resolve()); + const activeFileSurface = + activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; const resolvePreviewRuntimeTabId = useMemo( @@ -1869,38 +1826,6 @@ export default function ChatView(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = - usePanelAnimationSettings(); - const activeTerminalDrawerPresence = usePanelPresence( - Boolean(activeThreadKey && terminalUiState.terminalOpen), - true, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); - const rightPanelPresenceValue = useMemo( - () => ({ - activeSurface: activeRightPanelSurface, - surfaces: rightPanelState.surfaces, - }), - [activeRightPanelSurface, rightPanelState.surfaces], - ); - const rightPanelPresence = usePanelPresence( - rightPanelOpen && activeThreadRef !== null, - rightPanelPresenceValue, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); - const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; - const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; - const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; - const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; - const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.tabId ?? null, - renderedRightPanelSurface, - ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -1916,10 +1841,20 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activePreviewMiniPlayer) return; const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); - if (!miniTabStillExists) { + const sameTabOpenInPanel = + previewPanelOpen && + activeRightPanelSurface?.kind === "preview" && + activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; + if (!miniTabStillExists || sameTabOpenInPanel) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } - }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); + }, [ + activePreviewMiniPlayer, + activePreviewState.sessions, + activeRightPanelSurface, + activeThreadRef, + previewPanelOpen, + ]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); @@ -1953,7 +1888,7 @@ export default function ChatView(props: ChatViewProps) { currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, activeThreadId: activeThreadKey, - activeThreadTerminalOpen: activeTerminalDrawerPresence.present, + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -1961,7 +1896,7 @@ export default function ChatView(props: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = useMemo( () => @@ -2117,18 +2052,22 @@ export default function ChatView(props: ChatViewProps) { (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, ); const seen = new Set(); - const envs: EnvironmentOption[] = []; + const envs: Array<{ + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; + }> = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const environment = environmentById.get(p.environmentId) ?? null; + const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; envs.push({ environmentId: p.environmentId, projectId: p.id, - label: environment?.label ?? p.environmentId, + label, isPrimary, - machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); } // Sort: primary first, then alphabetical @@ -2396,7 +2335,7 @@ export default function ChatView(props: ChatViewProps) { variant: updateFailed ? "error" : "default", // Prioritize update progress over passive notices, but keep activity attached. priority: updateInProgress ? "urgent" : "notice", - icon: , + icon: , title: updateInProgress || updateFailed ? ( - sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), - ), - [providerStatuses, settings], - ); - const { selectedProviderEntry, requestedDriverKind } = useMemo( - () => - resolveComposerProviderSelection({ - entries: providerInstanceEntries, - candidateInstanceIds: [ - selectedProviderByThreadId, - activeThread?.session?.providerInstanceId, - activeThread?.modelSelection.instanceId, - activeProject?.defaultModelSelection?.instanceId, - ], - lockedProvider, - lockedInstanceId: - activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId, - }), - [ - activeProject?.defaultModelSelection?.instanceId, - activeThread?.modelSelection.instanceId, - activeThread?.session?.providerInstanceId, - lockedProvider, - providerInstanceEntries, - selectedProviderByThreadId, - ], + const unlockedSelectedProvider = resolveSelectableProvider( + providerStatuses, + selectedProviderByThreadId ?? threadProvider, ); - const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind; - const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null; - const activeProviderStatus = selectedProviderEntry?.snapshot ?? null; - const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({ - planModeEnabled: settings.planModeEnabled, - provider: activeProviderStatus, - interactionMode: - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, - }); - const conversationProviderStatus = - providerStatuses.find( - (status) => status.instanceId === activeThread?.session?.providerInstanceId, - ) ?? activeProviderStatus; - const supportsConversationRollback = - conversationProviderStatus !== null && - conversationProviderStatus.supportsConversationRollback !== false; + const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null; @@ -2630,33 +2528,7 @@ export default function ChatView(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const optimisticCompactionMessage = optimisticUserMessages.at(-1); - const pendingCompactionMessage = - isSendBusy && - optimisticCompactionMessage !== undefined && - isCompactCommandMessage(optimisticCompactionMessage) - ? optimisticCompactionMessage - : activeThread?.messages.findLast(isCompactCommandMessage); - const compactRequestIsActive = - pendingCompactionMessage !== undefined && - (pendingCompactionMessage.createdAt > - (activeLatestTurn?.requestedAt ?? pendingCompactionMessage.createdAt) || - (activeLatestTurn?.state === "running" && - pendingCompactionMessage.createdAt === activeLatestTurn.requestedAt)); - const compactionSettled = - pendingCompactionMessage !== undefined && - (latestTurnStartFailureId(activeThread, pendingCompactionMessage.id) !== null || - activeThread?.activities.some((activity) => { - if (activity.kind !== "context-compaction") return false; - const payload = activity.payload as { readonly requestId?: unknown } | null | undefined; - return payload?.requestId === pendingCompactionMessage.id; - })); - const isCompacting = - (isSendBusy || phase === "connecting" || phase === "running") && - compactRequestIsActive && - !compactionSettled; - const isWorking = - phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; + const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -2699,11 +2571,12 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { return () => { clearAttachmentPreviewHandoffs(); + cancelVideoPreviewRequest(); for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } }; - }, [clearAttachmentPreviewHandoffs]); + }, [cancelVideoPreviewRequest, clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; @@ -2724,15 +2597,25 @@ export default function ChatView(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; - const [projectServerMessagePreviews] = useState(createMessageAttachmentPreviewProjector); - const [projectHandoffMessagePreviews] = useState(createMessageAttachmentPreviewProjector); - const downloadFileAttachment = useCallback( + const openFileAttachment = useCallback( async (attachment: ChatFileAttachment) => { const connection = readPreparedConnection(environmentId); if (!connection) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } + const isVideo = videoMimeType(attachment) !== null; + const action = isVideo ? "play" : "download"; + const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; + const isCurrentRequest = () => + !isVideo || + isVideoPreviewRequestCurrent( + routeThreadKey, + routeThreadKeyRef.current, + videoPreviewRequestId, + videoPreviewRequestIdRef.current, + ); + if (isVideo) setOpeningVideoAttachmentId(attachment.id); try { const url = await resolveFileAttachmentUrl({ @@ -2741,29 +2624,30 @@ export default function ChatView(props: ChatViewProps) { httpBaseUrl: connection.httpBaseUrl, createAssetUrl: createAttachmentAssetUrl, }); + if (!isCurrentRequest()) return; + if (isVideo) { + setExpandedImage({ + images: [{ src: url, name: attachment.name, type: "video" }], + index: 0, + }); + return; + } const anchor = document.createElement("a"); anchor.href = url; anchor.download = attachment.name; anchor.click(); } catch (error) { + if (!isCurrentRequest()) return; toastManager.add({ type: "error", - title: "Could not download " + attachment.name, + title: "Could not " + action + " " + attachment.name, description: error instanceof Error ? error.message : "The attachment is unavailable.", }); + } finally { + if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); } }, - [createAttachmentAssetUrl, environmentId], - ); - const openFileAttachment = useCallback( - (attachment: ChatFileAttachment) => { - if (isBrowserPreviewAttachment(attachment) && activeThreadRef) { - useRightPanelStore.getState().openAttachment(activeThreadRef, attachment); - return; - } - void downloadFileAttachment(attachment); - }, - [activeThreadRef, downloadFileAttachment], + [createAttachmentAssetUrl, environmentId, routeThreadKey], ); const serverAttachmentResources = useMemo( () => selectHandoffImageResources(serverMessages, attachmentPreviewHandoffByMessageId), @@ -2971,21 +2855,38 @@ export default function ChatView(props: ChatViewProps) { } return byMessageId; }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo( - () => - buildRevertTurnCountByUserMessageId({ - supportsConversationRollback, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - inferredCheckpointTurnCountByTurnId, - }), - [ - supportsConversationRollback, - inferredCheckpointTurnCountByTurnId, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - ], - ); + const revertTurnCountByUserMessageId = useMemo(() => { + const byUserMessageId = new Map(); + for (let index = 0; index < timelineEntries.length; index += 1) { + const entry = timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { + const nextEntry = timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + + return byUserMessageId; + }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); const gitCwd = activeProject ? projectScriptCwd({ @@ -3010,11 +2911,26 @@ export default function ChatView(props: ChatViewProps) { }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const manualCompactionProviderAvailable = useMemo( + // Prefer an instance-id match so a custom Codex instance (e.g. + // `codex_personal`) surfaces its own status/message in the banner rather + // than the default Codex's. Falls back to first-match-by-kind when no + // saved instance id is available or the instance no longer exists. + const selectedProviderInstanceId = + providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) + ?.instanceId ?? null; + const activeProviderInstanceId = + selectedProviderInstanceId ?? + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null; + const compactionProviderAvailable = useMemo( () => - hasAvailableCompactionProvider({ - providers: providerInstanceEntries, - driverKind: selectedProvider, + hasAvailableClaudeCompactionProvider({ + providers: applyProviderInstanceSettings( + deriveProviderInstanceEntries(providerStatuses), + settings, + ), instanceId: activeProviderInstanceId, lockedInstanceId: lockedProvider ? (activeThread?.session?.providerInstanceId ?? @@ -3027,10 +2943,19 @@ export default function ChatView(props: ChatViewProps) { activeThread?.modelSelection.instanceId, activeThread?.session?.providerInstanceId, lockedProvider, - providerInstanceEntries, - selectedProvider, + providerStatuses, + settings, ], ); + const activeProviderStatus = useMemo(() => { + if (activeProviderInstanceId) { + return ( + providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null + ); + } + const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); + return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; + }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = useLocalStorage( `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`, @@ -3073,20 +2998,10 @@ export default function ChatView(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - // Keep a hidden, off-flow strip mounted for existing threads so the composer - // can measure whether its relocated controls fit. The visible chrome remains - // content-driven: Git/environment context or controls that actually fit. - const mountComposerContextStrip = shouldShowComposerContextStrip({ - hasActiveProject: activeProject !== null, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server", - }); const showComposerContextStrip = shouldShowComposerContextStrip({ hasActiveProject: activeProject !== null, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; @@ -3676,7 +3591,6 @@ export default function ChatView(props: ChatViewProps) { const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { - if (mode === "plan" && !interactionModeEnabled) return; if (mode === interactionMode) return; setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { @@ -3686,7 +3600,6 @@ export default function ChatView(props: ChatViewProps) { }, [ interactionMode, - interactionModeEnabled, isLocalDraftThread, scheduleComposerFocus, composerDraftTarget, @@ -3695,29 +3608,12 @@ export default function ChatView(props: ChatViewProps) { ], ); const toggleInteractionMode = useCallback(() => { - if (!interactionModeEnabled) return; handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }, [handleInteractionModeChange, interactionMode, interactionModeEnabled]); - const openProviderSetup = useCallback( - (instanceId: ProviderInstanceId) => { - void navigate({ - to: "/settings/providers", - search: { environmentId, instanceId }, - }); - }, - [environmentId, navigate], - ); - const createBrowserSurface = useCallback( - (profileId?: string) => { - if (!activeThreadRef) return; - void addBrowserSurface({ - threadRef: activeThreadRef, - openPreview, - ...(profileId === undefined ? {} : { profileId }), - }); - }, - [activeThreadRef, openPreview], - ); + }, [handleInteractionModeChange, interactionMode]); + const createBrowserSurface = useCallback(() => { + if (!activeThreadRef) return; + void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); + }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); @@ -3740,57 +3636,8 @@ export default function ChatView(props: ChatViewProps) { ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. - const persistedLinkedThreadPullRequest = isServerThread - ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) - : (activeThread?.linkedPullRequest ?? null); + const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; - const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest( - activeThreadRef?.environmentId ?? null, - persistedLinkedThreadPullRequest, - ); - const replacementLinkedThreadPullRequest = useMemo(() => { - const detected = gitStatusQuery.data?.pr; - const threadBranch = activeThread?.branch; - const projectId = activeProject?.id; - if ( - persistedLinkedThreadPullRequest === null || - (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" && - persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") || - gitStatusQuery.data?.refName !== threadBranch || - detected?.state !== "open" || - detected.headRef !== threadBranch || - projectId === undefined || - activeProjectRepository === null || - (persistedLinkedThreadPullRequest.projectId === projectId && - persistedLinkedThreadPullRequest.repository.toLowerCase() === - activeProjectRepository.toLowerCase() && - persistedLinkedThreadPullRequest.number === detected.number) - ) { - return null; - } - return { - projectId, - repository: activeProjectRepository, - number: detected.number, - url: detected.url, - }; - }, [ - activeProject?.id, - activeProjectRepository, - activeThread?.branch, - gitStatusQuery.data, - persistedLinkedThreadPullRequest, - persistedLinkedThreadPullRequestStatus?.pr.state, - ]); - const linkedThreadPullRequest = - replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest; - const linkedThreadPullRequestKey = linkedThreadPullRequest - ? JSON.stringify([ - linkedThreadPullRequest.projectId, - linkedThreadPullRequest.repository, - linkedThreadPullRequest.number, - ]) - : null; const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( (number: number) => { @@ -3814,63 +3661,6 @@ export default function ChatView(props: ChatViewProps) { supportsPullRequests, ], ); - useEffect(() => { - if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { - return; - } - if (replacementLinkedThreadPullRequest === null) { - threadPrRelinkKeysRef.current.delete(activeThreadKey); - return; - } - const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; - if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; - threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); - const openSurface = selectActiveRightPanelSurface( - useRightPanelStore.getState().byThreadKey, - activeThreadRef, - ); - if ( - openSurface?.kind === "pull-request" && - persistedLinkedThreadPullRequest !== null && - openSurface.projectId === persistedLinkedThreadPullRequest.projectId && - openSurface.repository.toLowerCase() === - persistedLinkedThreadPullRequest.repository.toLowerCase() && - openSurface.number === persistedLinkedThreadPullRequest.number - ) { - useRightPanelStore - .getState() - .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest); - } - - threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { - if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; - const result = await updateThreadMetadata({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadRef.threadId, - linkedPullRequest: replacementLinkedThreadPullRequest, - }, - }); - if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; - if (result._tag !== "Failure") return; - threadPrRelinkKeysRef.current.delete(activeThreadKey); - if (isAtomCommandInterrupted(result)) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to update the thread pull request", - description: chatActionErrorMessage(squashAtomCommandFailure(result)), - }), - ); - }); - }, [ - activeThreadKey, - activeThreadRef, - isServerThread, - persistedLinkedThreadPullRequest, - replacementLinkedThreadPullRequest, - updateThreadMetadata, - ]); const openProjectPullRequest = useCallback( (number: number) => { if ( @@ -3889,121 +3679,6 @@ export default function ChatView(props: ChatViewProps) { }, [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], ); - const proactiveTurnObservationRef = useRef<{ - threadKey: string; - runningTurnId: TurnId | null; - } | null>(null); - const proactivePullRequestObservationRef = useRef<{ - threadKey: string; - targetKey: string | null; - } | null>(null); - - useEffect(() => { - if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { - proactiveTurnObservationRef.current = null; - return; - } - if (!clientSettingsHydrated || threadDetailLoading) { - return; - } - - const previousObservation = proactiveTurnObservationRef.current; - const observingSameThread = previousObservation?.threadKey === activeThreadKey; - const previousRunningTurnId = observingSameThread - ? previousObservation.runningTurnId - : undefined; - const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null; - const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({ - previousRunningTurnId, - runningTurnId: activeRunningTurnId, - settledTurnId, - turnCompleted: activeLatestTurn?.state === "completed", - }) - ? settledTurnId - : null; - const eligibleCompletion = - settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null; - const completedCheckpoint = eligibleCompletion - ? activeThread?.checkpoints.find((checkpoint) => checkpoint.turnId === newlyCompletedTurnId) - : undefined; - const diffAction = eligibleCompletion - ? resolveProactiveTurnDiffAction({ - checkpoint: completedCheckpoint, - isGitRepo: gitStatusQuery.data?.isRepo, - activeSurfaceKind: activeRightPanelSurface?.kind ?? null, - }) - : "ignore"; - proactiveTurnObservationRef.current = { - threadKey: activeThreadKey, - runningTurnId: diffAction === "defer" ? (previousRunningTurnId ?? null) : activeRunningTurnId, - }; - if (diffAction !== "open" || newlyCompletedTurnId === null) return; - - useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }, [ - activeThread?.checkpoints, - activeLatestTurn?.turnId, - activeLatestTurn?.state, - activeRunningTurnId, - activeThreadKey, - activeThreadRef, - activeRightPanelSurface?.kind, - clientSettingsHydrated, - gitStatusQuery.data?.isRepo, - isServerThread, - latestTurnSettled, - onDiffPanelOpen, - settings.proactivePanelsEnabled, - shouldUseRightPanelSheet, - threadDetailLoading, - ]); - - useEffect(() => { - if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { - proactivePullRequestObservationRef.current = null; - return; - } - if (!clientSettingsHydrated || threadDetailLoading) { - return; - } - - const previousObservation = proactivePullRequestObservationRef.current; - const observingSameThread = previousObservation?.threadKey === activeThreadKey; - const previousTargetKey = observingSameThread ? previousObservation.targetKey : undefined; - const newlyLinkedPullRequest = shouldOpenProactivePullRequest( - previousTargetKey, - linkedThreadPullRequestKey, - ); - const eligibleLink = - settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyLinkedPullRequest; - const shouldOpenLink = - eligibleLink && - pullRequestsCapabilityKnown && - supportsPullRequests && - linkedThreadPullRequest !== null; - const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown; - proactivePullRequestObservationRef.current = { - threadKey: activeThreadKey, - targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey, - }; - if (!shouldOpenLink || linkedThreadPullRequest === null) return; - - useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); - }, [ - activeThreadKey, - activeThreadRef, - clientSettingsHydrated, - isServerThread, - linkedThreadPullRequest, - linkedThreadPullRequestKey, - pullRequestsCapabilityKnown, - settings.proactivePanelsEnabled, - shouldUseRightPanelSheet, - supportsPullRequests, - threadDetailLoading, - ]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -4200,27 +3875,6 @@ export default function ChatView(props: ChatViewProps) { storeCloseTerminal, ], ); - const closeAfterAgentBrowserConfirmation = useCallback( - (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => { - const message = agentControlledBrowserCloseConfirmation( - surfaces, - activePreviewState.desktopByTabId, - ); - if (!message) { - closeSurfaces(); - return; - } - const localApi = readLocalApi(); - if (!localApi) return; - void localApi.dialogs.confirm(message, { variant: "destructive" }).then( - (confirmed) => { - if (confirmed) closeSurfaces(); - }, - () => undefined, - ); - }, - [activePreviewState.desktopByTabId], - ); const syncActivePreviewSurface = useCallback(() => { if (!activeThreadRef) return; const nextActiveSurface = selectActiveRightPanelSurface( @@ -4231,26 +3885,14 @@ export default function ChatView(props: ChatViewProps) { setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); } }, [activeThreadRef]); - const finishRightPanelSurfaceClose = useCallback( - (surfaces: readonly RightPanelSurface[]) => { - if (!activeThreadRef) return; - cleanupRightPanelSurfaces(surfaces); - const store = useRightPanelStore.getState(); - for (const surface of surfaces) { - store.closeSurface(activeThreadRef, surface.id); - } - syncActivePreviewSurface(); - }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], - ); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - const finishClose = () => finishRightPanelSurfaceClose([surface]); - if (surface.kind === "preview") { - closeAfterAgentBrowserConfirmation([surface], finishClose); - return; - } + const finishClose = () => { + cleanupRightPanelSurfaces([surface]); + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + syncActivePreviewSurface(); + }; if (surface.kind !== "terminal") { finishClose(); return; @@ -4270,22 +3912,23 @@ export default function ChatView(props: ChatViewProps) { [ activeThreadRef, activeTerminalLabelsById, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, + cleanupRightPanelSurfaces, + syncActivePreviewSurface, ], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); - const finishClose = () => finishRightPanelSurfaceClose(surfaces); - closeAfterAgentBrowserConfirmation(surfaces, finishClose); + cleanupRightPanelSurfaces(surfaces); + useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id); + syncActivePreviewSurface(); }, [ activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, + cleanupRightPanelSurfaces, rightPanelState.surfaces, + syncActivePreviewSurface, ], ); const closeRightPanelSurfacesToRight = useCallback( @@ -4294,26 +3937,22 @@ export default function ChatView(props: ChatViewProps) { const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); - const finishClose = () => finishRightPanelSurfaceClose(surfaces); - closeAfterAgentBrowserConfirmation(surfaces, finishClose); + cleanupRightPanelSurfaces(surfaces); + useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id); + syncActivePreviewSurface(); }, [ activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, + cleanupRightPanelSurfaces, rightPanelState.surfaces, + syncActivePreviewSurface, ], ); const closeAllRightPanelSurfaces = useCallback(() => { if (!activeThreadRef) return; - const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces); - closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose); - }, [ - activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - rightPanelState.surfaces, - ]); + cleanupRightPanelSurfaces(rightPanelState.surfaces); + useRightPanelStore.getState().closeAllSurfaces(activeThreadRef); + }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -4435,7 +4074,6 @@ export default function ChatView(props: ChatViewProps) { const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); - const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null); const timelineScrollModeRef = useRef("following-end"); // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd // re-pins on its own (independent of the refs), so the timeline needs a @@ -4478,11 +4116,11 @@ export default function ChatView(props: ChatViewProps) { return getAnchoredTurnMetrics({ state, anchorIndex, - composerOverlayHeight: composerTimelineInset, + composerOverlayHeight, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); }, - [composerTimelineInset], + [composerOverlayHeight], ); const timelineRealContentOverflowsViewport = useCallback( (list?: LegendListRef | null) => { @@ -4507,44 +4145,12 @@ export default function ChatView(props: ChatViewProps) { const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, - (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET, + (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, - [composerTimelineInset], + [composerOverlayHeight], ); - const pageScrollControllerRef = useRef | null>( - null, - ); - const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { - if (key === "PageUp" && timelineRealContentOverflowsViewport()) { - cancelTimelineLiveFollowForUserNavigation(); - } - }); - useEffect(() => { - const controller = createPageScrollController({ - getContainer: () => legendListRef.current?.getScrollableNode() ?? null, - getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0, - onScrollStart: handlePageScrollStart, - }); - pageScrollControllerRef.current = controller; - - return () => { - controller.dispose(); - if (pageScrollControllerRef.current === controller) { - pageScrollControllerRef.current = null; - } - }; - }, [composerOverlayElement]); - const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => { - pageScrollControllerRef.current?.handleKeyDown(key); - }, []); - const onComposerPageScrollKeyUp = useCallback((key: string) => { - pageScrollControllerRef.current?.handleKeyUp(key); - }, []); - const onComposerPageScrollRelease = useCallback(() => { - pageScrollControllerRef.current?.releaseActiveKey(); - }, []); // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { @@ -4616,18 +4222,11 @@ export default function ChatView(props: ChatViewProps) { // up, and a gesture landing in that window while still pinned would // otherwise break follow with no scroll event left to re-arm it. const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; + resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === + false; // Only an upward wheel is a navigation intent; wheeling down while // following either does nothing (at the end) or moves toward it. const handleWheel = (event: WheelEvent) => { - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } if ( event.deltaY < 0 && contentScrollsUp() && @@ -4669,16 +4268,10 @@ export default function ChatView(props: ChatViewProps) { case "PageUp": case "Home": case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { handleManualNavigation(); } break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - break; default: break; } @@ -4709,7 +4302,7 @@ export default function ChatView(props: ChatViewProps) { } removeListeners?.(); }; - }, [activeThread?.id, timelineRealContentOverflowsViewport]); + }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { // Anchored-end space can be remeasured when the turn completes. Once the @@ -4769,9 +4362,6 @@ export default function ChatView(props: ChatViewProps) { if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { - if (timelineScrollIntentRef.current === "toward-end") { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); @@ -4844,7 +4434,6 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; - timelineScrollIntentRef.current = null; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); @@ -4907,8 +4496,10 @@ export default function ChatView(props: ChatViewProps) { return []; }); resetLocalDispatch(); + cancelVideoPreviewRequest(); + setOpeningVideoAttachmentId(null); setExpandedImage(null); - }, [draftId, resetLocalDispatch, threadId]); + }, [cancelVideoPreviewRequest, draftId, resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); @@ -4956,6 +4547,8 @@ export default function ChatView(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); + // The server-projected settled state keeps the banner and sidebar in sync. + const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const activeComposerTasksProgress = useMemo(() => { if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) { return null; @@ -4974,50 +4567,18 @@ export default function ChatView(props: ChatViewProps) { activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId ? activePlan.steps : null; - - const publishComposerOverlayHeight = useCallback((height: number) => { - const nextHeight = Math.ceil(height); - if (nextHeight <= 0) return; - const previousHeight = composerOverlayHeightRef.current; - if (previousHeight !== nextHeight) { - composerOverlayHeightRef.current = nextHeight; - setComposerOverlayHeight(nextHeight); - } - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, - isResting: composerRestingRef.current, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); - } - setScrollToEndClearance((currentClearance) => - currentClearance === nextHeight ? currentClearance : nextHeight, - ); - }, []); - // The composer reports its resting flag from a layout effect, which runs - // before this component's own layout effects and before any resize - // observation, so every measurement below sees the flag for its layout. - // Only the flag is stored here: the stored height still belongs to the - // previous layout, and the composer publishes the new layout's height - // itself once it has measured it. - const onComposerRestingChange = useCallback((resting: boolean) => { - composerRestingRef.current = resting; - }, []); - // A held reservation belongs to the previous thread's draft. Rebuild it from - // this thread's overlay so a tall draft elsewhere does not pad this one. - useLayoutEffect(() => { - if (!composerOverlayElement) return; - composerTimelineInsetRef.current = 0; - publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); - }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); - useLayoutEffect(() => { if (!composerOverlayElement) return; const updateHeight = () => { - publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + if (nextHeight <= 0) return; + setComposerOverlayHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + setScrollToEndClearance((currentClearance) => + currentClearance === nextHeight ? currentClearance : nextHeight, + ); }; updateHeight(); @@ -5028,40 +4589,62 @@ export default function ChatView(props: ChatViewProps) { return () => { resizeObserver.disconnect(); }; - }, [composerOverlayElement, publishComposerOverlayHeight]); - const activeThreadPr = - replacementLinkedThreadPullRequest !== null - ? (gitStatusQuery.data?.pr ?? null) - : resolveDisplayedThreadPr({ - threadBranch: activeThread?.branch ?? null, - gitStatus: gitStatusQuery.data ?? null, - snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, - retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, - linkedPullRequest: linkedThreadPullRequest, - linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus, - }); + }, [composerOverlayElement]); + const linkedPullRequestStatus = useLinkedThreadPullRequest( + activeThreadRef?.environmentId ?? null, + linkedThreadPullRequest, + ); + const activeThreadPr = resolveDisplayedThreadPr({ + threadBranch: activeThread?.branch ?? null, + gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, + linkedPullRequest: linkedThreadPullRequest, + linkedPullRequestStatus, + }); const handlePullRequestTabStatusChange = useCallback( - (status: Pick) => { - if ( - threadRepository?.toLowerCase() !== status.repository.toLowerCase() || - activeThreadPr?.number !== status.number || - activeThreadPr.state === status.state - ) { + (status: PullRequestTabStatus) => { + updatePullRequestTabStatusFromPanel(status); + const source = threadPullRequestRefreshSource({ + panel: status, + thread: { + repository: threadRepository, + number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, + state: activeThreadPr?.state ?? null, + linked: linkedThreadPullRequest !== null, + }, + }); + if (source === null) { sidebarPrRefreshKeyRef.current = null; return; } - const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`; + const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; if (sidebarPrRefreshKeyRef.current === refreshKey) return; sidebarPrRefreshKeyRef.current = refreshKey; - if (activeThreadRef === null || gitCwd === null) return; - void refreshVcsStatus({ - environmentId: activeThreadRef.environmentId, - input: { cwd: gitCwd }, - }).then(() => { - if (sidebarPrRefreshKeyRef.current === refreshKey) { - sidebarPrRefreshKeyRef.current = null; - } - }); + + if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { + appAtomRegistry.refresh( + linkedPullRequestDetailAtom({ + environmentId: activeThreadRef.environmentId, + input: { + projectId: linkedThreadPullRequest.projectId, + repository: linkedThreadPullRequest.repository, + number: linkedThreadPullRequest.number, + }, + }), + ); + return; + } + if (source === "vcs" && activeThreadRef && gitCwd !== null) { + void refreshVcsStatus({ + environmentId: activeThreadRef.environmentId, + input: { cwd: gitCwd }, + }).then(() => { + if (sidebarPrRefreshKeyRef.current === refreshKey) { + sidebarPrRefreshKeyRef.current = null; + } + }); + } }, [ activeThreadKey, @@ -5069,8 +4652,10 @@ export default function ChatView(props: ChatViewProps) { activeThreadPr?.state, activeThreadRef, gitCwd, + linkedThreadPullRequest, refreshVcsStatus, threadRepository, + updatePullRequestTabStatusFromPanel, ], ); const activeThreadReferenceCopyTarget = useMemo( @@ -5477,16 +5062,12 @@ export default function ChatView(props: ChatViewProps) { activeThread && activeContextWindow ? `${activeThread.id}:${activeContextWindow.updatedAt}` : null; - const activeThreadHasCompactableConversation = - activeThread?.messages.some( - (message) => message.role === "user" && !isCompactCommandMessage(message), - ) ?? false; - const compactThreadUnavailable = + const compactDisabled = !activeThread || - !activeThreadHasCompactableConversation || !activeProject || !isServerThread || - !manualCompactionProviderAvailable || + selectedProvider !== "claudeAgent" || + !compactionProviderAvailable || isWorking || threadDetailLoading || isPreparingWorktree || @@ -5494,15 +5075,15 @@ export default function ChatView(props: ChatViewProps) { feedbackUploading || pendingApprovals.length > 0 || pendingUserInputs.length > 0 || - showPlanFollowUpPrompt; - const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; + showPlanFollowUpPrompt || + composerHasUnsentContent; const compactDisabledReason = compactDisabled ? composerHasUnsentContent ? "Send or clear your draft before compacting" : !activeProject ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" + : !compactionProviderAvailable + ? "Enable a Claude provider before compacting" : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { @@ -5845,16 +5426,6 @@ export default function ChatView(props: ChatViewProps) { return; } - if (command === "rightPanel.close") { - // Nothing open: leave the event alone so the shortcut keeps its - // native meaning (close window on desktop, close tab in a browser). - if (!activeRightPanelSurface) return; - event.preventDefault(); - event.stopPropagation(); - if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface); - return; - } - if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); @@ -5943,7 +5514,6 @@ export default function ChatView(props: ChatViewProps) { terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, - closeRightPanelSurface, requestCloseTerminal, requestClosePanelTerminal, createNewTerminal, @@ -5967,37 +5537,11 @@ export default function ChatView(props: ChatViewProps) { composerRef, ]); - // Paste-to-focus: the resting composer blurs on a click into the timeline, - // so a paste that follows has no editable target and would be dropped. - // Route it to the composer like a typed key, which also expands it. - useEffect(() => { - const handler = (event: ClipboardEvent) => { - if (!activeThreadId || isCommandPaletteOpen()) return; - if (getTerminalFocusOwner() !== null) return; - if (composerRef.current?.isModelPickerOpen()) return; - const text = pasteTextToFocusComposer(event); - if (text === null) return; - if (composerRef.current?.insertTextAtEnd(text)) { - event.preventDefault(); - event.stopPropagation(); - } - }; - window.addEventListener("paste", handler, true); - return () => window.removeEventListener("paste", handler, true); - }, [activeThreadId, composerRef]); - const onRevertToTurnCount = useCallback( async (turnCount: number) => { const localApi = readLocalApi(); if (!localApi || !activeThread || isRevertingCheckpoint) return; - if (!supportsConversationRollback) { - setThreadError( - activeThread.id, - "This provider does not support reverting conversation history. Start a new thread instead.", - ); - return; - } if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( activeThread.id, @@ -6050,7 +5594,6 @@ export default function ChatView(props: ChatViewProps) { phase, revertThreadCheckpoint, setThreadError, - supportsConversationRollback, ], ); @@ -6123,8 +5666,6 @@ export default function ChatView(props: ChatViewProps) { selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, - interactionMode: sendInteractionMode, - interactionModeEnabled: sendInteractionModeEnabled, } = sendCtx; const annotationImageAlreadyAttached = directAnnotation?.image !== undefined && @@ -6271,7 +5812,6 @@ export default function ChatView(props: ChatViewProps) { } if ( !directAnnotation && - sendInteractionModeEnabled && showPlanFollowUpPrompt && activeProposedPlan && composerImages.length === 0 && @@ -6300,9 +5840,10 @@ export default function ChatView(props: ChatViewProps) { }); return; } - // Providers without the legacy toggle receive their native commands unchanged. + // Legacy plan mode: /plan and /default only act when the beta flag is on; + // otherwise they send as plain text like any other message. const standaloneSlashCommand = - sendInteractionModeEnabled && + settings.planModeEnabled && composerImages.length === 0 && composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && @@ -6622,7 +6163,7 @@ export default function ChatView(props: ChatViewProps) { ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), runtimeMode, - interactionMode: sendInteractionMode, + interactionMode, }); if (settingsResult._tag === "Failure") { failure = settingsResult; @@ -6653,7 +6194,7 @@ export default function ChatView(props: ChatViewProps) { title, modelSelection: threadCreateModelSelection, runtimeMode, - interactionMode: sendInteractionMode, + interactionMode, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, @@ -6693,7 +6234,7 @@ export default function ChatView(props: ChatViewProps) { modelSelection: ctxSelectedModelSelection, titleSeed: title, runtimeMode, - interactionMode: sendInteractionMode, + interactionMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, @@ -6914,7 +6455,7 @@ export default function ChatView(props: ChatViewProps) { ); const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionValue: string) => { + (questionId: string, optionLabel: string) => { if (!activePendingUserInput) { return; } @@ -6935,7 +6476,7 @@ export default function ChatView(props: ChatViewProps) { [questionId]: togglePendingUserInputOptionSelection( question, existing[activePendingUserInput.requestId]?.[questionId], - optionValue, + optionLabel, ), }, }; @@ -6957,10 +6498,6 @@ export default function ChatView(props: ChatViewProps) { if (!activePendingUserInput) { return; } - const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question || question.allowCustomAnswer === false) { - return; - } promptRef.current = value; setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, @@ -7034,7 +6571,7 @@ export default function ChatView(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) { + if (!sendCtx?.providerAvailable) { return; } const { @@ -7179,7 +6716,7 @@ export default function ChatView(props: ChatViewProps) { } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) { + if (!sendCtx?.providerAvailable) { return; } const { @@ -7465,9 +7002,14 @@ export default function ChatView(props: ChatViewProps) { } }; - const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { - setExpandedImage(preview); - }, []); + const onExpandTimelineImage = useCallback( + (preview: ExpandedImagePreview) => { + cancelVideoPreviewRequest(); + setOpeningVideoAttachmentId(null); + setExpandedImage(preview); + }, + [cancelVideoPreviewRequest], + ); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; @@ -7516,39 +7058,29 @@ export default function ChatView(props: ChatViewProps) { const panelLayoutControls = (
- {!shouldUseRightPanelSheet ? ( - - - + {rightPanelOpen && !shouldUseRightPanelSheet ? ( + ) : null} -
{panelToggleControls}
+ {panelToggleControls}
); const rightPanelContent = activeThreadRef ? ( - renderedRightPanelSurface?.kind === "preview" ? ( + activeRightPanelSurface?.kind === "preview" ? ( { @@ -7556,11 +7088,11 @@ export default function ChatView(props: ChatViewProps) { }} /> - ) : renderedRightPanelSurface?.kind === "terminal" ? ( + ) : activeRightPanelSurface?.kind === "terminal" ? ( - ) : renderedRightPanelSurface?.kind === "diff" ? ( + ) : activeRightPanelSurface?.kind === "diff" ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( + ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( + ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" ? ( + ) : activeRightPanelSurface?.kind === "pull-request" ? ( // No onClose: the surface tab's own X owns closing here, and a second X in the header // would be the same action twice. The thread context also drops the checkout button, so it // is only right for the thread's own pull request, whose branch is already under the // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. - ) : renderedRightPanelSurface?.kind === "agents" ? ( + ) : activeRightPanelSurface?.kind === "agents" ? ( - ) : (renderedRightPanelSurface?.kind === "files" || - renderedRightPanelSurface?.kind === "file") && - ((activeProject && activeWorkspaceRoot) || - (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && + activeProject && + activeWorkspaceRoot ? ( @@ -7689,7 +7199,7 @@ export default function ChatView(props: ChatViewProps) { return (
- {rightPanelControlsAtRoot ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
- {isElectron && rightPanelControlsAtRoot ? ( - - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {!rightPanelOpen ? panelLayoutControls : null} + { + setThreadError(activeThread.id, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} + /> {/* Main content area with optional plan sidebar */}
{/* Chat column */} @@ -7769,20 +7280,11 @@ export default function ChatView(props: ChatViewProps) {
) : null} - {/* Banners overlay the timeline without changing its content height. */} -
+ {/* Provider status overlays the timeline without changing its content height. */} +
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} - onOpenProviderSetup={openProviderSetup} - /> - { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} />
{/* Messages Wrapper */} @@ -7796,9 +7298,6 @@ export default function ChatView(props: ChatViewProps) { onOpenAgents={addAgentsSurface} key={activeThread.id} isWorking={isWorking} - isPreparingWorktree={isPreparingWorktree} - isCompacting={isCompacting} - activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} latestTurn={activeLatestTurn} @@ -7813,7 +7312,7 @@ export default function ChatView(props: ChatViewProps) { isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} onFileOpen={openFileAttachment} - onFileDownload={downloadFileAttachment} + openingVideoAttachmentId={openingVideoAttachmentId} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} @@ -7825,7 +7324,7 @@ export default function ChatView(props: ChatViewProps) { } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} - contentInsetEndAdjustment={composerTimelineInset} + contentInsetEndAdjustment={composerOverlayHeight} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} @@ -7842,11 +7341,7 @@ export default function ChatView(props: ChatViewProps) { >
@@ -8015,7 +7499,7 @@ export default function ChatView(props: ChatViewProps) { data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined} className="relative z-0" > - {mountComposerContextStrip && ( + {showComposerContextStrip && (
)} @@ -8059,7 +7541,7 @@ export default function ChatView(props: ChatViewProps) {
- {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( + {activeThreadRef && activePreviewMiniPlayer ? ( - {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} + onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} @@ -8171,32 +7650,23 @@ export default function ChatView(props: ChatViewProps) { filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} agentsAvailable + pullRequestStatuses={pullRequestTabStatuses} liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} ) : null} - {rightPanelPresent && shouldUseRightPanelSheet && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + {panelToggleControls} - ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} + layoutControls={
{panelToggleControls}
} + surfaces={rightPanelState.surfaces} + activeSurfaceId={activeRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} @@ -8208,8 +7678,7 @@ export default function ChatView(props: ChatViewProps) { onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} + onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} @@ -8221,6 +7690,7 @@ export default function ChatView(props: ChatViewProps) { filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} agentsAvailable + pullRequestStatuses={pullRequestTabStatuses} liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} @@ -8230,7 +7700,7 @@ export default function ChatView(props: ChatViewProps) { {expandedImage && ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bf3d37d0edc2..fb8533b06c59 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -21,6 +21,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarThreadStatus, resolveThreadStatusPill, @@ -250,6 +251,44 @@ describe("buildMultiSelectThreadContextMenuItems", () => { }); }); +describe("resolveSidebarStageBadgeLabel", () => { + it("returns Nightly for nightly primary server versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.28-nightly.20260616.12", + fallbackStageLabel: "Alpha", + }), + ).toBe("Nightly"); + }); + + it("returns the fallback label for stable primary server versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.27", + fallbackStageLabel: "Alpha", + }), + ).toBe("Alpha"); + }); + + it("returns the fallback label when the primary server version is missing", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: null, + fallbackStageLabel: "Dev", + }), + ).toBe("Dev"); + }); + + it("returns the fallback label for malformed nightly prerelease versions", () => { + expect( + resolveSidebarStageBadgeLabel({ + primaryServerVersion: "0.0.28-nightly.20260616", + fallbackStageLabel: "Alpha", + }), + ).toBe("Alpha"); + }); +}); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8e88aea37f4c..5fdc9cd84512 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,6 +15,7 @@ import type { SidebarThreadSummary, Thread } from "../types"; import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; +import { resolveServerBackedAppStageLabel } from "../branding.logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; @@ -234,6 +235,13 @@ export interface ThreadJumpHintVisibilityController { dispose: () => void; } +export function resolveSidebarStageBadgeLabel(input: { + primaryServerVersion: string | null | undefined; + fallbackStageLabel: string; +}): string { + return resolveServerBackedAppStageLabel(input); +} + export function createThreadJumpHintVisibilityController(input: { delayMs: number; onVisibilityChange: (visible: boolean) => void; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5074d8a27c8e..30e17320d1cb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -23,7 +23,6 @@ import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { - Fragment, memo, type ReactNode, useCallback, @@ -47,23 +46,11 @@ import { replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; -import { - deriveComposerSendState, - getAntigravitySendBlockReason, - readFileAsDataUrl, - resolveComposerInteractionMode, - resolveComposerProviderSelection, -} from "../ChatView.logic"; +import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, } from "./composerMentionDrag"; -import { - composerFloatingLayerProps, - isInsideCollapsedComposerControls, - isInsideComposerFloatingLayer, - isInsideRestingComposerControlScope, -} from "./composerEventScope"; import { type ComposerFileAttachment, type ComposerImageAttachment, @@ -87,7 +74,6 @@ import { } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; -import { useComposerMenuState } from "./useComposerMenuState"; import { ComposerTasksBadge, ComposerTasksContent, @@ -95,7 +81,7 @@ import { type ComposerTaskStep, type ComposerTasksProgress, } from "./ComposerTasksBadge"; -import { ComposerActivityRow } from "./ComposerActivityStatus"; +import { ComposerActivityRow, type ComposerActivityStatus } from "./ComposerActivityStatus"; import type { ThreadSyncPhase } from "../../threadSync"; import { ComposerBanner } from "./ComposerBanner"; import { ComposerSurface } from "./ComposerSurface"; @@ -149,17 +135,9 @@ import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; import { - COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, - COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, - getRestingComposerImagePreviewCounts, - resolveRestingComposerControlsLayout, - shouldAnimateComposerRestingTransition, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, - shouldUseRestingComposerLayout, } from "../composerFooterLayout"; -import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; -import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; @@ -169,12 +147,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; -import { - ComposerControl, - ComposerControlIcon, - ComposerControlSeparator, - ComposerSelectControl, -} from "./ComposerControl"; +import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems, @@ -187,10 +160,7 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { - providerSupportsManualCompaction, - resolveContextWindowModelDisplayName, -} from "./ContextWindowMeter.logic"; +import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, buildExpandedImagePreview, @@ -198,19 +168,13 @@ import { } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; +import { Separator } from "../ui/separator"; import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; -import { - createComposerScrollGestureState, - recordComposerScrollGestureEvent, - resetComposerScrollGesture, - suppressActiveComposerScrollGesture, -} from "./composerScrollGesture"; -import { selectionHoldsComposerOpen } from "./composerSelectionHold"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { @@ -242,448 +206,6 @@ type ComposerCommandMenuPosition = { width: number; }; -const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; -const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; -const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280; -const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50; -const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; -const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; - -function useComposerRestingTransition( - isCollapsed: boolean, - isResting: boolean, - restingControlsRef: React.RefObject, - onOverlayHeightChange: (height: number) => void, -) { - const elementRef = useRef(null); - const isCollapsedRef = useRef(isCollapsed); - const previousCollapsedRef = useRef(isCollapsed); - const previousRestingRef = useRef(isResting); - const previousHeightRef = useRef(null); - const previousContentOffsetsRef = useRef<{ - promptFromTop: number | null; - promptHeight: number | null; - actionFromBottom: number | null; - }>({ promptFromTop: null, promptHeight: null, actionFromBottom: null }); - const animationRef = useRef(null); - const animationTargetHeightRef = useRef(null); - const contentAnimationsRef = useRef([]); - const stateChangeAnimationsRef = useRef([]); - const pinnedOverlayRef = useRef(null); - const transitionCleanupTimeoutRef = useRef(null); - const transitionLayoutRequestRef = useRef(0); - const hasCompletedInitialLayoutRef = useRef(false); - - const clearOverlayPin = useCallback(() => { - // The overlay belongs to the chat view and outlives this composer, so it - // is remembered from pin time rather than re-resolved through a ref that - // React may already have detached during unmount. - const overlay = pinnedOverlayRef.current; - pinnedOverlayRef.current = null; - overlay?.style.removeProperty("height"); - overlay?.style.removeProperty("display"); - overlay?.style.removeProperty("flex-direction"); - overlay?.style.removeProperty("justify-content"); - }, []); - - const clearTransitionStyles = useCallback(() => { - const element = elementRef.current; - const footer = element?.querySelector('[data-chat-composer-footer="true"]'); - element?.style.removeProperty("overflow"); - element - ?.querySelector('[data-chat-composer-surface="true"]') - ?.style.removeProperty("height"); - footer?.style.removeProperty("position"); - footer?.style.removeProperty("top"); - footer?.style.removeProperty("bottom"); - footer?.style.removeProperty("left"); - footer?.style.removeProperty("right"); - footer?.style.removeProperty("height"); - clearOverlayPin(); - }, [clearOverlayPin]); - - isCollapsedRef.current = isCollapsed; - - const transitionToCurrentGeometry = useCallback( - (stateChanged: boolean) => { - const element = elementRef.current; - const surface = element?.querySelector('[data-chat-composer-surface="true"]'); - if (!element || !surface) return; - - const nextIsCollapsed = isCollapsedRef.current; - - const visibleTransitionElement = (selector: string) => - Array.from(element.querySelectorAll(selector)).find( - (candidate) => candidate.getClientRects().length > 0, - ) ?? null; - const prompt = visibleTransitionElement( - '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', - ); - const action = visibleTransitionElement('[data-chat-composer-transition-actions="true"]'); - const footer = element.querySelector('[data-chat-composer-footer="true"]'); - const interruptedAnimation = animationRef.current; - const interruptedPromptTop = interruptedAnimation - ? (prompt?.getBoundingClientRect().top ?? null) - : null; - const interruptedActionTop = interruptedAnimation - ? (action?.getBoundingClientRect().top ?? null) - : null; - const interruptedHeight = interruptedAnimation - ? element.getBoundingClientRect().height - : null; - const interruptedTargetHeight = animationTargetHeightRef.current; - const interruptedCurrentTime = - typeof interruptedAnimation?.currentTime === "number" - ? interruptedAnimation.currentTime - : null; - const interruptedDuration = interruptedAnimation?.effect?.getComputedTiming().duration; - if (transitionCleanupTimeoutRef.current !== null) { - window.clearTimeout(transitionCleanupTimeoutRef.current); - transitionCleanupTimeoutRef.current = null; - } - interruptedAnimation?.cancel(); - animationRef.current = null; - for (const animation of contentAnimationsRef.current) animation.cancel(); - contentAnimationsRef.current = []; - // The reveal and fade animations keep their own schedule across the - // body-resize re-entries that retarget the geometry mid-flight (every - // transition with a draft triggers one); cancelling them there would - // pop their subjects to full visibility at the start of the tween. - if (stateChanged) { - for (const animation of stateChangeAnimationsRef.current) animation.cancel(); - stateChangeAnimationsRef.current = []; - } - clearTransitionStyles(); - - const nextRect = element.getBoundingClientRect(); - const nextHeight = nextRect.height; - // The chat view resize-observes the overlay to place the timeline - // inset, the scroll-to-end pill, and the mini player. Publishing the - // destination height here turns that feedback into one update instead - // of a ChatView re-render on every animation frame. - const overlay = element.closest('[data-chat-composer-overlay="true"]'); - const overlayHeight = overlay?.getBoundingClientRect().height ?? null; - if (overlayHeight !== null) { - onOverlayHeightChange(overlayHeight); - } - const nextPromptRect = prompt?.getBoundingClientRect() ?? null; - const nextPromptTop = nextPromptRect?.top ?? null; - const nextActionTop = action?.getBoundingClientRect().top ?? null; - const previousHeight = interruptedHeight ?? previousHeightRef.current; - const targetChanged = - interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; - const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; - const shouldAnimate = shouldAnimateComposerRestingTransition({ - hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, - stateChanged, - hasInterruptedAnimation: interruptedHeight !== null, - }); - - if ( - shouldAnimate && - !prefersReducedMotion && - previousHeight !== null && - Math.abs(previousHeight - nextHeight) >= 0.5 - ) { - const remainingDuration = - typeof interruptedDuration === "number" && interruptedCurrentTime !== null - ? Math.max(1, interruptedDuration - interruptedCurrentTime) - : COMPOSER_RESTING_TRANSITION_DURATION_MS; - const duration = - interruptedHeight !== null && !targetChanged - ? remainingDuration - : COMPOSER_RESTING_TRANSITION_DURATION_MS; - element.style.overflow = "clip"; - surface.style.height = "100%"; - - // Pinning the overlay at the destination height keeps the resize - // observer quiet for the tween; bottom alignment keeps the animating - // surface glued to the overlay's stable bottom edge. The pin lasts - // only for the tween so later attachment, thread, font, and viewport - // changes remain natural. - if (overlay && overlayHeight !== null) { - overlay.style.height = `${String(overlayHeight)}px`; - overlay.style.display = "flex"; - overlay.style.flexDirection = "column"; - overlay.style.justifyContent = "flex-end"; - pinnedOverlayRef.current = overlay; - } - - // Keep the footer attached to the stable bottom edge while the outer - // height changes. Its resting absolute layout otherwise spans the old - // height on collapse, while its expanded flow layout falls below the - // clipped surface on expansion. - if (footer) { - footer.style.position = "absolute"; - footer.style.top = "auto"; - footer.style.bottom = "1px"; - footer.style.height = "3rem"; - if (nextIsCollapsed) { - footer.style.left = "auto"; - footer.style.right = "1px"; - } else { - footer.style.left = "1px"; - footer.style.right = "1px"; - } - } - - const animation = element.animate( - [{ height: `${previousHeight}px` }, { height: `${nextHeight}px` }], - { - duration, - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ); - animationRef.current = animation; - animationTargetHeightRef.current = nextHeight; - - const animatedRect = element.getBoundingClientRect(); - const previousPromptTop = - interruptedPromptTop ?? - (previousContentOffsetsRef.current.promptFromTop === null - ? null - : animatedRect.top + previousContentOffsetsRef.current.promptFromTop); - const previousActionTop = - interruptedActionTop ?? - (previousContentOffsetsRef.current.actionFromBottom === null - ? null - : animatedRect.bottom - previousContentOffsetsRef.current.actionFromBottom); - const contentAnimations: Animation[] = []; - const animateContentPosition = ( - content: HTMLElement | null, - previousTop: number | null, - ) => { - if (!content || previousTop === null) return; - const offset = previousTop - content.getBoundingClientRect().top; - if (Math.abs(offset) < 0.5) return; - contentAnimations.push( - content.animate( - [{ transform: `translateY(${String(offset)}px)` }, { transform: "none" }], - { - duration, - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ), - ); - }; - animateContentPosition(prompt, previousPromptTop); - animateContentPosition(action, previousActionTop); - contentAnimationsRef.current = contentAnimations; - - if (stateChanged) { - const stateChangeAnimations: Animation[] = []; - - // A prompt that gains lines on expansion would otherwise slide up - // from under the footer band as one block. Opening a bottom clip in - // step with the tween instead unfurls the extra lines beneath the - // rising first line, so no text crosses the returning controls. - const previousPromptHeight = previousContentOffsetsRef.current.promptHeight; - if ( - !nextIsCollapsed && - prompt && - nextPromptRect && - previousPromptHeight !== null && - nextPromptRect.height - previousPromptHeight >= 0.5 - ) { - const hiddenHeight = nextPromptRect.height - previousPromptHeight; - stateChangeAnimations.push( - prompt.animate( - [ - { clipPath: `inset(0 0 ${String(hiddenHeight)}px 0)` }, - { clipPath: "inset(0 0 0 0)" }, - ], - { - duration, - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ), - ); - } - - // The footer controls teleport between the composer footer and the - // context strip below it in a single commit. Fading the arriving - // cluster in along its direction of travel reads as one continuous - // move instead of a pop. Collapsing controls land in empty strip - // space and can appear immediately, but expanding controls return - // to the bottom row the prompt still occupies while the surface is - // short, so they stay hidden through the first half of the tween - // and fade in once the geometry has mostly settled. - const arrivingControls = nextIsCollapsed - ? restingControlsRef.current - : element.querySelector('[data-chat-composer-controls="left"]'); - if (arrivingControls) { - const drift = nextIsCollapsed - ? -COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX - : COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX; - stateChangeAnimations.push( - arrivingControls.animate( - [ - { opacity: 0, transform: `translateY(${String(drift)}px)` }, - { opacity: 1, transform: "none" }, - ], - { - duration: nextIsCollapsed ? duration : duration / 2, - delay: nextIsCollapsed ? 0 : duration / 2, - fill: "backwards", - easing: COMPOSER_RESTING_TRANSITION_EASING, - }, - ), - ); - } - - const arrivingImagePreviews = nextIsCollapsed - ? Array.from( - element.querySelectorAll('[data-chat-composer-resting-images="true"]'), - ) - : Array.from( - element.querySelectorAll('[data-chat-composer-expanded-image="true"]'), - ); - for (const imagePreview of arrivingImagePreviews) { - stateChangeAnimations.push( - imagePreview.animate([{ opacity: 0 }, { opacity: 1 }], { - duration: nextIsCollapsed ? duration : duration / 2, - delay: nextIsCollapsed ? 0 : duration / 2, - fill: "backwards", - easing: COMPOSER_RESTING_TRANSITION_EASING, - }), - ); - } - stateChangeAnimationsRef.current = stateChangeAnimations; - } - - const finishTransition = (cancelAnimations: boolean) => { - if (animationRef.current !== animation) return; - if (transitionCleanupTimeoutRef.current !== null) { - window.clearTimeout(transitionCleanupTimeoutRef.current); - transitionCleanupTimeoutRef.current = null; - } - if (cancelAnimations) { - animation.cancel(); - for (const contentAnimation of contentAnimationsRef.current) { - contentAnimation.cancel(); - } - for (const stateChangeAnimation of stateChangeAnimationsRef.current) { - stateChangeAnimation.cancel(); - } - } - animationRef.current = null; - animationTargetHeightRef.current = null; - contentAnimationsRef.current = []; - stateChangeAnimationsRef.current = []; - clearTransitionStyles(); - }; - void animation.finished.catch(() => undefined).then(() => finishTransition(false)); - // A suspended document timeline can leave `finished` pending while - // these measurement styles remain active. Wall-clock cleanup makes - // the natural layout the eventual source of truth in that case. - transitionCleanupTimeoutRef.current = window.setTimeout( - () => finishTransition(true), - duration + COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS, - ); - } else { - animationTargetHeightRef.current = null; - } - - previousCollapsedRef.current = nextIsCollapsed; - previousHeightRef.current = nextHeight; - previousContentOffsetsRef.current = { - promptFromTop: nextPromptTop === null ? null : nextPromptTop - nextRect.top, - promptHeight: nextPromptRect?.height ?? null, - actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop, - }; - }, - [clearTransitionStyles, onOverlayHeightChange, restingControlsRef], - ); - - useLayoutEffect(() => { - const requestId = transitionLayoutRequestRef.current + 1; - transitionLayoutRequestRef.current = requestId; - const stateChanged = previousCollapsedRef.current !== isCollapsed; - // A non-Git context strip enters or leaves flow through ChatView state in - // an earlier layout effect. Let React flush that parent update before the - // FLIP reads its destination geometry, while still running before paint. - queueMicrotask(() => { - if (transitionLayoutRequestRef.current !== requestId) return; - transitionToCurrentGeometry(stateChanged); - }); - return () => { - if (transitionLayoutRequestRef.current === requestId) { - transitionLayoutRequestRef.current += 1; - } - }; - }, [isCollapsed, transitionToCurrentGeometry]); - - // The resting flag can change while the collapsed layout stays the same, - // for example when an unfocused thread crosses the phone breakpoint. The - // chat view pairs overlay heights with that flag, so republish the natural - // height for the new flag. A transition in flight publishes its own. - useLayoutEffect(() => { - if (previousRestingRef.current === isResting) return; - previousRestingRef.current = isResting; - if (animationRef.current) return; - const overlay = elementRef.current?.closest('[data-chat-composer-overlay="true"]'); - if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); - }, [isResting, onOverlayHeightChange]); - - useLayoutEffect(() => { - const element = elementRef.current; - if (!element || typeof ResizeObserver === "undefined") return; - - const body = element.querySelector('[data-chat-composer-body="true"]'); - const observer = new ResizeObserver((entries) => { - if (animationRef.current) { - if (body && entries.some((entry) => entry.target === body)) { - transitionToCurrentGeometry(false); - } - return; - } - const elementRect = element.getBoundingClientRect(); - const visibleTransitionElement = (selector: string) => - Array.from(element.querySelectorAll(selector)).find( - (candidate) => candidate.getClientRects().length > 0, - ) ?? null; - const promptRect = visibleTransitionElement( - '[data-testid="composer-editor"], [data-chat-composer-transition-prompt="true"]', - )?.getBoundingClientRect(); - const actionTop = visibleTransitionElement( - '[data-chat-composer-transition-actions="true"]', - )?.getBoundingClientRect().top; - previousHeightRef.current = elementRect.height; - previousContentOffsetsRef.current = { - promptFromTop: promptRect === undefined ? null : promptRect.top - elementRect.top, - promptHeight: promptRect?.height ?? null, - actionFromBottom: actionTop === undefined ? null : elementRect.bottom - actionTop, - }; - }); - observer.observe(element); - if (body) observer.observe(body); - return () => observer.disconnect(); - }, [transitionToCurrentGeometry]); - - useEffect(() => { - // Host discovery and width measurement settle through layout updates on - // mount. Treat that bootstrap as initial geometry so an existing thread - // paints at rest instead of visibly collapsing from the expanded height. - hasCompletedInitialLayoutRef.current = true; - return () => { - if (transitionCleanupTimeoutRef.current !== null) { - window.clearTimeout(transitionCleanupTimeoutRef.current); - transitionCleanupTimeoutRef.current = null; - } - animationRef.current?.cancel(); - animationRef.current = null; - animationTargetHeightRef.current = null; - for (const animation of contentAnimationsRef.current) animation.cancel(); - contentAnimationsRef.current = []; - for (const animation of stateChangeAnimationsRef.current) animation.cancel(); - stateChangeAnimationsRef.current = []; - clearTransitionStyles(); - }; - }, [clearTransitionStyles]); - - return elementRef; -} - function composerCommandMenuPositionsEqual( a: ComposerCommandMenuPosition, b: ComposerCommandMenuPosition, @@ -792,11 +314,13 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { hasProviderSetup } from "./ProviderStatusBanner"; +import { getProviderInteractionModeToggle } from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveProviderDriverKindForInstanceSelection, + resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -819,8 +343,6 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "../../state/server"; import type { ReviewCommentContext } from "../../reviewCommentContext"; -const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; - const runtimeModeConfig: Record< RuntimeMode, { label: string; description: string; icon: LucideIcon } @@ -848,6 +370,15 @@ const runtimeModeConfig: Record< }; const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; +const COMPOSER_FLOATING_LAYER_SELECTOR = [ + '[data-composer-drawer-layer="true"]', + '[data-slot="popover-popup"]', + '[data-slot="menu-popup"]', + '[data-slot="select-popup"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', +].join(","); + const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -876,61 +407,17 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); -function useRestingComposerControlsLayout(host: HTMLDivElement | null) { - const controlsRef = useRef(null); - const hostRef = useRef(host); - hostRef.current = host; - const [layout, setLayout] = useState({ hiddenCount: 0, visible: true }); - - const measure = useCallback(() => { - const currentHost = hostRef.current; - const controls = controlsRef.current; - // The controls only mount while the composer rests, so the expanded - // composer pays no layout reads here despite the every-render effect. - if (currentHost === null || !controls) return; - - const measurement = measureRestingComposerControls(controls); - if (!measurement) return; - const hostWidth = currentHost.clientWidth; - - setLayout((current) => { - const next = resolveRestingComposerControlsLayout({ - ...measurement, - hostWidth, - previous: current, - }); - return next.hiddenCount === current.hiddenCount && next.visible === current.visible - ? current - : next; - }); - }, []); - - useLayoutEffect(measure); - useEffect(() => { - if (!host) return; - const observer = new ResizeObserver(measure); - observer.observe(host); - document.fonts.addEventListener("loadingdone", measure); - return () => { - observer.disconnect(); - document.fonts.removeEventListener("loadingdone", measure); - }; - }, [host, measure]); - - return { controlsRef, hiddenBlockCount: layout.hiddenCount, controlsVisible: layout.visible }; +function isInsideComposerFloatingLayer(element: Element): boolean { + return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; - size?: "sm" | "xs"; - hidden?: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { - const size = props.size ?? "sm"; - const [open, setOpen] = useComposerMenuState(props.hidden); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -940,19 +427,16 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const interactionModeToggle = props.showInteractionModeToggle ? ( <> - + {props.interactionMode === "plan" ? ( - + ) : ( - + )} {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -984,28 +460,20 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop return ( <> - + ) : null} + {showMobilePendingAnswerActions ? null : inlineStashBadge} diff --git a/apps/web/src/components/chat/ComposerActivityStatus.tsx b/apps/web/src/components/chat/ComposerActivityStatus.tsx index 3d6f5b763b95..23de4fcc344a 100644 --- a/apps/web/src/components/chat/ComposerActivityStatus.tsx +++ b/apps/web/src/components/chat/ComposerActivityStatus.tsx @@ -1,22 +1,106 @@ import { LoaderCircleIcon } from "lucide-react"; +import { useEffect, useRef } from "react"; import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; import { ComposerBanner } from "./ComposerBanner"; -export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) { +export type ComposerActivityStatus = + | { readonly kind: "working"; readonly startedAt: string | null } + | { readonly kind: "sync"; readonly phase: ThreadSyncPhase }; + +export function ComposerActivityIcon({ status }: { readonly status: ComposerActivityStatus }) { + if (status.kind !== "sync") return null; + return ( + + + + ); +} + +export function ComposerActivityRow({ status }: { readonly status: ComposerActivityStatus }) { return ( - - - + - - {threadSyncLabel(phase)} - + ); } + +export function ComposerActivityLabel({ status }: { readonly status: ComposerActivityStatus }) { + if (status.kind === "sync") { + return ( + + {threadSyncLabel(status.phase)} + + ); + } + return ( + + {status.startedAt ? ( + <> + Working for + + ) : ( + "Working…" + )} + + ); +} + +/** Updates only the elapsed text, without committing the composer or timeline each second. */ +function WorkingTimer({ createdAt }: { createdAt: string }) { + const textRef = useRef(null); + const initialText = formatWorkingTimerNow(createdAt); + + useEffect(() => { + const updateText = () => { + if (textRef.current) { + textRef.current.textContent = formatWorkingTimerNow(createdAt); + } + }; + updateText(); + const id = setInterval(updateText, 1000); + return () => clearInterval(id); + }, [createdAt]); + + return ( + + {initialText} + + ); +} + +function formatWorkingTimer(startIso: string, endIso: string): string | null { + const startedAtMs = Date.parse(startIso); + const endedAtMs = Date.parse(endIso); + if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { + return null; + } + + const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s`; + } + + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +function formatWorkingTimerNow(startIso: string): string { + return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s"; +} diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 092baa0ec8af..e297cbb97139 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -104,7 +104,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) {
[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", @@ -118,24 +118,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { function Dock({ className, ...props }: ComponentProps<"div">) { return ( - ); -} - -/** Attachments share a column while neighboring tabs keep their own surface. */ -function Column({ className, ...props }: ComponentProps<"div">) { - return ( -
[data-slot=composer-banner-attachment]]:w-full [&>[data-slot=composer-banner-attachment]:last-child]:mb-0", - className, - )} + className={cn("flex items-end gap-1 *:data-[composer-banner-width=fill]:flex-1", className)} {...props} /> ); @@ -317,11 +300,7 @@ function ToggleIcon({ expanded, className }: { expanded: boolean; className?: st return ( @@ -341,7 +320,6 @@ export const ComposerBanner = { Peek, Attachment, Dock, - Column, Root, Row, Icon, diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 5de40a0d90d5..468190bea2bd 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -1,5 +1,4 @@ -import { InfoIcon } from "lucide-react"; -import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; @@ -48,8 +47,6 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro const [stackExpanded, setStackExpanded] = useState(false); const noticesRef = useRef(null); const peekRef = useRef(null); - const expandedItemsRef = useRef(null); - const pendingFocusRef = useRef<"peek" | "notice" | null>(null); const expandedItemsId = useId(); const [requestedExitingItemId, setExitingItemId] = useState(null); const dismissTimeoutRef = useRef | null>(null); @@ -70,19 +67,6 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro if (items.length < 2) setStackExpanded(false); }, [items.length]); - useLayoutEffect(() => { - if (stackExpanded && pendingFocusRef.current === "notice") { - pendingFocusRef.current = null; - const firstControl = expandedItemsRef.current?.querySelector( - 'button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]', - ); - (firstControl ?? expandedItemsRef.current)?.focus({ preventScroll: true }); - } else if (!stackExpanded && pendingFocusRef.current === "peek") { - pendingFocusRef.current = null; - peekRef.current?.focus({ preventScroll: true }); - } - }, [stackExpanded]); - if (items.length === 0) { return null; } @@ -147,17 +131,14 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro {hasStack ? (
{ - if (event.pointerType === "touch") return; - if (document.activeElement === peekRef.current) { - pendingFocusRef.current = "notice"; - } - setStackExpanded(true); + if (event.pointerType !== "touch") setStackExpanded(true); }} onPointerLeave={(event) => { if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false); }} + onFocusCapture={() => setStackExpanded(true)} onBlurCapture={(event) => { if ( !event.currentTarget.contains(event.relatedTarget) && @@ -167,10 +148,9 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro } }} onKeyDown={(event) => { - if (event.key !== "Escape" || !stackExpanded) return; - event.preventDefault(); + if (event.key !== "Escape") return; event.stopPropagation(); - pendingFocusRef.current = "peek"; + peekRef.current?.focus({ preventScroll: true }); setStackExpanded(false); }} > @@ -181,25 +161,18 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro aria-label="Show other notices" aria-expanded={stackExpanded} aria-controls={expandedItemsId} - aria-hidden={stackExpanded || undefined} - tabIndex={stackExpanded ? -1 : 0} onClick={(event) => { event.currentTarget.focus({ preventScroll: true }); - pendingFocusRef.current = "notice"; setStackExpanded(true); }} - className={cn(stackExpanded && "pointer-events-none invisible opacity-0")} + className={cn(stackExpanded && "opacity-0")} /> ) : null}
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 2a7df2488233..b8cca410db13 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -201,7 +201,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( - +

{activeQuestion.question}

{activeQuestion.multiSelect ? (

Select one or more options.

diff --git a/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx b/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx index 60a998cbeab9..4133bde44b2d 100644 --- a/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx +++ b/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx @@ -1,25 +1,10 @@ import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; -import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react"; import { useId, useState } from "react"; import { serverUpdateStageLabel } from "../ServerUpdateAction"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ComposerBanner } from "./ComposerBanner"; -export function ComposerServerUpdateIcon({ - status, -}: { - readonly status: ServerUpdateState["status"]; -}) { - if (status === "running") { - return ; - } - if (status === "failed") { - return ; - } - return ; -} - /** One text line, clipped at the end so the error detail never squeezes its title. */ export function ComposerServerUpdateStatus({ state, diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx index 9a70094669fa..a2480fc740b2 100644 --- a/apps/web/src/components/chat/ComposerStashBadge.tsx +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -2,6 +2,7 @@ import { BookmarkIcon } from "lucide-react"; import { memo } from "react"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; import { ComposerBanner } from "./ComposerBanner"; /** @@ -40,6 +41,27 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { data-composer-shoulder-tab className="ml-auto" > + } + data-prompt-stash-badge="true" + aria-label={`Stashed prompts: ${props.count}. Open stash.`} + aria-expanded={props.menuOpen} + className={cn( + "shrink-0 gap-1 px-1.5 text-xs sm:text-xs", + (props.menuOpen || props.pulsing) && + "[--control-icon-color:currentColor] text-foreground", + )} + onPointerDown={(event) => event.preventDefault()} + onClick={props.onToggleMenu} + > +