From 3bcc28a34f14343eee5800c156d18ccb4524bc9d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 12:37:17 -0700 Subject: [PATCH 001/326] 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 | 297 +++---- .../features/threads/ThreadDetailScreen.tsx | 300 ++++--- .../src/features/threads/ThreadFeed.tsx | 217 +++-- .../threads/floating-working-control.tsx | 205 ----- .../src/features/threads/thread-work-log.tsx | 436 +++------- apps/mobile/src/lib/threadActivity.test.ts | 268 +----- apps/mobile/src/lib/threadActivity.ts | 293 ++----- apps/web/src/appearanceFonts.test.ts | 15 + apps/web/src/appearanceFonts.ts | 6 + apps/web/src/components/BranchToolbar.tsx | 6 +- .../web/src/components/ChatView.logic.test.ts | 19 - apps/web/src/components/ChatView.logic.ts | 11 - apps/web/src/components/ChatView.tsx | 149 ++-- apps/web/src/components/Sidebar.logic.test.ts | 39 + apps/web/src/components/Sidebar.logic.ts | 8 + apps/web/src/components/chat/ChatComposer.tsx | 391 +++++---- .../chat/ComposerActivityStatus.tsx | 106 +++ .../src/components/chat/ComposerBanner.tsx | 330 ++++++++ .../chat/ComposerBannerStack.test.tsx | 108 --- .../components/chat/ComposerBannerStack.tsx | 306 ++++--- .../chat/ComposerCommandMenu.test.tsx | 2 - .../components/chat/ComposerCommandMenu.tsx | 7 +- .../chat/ComposerPendingApprovalPanel.tsx | 4 +- .../chat/ComposerPendingUserInputPanel.tsx | 67 +- .../chat/ComposerPlanFollowUpBanner.tsx | 17 +- .../chat/ComposerServerUpdateStatus.tsx | 49 ++ .../chat/ComposerStashBadge.test.tsx | 68 -- .../components/chat/ComposerStashBadge.tsx | 54 +- .../chat/ComposerStashMenu.test.tsx | 44 - .../src/components/chat/ComposerStashMenu.tsx | 168 ++-- .../src/components/chat/ComposerSurface.tsx | 99 +++ .../chat/ComposerTasksBadge.test.tsx | 190 ----- .../components/chat/ComposerTasksBadge.tsx | 327 ++++---- .../chat/MessagesTimeline.logic.test.ts | 61 +- .../components/chat/MessagesTimeline.logic.ts | 159 +--- .../components/chat/MessagesTimeline.test.tsx | 29 +- .../src/components/chat/MessagesTimeline.tsx | 357 ++++---- .../chat/ThreadSyncStatusPill.test.tsx | 22 - .../components/chat/ThreadSyncStatusPill.tsx | 19 - apps/web/src/index.css | 524 ------------ 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 | 28 + apps/web/src/session-logic.ts | 1 + 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 | 45 + apps/web/src/versionSkew.ts | 27 + packages/client-runtime/package.json | 8 - .../src/work-log/commandLabel.ts | 169 ---- .../src/work-log/presentation.ts | 188 ----- patches/@legendapp__list@3.3.5.patch | 778 ++---------------- ...t-native-keyboard-controller@1.21.13.patch | 526 ++---------- pnpm-lock.yaml | 16 +- 57 files changed, 2682 insertions(+), 5075 deletions(-) delete mode 100644 apps/mobile/src/features/threads/floating-working-control.tsx create mode 100644 apps/web/src/components/chat/ComposerActivityStatus.tsx create mode 100644 apps/web/src/components/chat/ComposerBanner.tsx delete mode 100644 apps/web/src/components/chat/ComposerBannerStack.test.tsx create mode 100644 apps/web/src/components/chat/ComposerServerUpdateStatus.tsx delete mode 100644 apps/web/src/components/chat/ComposerStashBadge.test.tsx create mode 100644 apps/web/src/components/chat/ComposerSurface.tsx delete mode 100644 apps/web/src/components/chat/ComposerTasksBadge.test.tsx delete mode 100644 apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx delete mode 100644 apps/web/src/components/chat/ThreadSyncStatusPill.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 c81b2456dbf7..2339994e9f5e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -9,16 +9,7 @@ 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 { 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, { @@ -27,9 +18,6 @@ import Animated, { FadeOut, FadeOutDown, LinearTransition, - useAnimatedStyle, - useSharedValue, - withTiming, } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; @@ -43,6 +31,7 @@ import { ComposerInlineControl, ComposerToolbarButton, ComposerToolbarRow, + ComposerToolbarScroller, } from "../../components/ComposerToolbar"; import { ControlPill } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; @@ -115,17 +104,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); +const COMPOSER_LAYOUT_TRANSITION = + Platform.OS === "android" ? undefined : LinearTransition.duration(220); export function ComposerSurface(props: { readonly children: ReactNode; @@ -133,53 +119,29 @@ export function ComposerSurface(props: { /** Existing thread composers morph between pill and card 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, - }) - : targetBorderRadius; - }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]); - const animatedShapeStyle = useAnimatedStyle(() => ({ - borderRadius: animatedBorderRadius.value, - })); - - // Clipping here keeps the expanded toolbar inside the glass while the - // bottom-anchored host owns the geometry transition. + // 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} - {props.children} ); } @@ -292,8 +254,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const isExpanded = isFocused || settingsSheetPresentation.isActive; const canSend = hasContent; - // Keep the parent's feed inset synchronized while the settings sheet keeps - // the composer expanded between editor focus events. + // 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]); @@ -316,17 +279,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); - onExpandedChange?.(true); onEditorFocusChange?.(true); - }, [onEditorFocusChange, onExpandedChange]); + }, [onEditorFocusChange]); const handleBlur = useCallback(() => { setIsFocused(false); - if (!settingsSheetPresentation.isActive) { - onExpandedChange?.(false); - } onEditorFocusChange?.(false); - }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); + }, [onEditorFocusChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; @@ -486,6 +445,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return ( {composerMenu.trigger && composerMenu.items.length > 0 ? ( @@ -532,11 +493,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer paddingTop: 14, } : { - // Keep the numeric radius close to the expanded card so the - // shape morph stays bounded while rendering as a capsule. - borderRadius: 27, + borderRadius: 999, overflow: "hidden" as const, - paddingHorizontal: 14, + flexDirection: "row" as const, + alignItems: "center" as const, + paddingLeft: 18, + paddingRight: 5, paddingVertical: 5, } } @@ -556,125 +518,96 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : 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, - paddingHorizontal: 4, - paddingVertical: 4, - } - : { - height: 36, - paddingHorizontal: 4, - } - } - 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 ? ( - - {showStopAction ? ( - - ) : ( - - )} - - ) : null} - - - - - void props.onPickDraftImages()} - showChevron={false} - /> - - + + 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, } - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth={152} - onPress={openSettings} + : { + 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} + {!isExpanded ? ( + + {showStopAction ? ( + + ) : ( + + )} + + ) : null} + {isExpanded ? ( + + + void props.onPickDraftImages()} showChevron={false} /> + + } + label={currentModelOption?.label ?? currentModelSelection.model} + maxWidth={152} + onPress={openSettings} + /> {showStopAction ? ( ) : null} - + + - + ) : null} {/* Queue count */} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 7888cd5321cf..2c6860199722 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -27,6 +27,7 @@ import { useRef, useState, } from "react"; +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { AppState, Keyboard, @@ -44,13 +45,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"; @@ -66,10 +67,6 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; -import { - FLOATING_WORKING_CONTROL_COVERAGE, - FloatingWorkingControl, -} from "./floating-working-control"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, @@ -78,8 +75,6 @@ import { import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, - COMPOSER_LAYOUT_TRANSITION, - COMPOSER_TRANSITION_DURATION_MS, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; @@ -295,14 +290,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } })(); - const showWorkingControl = - props.activeWorkStartedAt !== null && - contentPresentationKind === "ready" && - threadSyncPhase === null && - props.connectionStateLabel === "connected" && - props.activePendingApproval === null && - props.activePendingUserInput === null; - const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : null; const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; @@ -356,7 +343,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 @@ -370,15 +356,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. @@ -389,7 +366,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useAnimatedReaction( () => contentInsetEndAdjustment.value + - floatingControlCoverage.value + (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), (value) => { combinedContentInsetEndAdjustment.value = value; @@ -399,24 +375,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; } @@ -429,29 +401,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; @@ -461,7 +416,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. @@ -472,11 +427,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, @@ -667,9 +622,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} @@ -686,110 +639,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 ec41cb9b5e22..23bee49455e9 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -4,6 +4,7 @@ import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; +import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { SymbolView } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useNavigation } from "@react-navigation/native"; @@ -44,13 +45,7 @@ import { import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { - FadeIn, - FadeInUp, - FadeOut, - 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"; @@ -89,7 +84,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"; @@ -108,7 +105,6 @@ import { collapsedWorkLogHeight, ThreadWorkGroupToggle, ThreadWorkLog, - THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; @@ -138,10 +134,9 @@ function formatMessageTime(input: string): string { // 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) -const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); -const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140); -const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120); -const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet = new Set(); +// 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 @@ -990,6 +985,10 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "working") { + return ; + } + if (entry.type === "turn-fold") { return ( props.onToggleWorkGroup(entry.groupId)} /> ); @@ -1184,6 +1180,32 @@ 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; @@ -1457,14 +1479,14 @@ function ThreadFeedPlaceholder(props: { export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const navigation = useNavigation(); 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 previousPresentedFeedRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const userScrollSettleTimerRef = useRef | null>(null); const { width: windowWidth } = useWindowDimensions(); + const { appearance } = useAppearancePreferences(); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); @@ -1769,37 +1791,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); - const disclosureEnteringEntryIds = useMemo(() => { - const anchorKey = disclosureAnchorKeyRef.current; - const previousPresentedFeed = previousPresentedFeedRef.current; - if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) { - return EMPTY_DISCLOSURE_ENTRY_IDS; - } - - const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id)); - const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey); - const enteringIds = new Set(); - if (anchorIndex < 0) { - return enteringIds; - } - for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) { - const entryId = presentedFeed[index]!.id; - if (previousIds.has(entryId)) { - break; - } - enteringIds.add(entryId); - } - return enteringIds; - }, [disclosureToggleSettling, presentedFeed]); - - useLayoutEffect(() => { - previousPresentedFeedRef.current = presentedFeed; - }, [presentedFeed]); - // 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; @@ -1864,52 +1863,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(() => { + foldSettleFrameRef.current = requestAnimationFrame(() => { + foldSettleSecondFrameRef.current = requestAnimationFrame(() => { disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); - disclosureSettleFrameRef.current = null; - disclosureSettleSecondFrameRef.current = null; + foldSettleFrameRef.current = null; + foldSettleSecondFrameRef.current = null; }); }); }, []); - 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; @@ -1994,7 +1975,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) => { switch (entry.type) { @@ -2002,56 +1987,46 @@ 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": // 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], + [expandedWorkRows, workingRowHeight, appearance.baseFontSize], ); const renderItem = useCallback( - (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, - })} - - ), + (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, - disclosureEnteringEntryIds, expandedWorkRows, terminalAssistantMessageIds, unsettledTurnId, @@ -2118,7 +2093,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. @@ -2173,8 +2148,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 1883947c0486..000000000000 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { - isLiquidGlassSupported, - LiquidGlassContainerView, - LiquidGlassView, -} from "@callstack/liquid-glass"; -import { useEffect, useState } from "react"; -import { 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 { ControlPill } from "../../components/ControlPill"; - -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; - -const UniwindLiquidGlassView = withUniwind(LiquidGlassView); -const UniwindLiquidGlassContainerView = withUniwind(LiquidGlassContainerView); -const AnimatedLiquidGlassView = Animated.createAnimatedComponent(UniwindLiquidGlassView); - -export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; - -export function FloatingWorkingControl(props: { - readonly colorScheme: "light" | "dark"; - readonly startedAt: string | 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.startedAt === null && !props.showScrollToEnd) { - return null; - } - - return ( - - {props.startedAt !== null && isLiquidGlassSupported ? ( - - - - - - - - - - - - ) : props.startedAt !== null ? ( - - - - - - - - - - ) : isLiquidGlassSupported ? ( - - - - ) : ( - - )} - - ); -} - -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/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a316d9daee7c..192d82f92cbf 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,201 +1,30 @@ import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; -import { MaskedView } from "@expo/ui/community/masked-view"; -import { useIsFocused } from "@react-navigation/native"; -import { useEffect, useId, useState, type ComponentProps } from "react"; -import { - AccessibilityInfo, - AppState, - type ColorValue, - Pressable, - ScrollView, - View, -} from "react-native"; -import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; +import { LayoutAnimation, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; -import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation"; -import Animated, { - cancelAnimation, - Easing, - FadeIn, - FadeOut, - LinearTransition, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withDelay, - withRepeat, - withSequence, - withTiming, -} from "react-native-reanimated"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import Animated, { FadeIn } from "react-native-reanimated"; -const SHIMMER_WIDTH = 72; -const SHIMMER_SWEEP_MS = 1_350; -const SHIMMER_PAUSE_MS = 1_450; -const SHIMMER_ICON_AND_GAP_WIDTH = 30; -export const THREAD_DISCLOSURE_TRANSITION_MS = 180; -const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); -const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); -const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); +const WORK_LOG_LAYOUT_ANIMATION = { + duration: 180, + create: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, + update: { type: LayoutAnimation.Types.easeInEaseOut }, + delete: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, +} as const; -function ShimmerWorkContent(props: { - readonly highlighted: boolean; - readonly icon: AppSymbolName; - readonly iconSubtleColor: ColorValue; - readonly label: string; - readonly onTextLayout?: ComponentProps["onTextLayout"]; - readonly showIcon: boolean; -}) { - return ( - - - {props.showIcon ? ( - - ) : null} - - - {props.label} - - - ); -} - -function ShimmeringWorkContent(props: { - readonly icon: AppSymbolName; - readonly iconSubtleColor: ColorValue; - readonly label: string; - readonly showIcon: boolean; -}) { - const [availableWidth, setAvailableWidth] = useState(0); - const [textWidth, setTextWidth] = useState(0); - const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active"); - const [reducedMotion, setReducedMotion] = useState(true); - const screenIsFocused = useIsFocused(); - const progress = useSharedValue(0); - const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`; - const contentWidth = Math.min(availableWidth, SHIMMER_ICON_AND_GAP_WIDTH + Math.ceil(textWidth)); - - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => { - setAppIsActive(state === "active"); - }); - return () => subscription.remove(); - }, []); - - useEffect(() => { - void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion); - const subscription = AccessibilityInfo.addEventListener( - "reduceMotionChanged", - setReducedMotion, - ); - return () => subscription.remove(); - }, []); - - useEffect(() => { - cancelAnimation(progress); - progress.value = 0; - if (contentWidth <= 0 || reducedMotion || !appIsActive || !screenIsFocused) return; - - progress.value = withRepeat( - withSequence( - withTiming(1, { - duration: SHIMMER_SWEEP_MS, - easing: Easing.linear, - reduceMotion: ReduceMotion.Never, - }), - withDelay( - SHIMMER_PAUSE_MS, - withTiming(0, { duration: 0, reduceMotion: ReduceMotion.Never }), - ), - ), - -1, - false, - undefined, - ReduceMotion.Never, - ); - return () => cancelAnimation(progress); - }, [appIsActive, contentWidth, progress, reducedMotion, screenIsFocused]); - - const sweepStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: -SHIMMER_WIDTH + progress.value * (contentWidth + SHIMMER_WIDTH) }], - })); - const counterSweepStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: SHIMMER_WIDTH - progress.value * (contentWidth + SHIMMER_WIDTH) }], - })); - - return ( - setAvailableWidth(event.nativeEvent.layout.width)} - > - setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} - /> - {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( - - - - - - - - - - - - - - - - } - > - - - - - - ) : null} - - ); +function triggerDisclosureFeedback() { + LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION); + void Haptics.selectionAsync(); } function stripShellWrapper(value: string): string { @@ -250,23 +79,44 @@ function isFreshRow(createdAt: string): boolean { return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; } +// Tool-like activities with a neutral status carry no signal worth a row. +export function visibleWorkLogActivities( + activities: ReadonlyArray, +): ReadonlyArray { + return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral")); +} + // Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log // rows are single-line (numberOfLines={1}) inside a min-height that stays -// taller than text-sm at every supported base font size, so row height is -// deterministic. Values mirror the classNames below. A mismatch only costs a -// one-time correction on measure. +// taller than the text at every supported base font size (text-xs reaches +// 23px at the 22pt maximum, under the 32px min-h-8), so row height is +// deterministic. The "work log" label has no such clamp — its height follows +// the scaled text-2xs line height. Values mirror the classNames below — keep +// them in sync; a mismatch only costs a one-time correction on measure. const WORK_ROW_HEIGHT = 32; // min-h-8 const WORK_ROW_GAP = 1; // gap-px +const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) -export function collapsedWorkLogHeight(activities: ReadonlyArray): number { - const rows = activities; +export function collapsedWorkLogHeight( + activities: ReadonlyArray, + baseFontSize: number, +): number { + const rows = visibleWorkLogActivities(activities); if (rows.length === 0) { return 0; } - return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP; + const onlyToolRows = rows.every((row) => row.toolLike); + const headerHeight = + scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING; + return ( + WORK_LOG_BOTTOM_MARGIN + + (onlyToolRows ? 0 : headerHeight) + + rows.length * WORK_ROW_HEIGHT + + (rows.length - 1) * WORK_ROW_GAP + ); } export function ThreadWorkLog(props: { @@ -277,7 +127,7 @@ export function ThreadWorkLog(props: { readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string) => void; }) { - const rows = props.activities.map((activity) => ({ + const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail), })); @@ -286,28 +136,32 @@ export function ThreadWorkLog(props: { return null; } + const onlyToolRows = rows.every((row) => row.toolLike); + return ( + {!onlyToolRows ? ( + + work log + + ) : null} + {rows.map((row) => { const expanded = props.expandedRows[row.id] ?? false; const canExpand = row.canExpand; const fullDetail = expanded ? row.getFullDetail() : null; - const displayText = row.detail ?? row.summary; + const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; - const failed = row.status === "failure"; - const showIcon = !row.groupedToolDetail || iconIsDestructive || failed; return ( { if (canExpand) { - void Haptics.selectionAsync(); + triggerDisclosureFeedback(); props.onToggleRow(row.id); } }} @@ -325,41 +179,29 @@ export function ThreadWorkLog(props: { className="rounded-md px-0.5 py-0 active:bg-subtle" > - {row.live ? ( - + - ) : ( - <> - - {showIcon ? ( - - ) : null} - - - {displayText} - - - )} + + + + + {row.summary} + + {row.detail ? ( + {row.detail} + ) : null} + {props.copiedRowId === row.id ? ( @@ -381,17 +223,28 @@ export function ThreadWorkLog(props: { /> ) : null} + + {row.status ? ( + + ) : null} + {fullDetail ? ( - + - + ) : null} ); @@ -420,24 +273,27 @@ export function ThreadWorkGroupToggle(props: { readonly expanded: boolean; readonly hiddenCount: number; readonly iconSubtleColor: import("react-native").ColorValue; - readonly summary: string; - readonly summaryKind: ToolGroupSummaryKind; - readonly hasFailure: boolean; - readonly shimmer: boolean; + readonly onlyToolActivities: boolean; readonly onToggle: () => void; }) { - const accessibilityLabel = props.hasFailure - ? `${props.summary}, tool call failed` - : props.summary; - const icon = toolGroupSummarySymbolName(props.summaryKind); + const noun = props.onlyToolActivities + ? props.hiddenCount === 1 + ? "tool call" + : "tool calls" + : props.hiddenCount === 1 + ? "log entry" + : "log entries"; + const collapsedLabel = `Show ${props.hiddenCount} previous ${noun}`; + const expandedLabel = props.onlyToolActivities + ? "Show fewer tool calls" + : "Show fewer log entries"; return ( { void Haptics.selectionAsync(); @@ -445,64 +301,22 @@ export function ThreadWorkGroupToggle(props: { }} className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle" > - {props.shimmer ? ( - + - ) : ( - <> - - - - - {props.summary} - - - )} - + + + {props.expanded ? expandedLabel : `+${props.hiddenCount} previous ${noun}`} + ); } - -function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { - switch (kind) { - case "read": - return { ios: "eye", android: "visibility" }; - case "edit": - return { ios: "square.and.pencil", android: "edit" }; - case "command": - return { ios: "terminal", android: "terminal" }; - case "search": - return { ios: "globe", android: "public" }; - case "code-search": - return "magnifyingglass"; - case "other": - return { ios: "wrench", android: "build" }; - case "agent-tool": - return { ios: "sparkles", android: "auto_awesome" }; - case "tone-tool": - return { ios: "bolt", android: "bolt" }; - case "dynamic-tool": - case "update": - case "mixed": - return { ios: "hammer", android: "construction" }; - } -} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 73080b3dfcc6..19194ea3a573 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -411,8 +411,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", @@ -592,7 +592,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", ]); }); @@ -745,20 +745,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", @@ -777,26 +763,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" }], }); - expect( - deriveThreadFeedPresentation( - feed, - thread.latestTurn, - new Set(), - new Set(["work-group:tool-succeeded"]), - ).map((entry) => entry.id), - ).toEqual(["work-toggle:work-group:tool-succeeded", "tool-succeeded", "tool-failed"]); + }); + + 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(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]); }); it("models work-log overflow as list rows", () => { @@ -816,14 +801,6 @@ describe("buildThreadFeed", () => { icon: "command", toolLike: true, status, - workEntry: { - id, - createdAt, - turnId: null, - label: `Tool ${id}`, - command: `command ${id}`, - tone: "tool", - }, }); const feed: ThreadFeedEntry[] = [ { @@ -841,216 +818,26 @@ describe("buildThreadFeed", () => { ]; 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", }); - 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", "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, }); }); - - it("keeps live state on the active uninterrupted tool run", () => { - 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: 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, - ...(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", "success", "completed", "tool", "sudo -u root pnpm test"), - ], - }, - ]; - 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: "Running pnpm", - summaryKind: "command", - live: true, - shimmer: false, - }); - }); - - 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, - }); - }); }); describe("quiet timeline: nested agents", () => { @@ -1086,8 +873,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 f30081dd35b4..6dd8230307a5 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -13,14 +13,6 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import { - normalizeCompactToolLabel, - omitSupersededLifecycleMarkers, - summarizeToolGroup, - toolGroupSummaryKind, - type ToolGroupSummaryKind, -} from "@t3tools/client-runtime/work-log/presentation"; -import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -73,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; @@ -95,14 +85,11 @@ export interface WorkLogEntry { 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; @@ -125,6 +112,11 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | Extract + | { + readonly type: "working"; + readonly id: string; + readonly createdAt: string; + } | { readonly type: "activity-group"; readonly id: string; @@ -140,11 +132,7 @@ export type ThreadFeedEntry = readonly groupId: string; readonly hiddenCount: number; readonly expanded: boolean; - readonly summary: string; - readonly summaryKind: ToolGroupSummaryKind; - readonly hasFailure: boolean; - readonly live: boolean; - readonly shimmer: boolean; + readonly onlyToolActivities: boolean; } | { readonly type: "turn-fold"; @@ -424,16 +412,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); if ( @@ -492,13 +472,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) { @@ -509,78 +488,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" - ) { + if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { return false; } - if (next.sourceActivityKind !== "tool.updated" && next.sourceActivityKind !== "tool.completed") { + if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { return false; } - if (previous.turnId !== next.turnId) { + if (previous.activityKind === "tool.completed") { return false; } - if (previous.sourceActivityKind === "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( @@ -596,13 +527,10 @@ function mergeDerivedWorkLogEntries( 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 } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), @@ -612,7 +540,6 @@ function mergeDerivedWorkLogEntries( ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), - ...(toolCallId ? { toolCallId } : {}), ...(toolData !== undefined ? { toolData } : {}), }; } @@ -629,15 +556,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 ?? ""; @@ -647,6 +568,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; @@ -723,12 +648,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.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"; @@ -1362,14 +1287,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)) { @@ -1381,13 +1302,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) { result.push({ @@ -1400,65 +1314,49 @@ export function deriveThreadFeedPresentation( }); } 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; } - 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, @@ -1467,87 +1365,16 @@ function appendPresentedFeedEntry( 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 latestInProgressActivity = activities.findLast( - (activity) => - isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId, - ); - const live = activeTail || latestInProgressActivity !== undefined; - const latestActivity = activeTail - ? activities.at(-1)! - : (latestInProgressActivity ?? activities.at(-1)!); - const summary = live - ? liveToolActivitySummary(latestActivity) - : activities.length === 1 && !activities[0]!.toolLike - ? activities[0]!.workEntry.label - : summarizeToolGroup(activities.map((activity) => activity.workEntry)); 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), - ), - hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", - live, - shimmer: - isWorking && - latestActivity.lifecycleStatus === "inProgress" && - latestActivity.turnId === unsettledTurnId, + onlyToolActivities: activities.every((activity) => activity.toolLike), }); - if (!expanded) { - return; - } - for (const activity of activities) { - result.push({ - type: "activity-group", - id: activity.id, - createdAt: activity.createdAt, - turnId: activity.turnId, - activities: [ - { - ...activity, - groupedToolDetail: true, - live: - isWorking && - activity.id === latestActivity.id && - activity.lifecycleStatus === "inProgress" && - activity.turnId === unsettledTurnId, - }, - ], - }); - } -} - -function liveToolActivitySummary(activity: ThreadFeedActivity): string { - const command = activity.workEntry.command?.trim(); - if (command) { - const program = commandProgramName(command); - return program ? `Running ${program}` : "Running command"; - } - return activity.detail ?? activity.summary; } /** @@ -1782,8 +1609,6 @@ export function buildThreadFeed( icon: workEntryIcon(entry), toolLike: workLogEntryIsToolLike(entry), status: workEntryStatus(entry), - ...(entry.toolLifecycleStatus ? { lifecycleStatus: entry.toolLifecycleStatus } : {}), - workEntry: entry, }, }; }), 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/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11fbe..8a4dc5819923 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -40,6 +40,7 @@ import { MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; +import { ComposerSurface } from "./chat/ComposerSurface"; interface BranchToolbarProps { environmentId: EnvironmentId; @@ -466,10 +467,9 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
{isMobile && showGitControls ? ( ) : null} -
+ ); }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5ff86bb2381b..6586c62d2f13 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -32,7 +32,6 @@ import { resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, - shoulderTabReserve, startNewThreadForProject, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, @@ -46,24 +45,6 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; -describe("shoulderTabReserve", () => { - it("ignores the top drawer when measuring the shoulder tab band", () => { - const elementAt = (top: number) => ({ getBoundingClientRect: () => ({ top }) }) as HTMLElement; - const elements = new Map([ - ['[data-chat-composer-form="true"]', elementAt(20)], - [".chat-composer-shoulder-tab", elementAt(100)], - ['[data-chat-composer-main-surface="true"]', elementAt(128)], - ]); - const overlay = { - querySelector: (selector: string) => elements.get(selector) ?? null, - } as HTMLElement; - - expect(shoulderTabReserve(overlay)).toBe(28); - elements.set(".chat-composer-tasks-tab", elementAt(100)); - expect(shoulderTabReserve(overlay)).toBe(0); - }); -}); - 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 25a49f3efa2c..b435e7021c9b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -39,17 +39,6 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); -export function shoulderTabReserve(overlay: HTMLElement): number { - if (overlay.querySelector(".chat-composer-tasks-tab")) return 0; - const tab = overlay.querySelector(".chat-composer-shoulder-tab"); - const surface = overlay.querySelector('[data-chat-composer-main-surface="true"]'); - if (!tab || !surface) return 0; - return Math.max( - 0, - Math.round(surface.getBoundingClientRect().top - tab.getBoundingClientRect().top), - ); -} - export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a027a1848b30..7f953cfd8300 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -183,6 +183,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + InfoIcon, Minimize2Icon, PaperclipIcon, WifiOffIcon, @@ -316,14 +317,14 @@ import { threadChangeRequestSnapshotsAtom, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; -import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { ComposerSurface } from "./chat/ComposerSurface"; import { hasAvailableClaudeCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; -import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, @@ -350,7 +351,6 @@ import { shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, - shoulderTabReserve, shouldShowPlanFollowUpPrompt, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -401,10 +401,13 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; +import { ServerUpdateAction } from "./ServerUpdateAction"; +import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus"; import { buildVersionMismatchDismissalKey, + dismissServerUpdateFailure, dismissVersionMismatch, + isServerUpdateFailureDismissed, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, @@ -2156,6 +2159,12 @@ function ChatViewContent(props: ChatViewProps) { const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); + const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< + typeof serverUpdateState | null + >(null); + const serverUpdateFailureDismissed = + serverUpdateState === dismissedServerUpdateState || + isServerUpdateFailureDismissed(serverUpdateState); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; @@ -2183,8 +2192,8 @@ function ChatViewContent(props: ChatViewProps) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", - // Live connection status: calm styling, but it must front the stack. - urgent: true, + // Prioritize live connection progress among the notices. + priority: "urgent", icon: (