| author | |
| committer | |
| log | 22f021ed697f4021462fcbc65fad31e4c5c77ce0 |
| tree | 05d654550b63dba2be0c235d49030f459d27fa6e |
| parent | a4964b3b363dc515fad04458e5936cd32053c659 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>21 files changed, 586 insertions(+), 434 deletions(-)
apps/mobile/package.json+3-3| ... | @@ -44,7 +44,7 @@ | ... | @@ -44,7 +44,7 @@ |
| 44 | "@effect/atom-react": "catalog:", | 44 | "@effect/atom-react": "catalog:", |
| 45 | "@expo-google-fonts/dm-sans": "^0.4.2", | 45 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 46 | "@expo/ui": "~56.0.8", | 46 | "@expo/ui": "~56.0.8", |
| 47 | "@legendapp/list": "3.0.0-beta.44", | 47 | "@legendapp/list": "3.2.0", |
| 48 | "@noble/curves": "catalog:", | 48 | "@noble/curves": "catalog:", |
| 49 | "@noble/hashes": "catalog:", | 49 | "@noble/hashes": "catalog:", |
| 50 | "@pierre/diffs": "catalog:", | 50 | "@pierre/diffs": "catalog:", |
| ... | @@ -93,9 +93,9 @@ | ... | @@ -93,9 +93,9 @@ |
| 93 | "react-native": "0.85.3", | 93 | "react-native": "0.85.3", |
| 94 | "react-native-gesture-handler": "~2.31.1", | 94 | "react-native-gesture-handler": "~2.31.1", |
| 95 | "react-native-image-viewing": "^0.2.2", | 95 | "react-native-image-viewing": "^0.2.2", |
| 96 | "react-native-keyboard-controller": "1.21.6", | 96 | "react-native-keyboard-controller": "1.21.7", |
| 97 | "react-native-nitro-markdown": "^0.5.0", | 97 | "react-native-nitro-markdown": "^0.5.0", |
| 98 | "react-native-nitro-modules": "^0.35.4", | 98 | "react-native-nitro-modules": "0.35.9", |
| 99 | "react-native-reanimated": "4.3.1", | 99 | "react-native-reanimated": "4.3.1", |
| 100 | "react-native-safe-area-context": "~5.7.0", | 100 | "react-native-safe-area-context": "~5.7.0", |
| 101 | "react-native-screens": "4.25.2", | 101 | "react-native-screens": "4.25.2", |
apps/mobile/src/features/threads/ThreadComposer.tsx+3-4| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; |
| 2 | import type { | 2 | import type { |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | MessageId, | ||
| 4 | ModelSelection, | 5 | ModelSelection, |
| 5 | OrchestrationThreadShell, | 6 | OrchestrationThreadShell, |
| 6 | ProviderInteractionMode, | 7 | ProviderInteractionMode, |
| ... | @@ -91,7 +92,7 @@ export interface ThreadComposerProps { | ... | @@ -91,7 +92,7 @@ export interface ThreadComposerProps { |
| 91 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 92 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 92 | readonly onRemoveDraftImage: (imageId: string) => void; | 93 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 93 | readonly onStopThread: () => void; | 94 | readonly onStopThread: () => void; |
| 94 | readonly onSendMessage: () => Promise<void>; | 95 | readonly onSendMessage: () => Promise<MessageId | null>; |
| 95 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; | 96 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; |
| 96 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; | 97 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| 97 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; | 98 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; |
| ... | @@ -447,9 +448,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -447,9 +448,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 447 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; | 448 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; |
| 448 | 449 | ||
| 449 | const handleSend = useCallback(() => { | 450 | const handleSend = useCallback(() => { |
| 450 | void onSendMessage().then(() => { | 451 | void onSendMessage(); |
| 451 | inputRef.current?.blur(); | ||
| 452 | }); | ||
| 453 | }, [onSendMessage]); | 452 | }, [onSendMessage]); |
| 454 | const handleCommandSelect = useCallback( | 453 | const handleCommandSelect = useCallback( |
| 455 | (item: ComposerCommandItem) => { | 454 | (item: ComposerCommandItem) => { |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+90-24| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | 1 | import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 2 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; | ||
| 3 | import type { LegendListRef } from "@legendapp/list/react-native"; | ||
| 2 | import type { | 4 | import type { |
| 3 | ApprovalRequestId, | 5 | ApprovalRequestId, |
| 4 | EnvironmentId, | 6 | EnvironmentId, |
| 7 | MessageId, | ||
| 5 | ModelSelection, | 8 | ModelSelection, |
| 6 | OrchestrationThreadShell, | 9 | OrchestrationThreadShell, |
| 7 | ProviderApprovalDecision, | 10 | ProviderApprovalDecision, |
| ... | @@ -13,8 +16,8 @@ import type { | ... | @@ -13,8 +16,8 @@ import type { |
| 13 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 16 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 14 | import * as Haptics from "expo-haptics"; | 17 | import * as Haptics from "expo-haptics"; |
| 15 | import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; | 18 | import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; |
| 16 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 17 | import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; | 20 | import { View, type GestureResponderEvent } from "react-native"; |
| 18 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | 21 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; |
| 19 | import { KeyboardStickyView } from "react-native-keyboard-controller"; | 22 | import { KeyboardStickyView } from "react-native-keyboard-controller"; |
| 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -25,7 +28,7 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | ... | @@ -25,7 +28,7 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 25 | import type { StatusTone } from "../../components/StatusPill"; | 28 | import type { StatusTone } from "../../components/StatusPill"; |
| 26 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 29 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 27 | import type { LayoutVariant } from "../../lib/layout"; | 30 | import type { LayoutVariant } from "../../lib/layout"; |
| 28 | import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; | 31 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 29 | import type { | 32 | import type { |
| 30 | PendingApproval, | 33 | PendingApproval, |
| 31 | PendingUserInput, | 34 | PendingUserInput, |
| ... | @@ -73,7 +76,7 @@ export interface ThreadDetailScreenProps { | ... | @@ -73,7 +76,7 @@ export interface ThreadDetailScreenProps { |
| 73 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 76 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 74 | readonly onRemoveDraftImage: (imageId: string) => void; | 77 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 75 | readonly onStopThread: () => void; | 78 | readonly onStopThread: () => void; |
| 76 | readonly onSendMessage: () => Promise<void>; | 79 | readonly onSendMessage: () => Promise<MessageId | null>; |
| 77 | readonly onReconnectEnvironment: () => void; | 80 | readonly onReconnectEnvironment: () => void; |
| 78 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; | 81 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; |
| 79 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; | 82 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; |
| ... | @@ -206,25 +209,33 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -206,25 +209,33 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 206 | const insets = useSafeAreaInsets(); | 209 | const insets = useSafeAreaInsets(); |
| 207 | const headerHeight = useHeaderHeight(); | 210 | const headerHeight = useHeaderHeight(); |
| 208 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 211 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 209 | const composerRef = useRef<ComposerEditorHandle>(null); | 212 | const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); |
| 213 | const composerEditorRef = useRef<ComposerEditorHandle>(null); | ||
| 214 | const composerOverlayRef = useRef<View>(null); | ||
| 215 | const listRef = useRef<LegendListRef>(null); | ||
| 210 | const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); | 216 | const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); |
| 217 | const selectedThreadKeyRef = useRef(selectedThreadKey); | ||
| 218 | const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null); | ||
| 211 | const [composerExpanded, setComposerExpanded] = useState(false); | 219 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 220 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); | ||
| 212 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); | 221 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); |
| 222 | const contentPresentationKind = props.contentPresentation.kind; | ||
| 223 | const selectedThreadFeed = props.selectedThreadFeed; | ||
| 213 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 224 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 214 | const composerOverlapHeight = composerChrome + composerBottomInset; | 225 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 215 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; | 226 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; |
| 216 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; | 227 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; |
| 217 | const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); | 228 | const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( |
| 229 | listRef, | ||
| 230 | composerOverlayRef, | ||
| 231 | estimatedOverlayHeight, | ||
| 232 | ); | ||
| 233 | const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); | ||
| 218 | const showContent = props.showContent ?? true; | 234 | const showContent = props.showContent ?? true; |
| 219 | const layoutVariant = props.layoutVariant ?? "compact"; | 235 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 220 | const isSplitLayout = layoutVariant === "split"; | 236 | const isSplitLayout = layoutVariant === "split"; |
| 221 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; | 237 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; |
| 222 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); | 238 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 223 | const feedBottomInset = resolveThreadFeedBottomInset({ | ||
| 224 | estimatedOverlayHeight, | ||
| 225 | measuredOverlayHeight, | ||
| 226 | gap: 8, | ||
| 227 | }); | ||
| 228 | const selectedProviderSkills = useMemo( | 239 | const selectedProviderSkills = useMemo( |
| 229 | () => | 240 | () => |
| 230 | props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) | 241 | props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) |
| ... | @@ -254,15 +265,67 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -254,15 +265,67 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 254 | [completeDrawerGesture, isSplitLayout], | 265 | [completeDrawerGesture, isSplitLayout], |
| 255 | ); | 266 | ); |
| 256 | 267 | ||
| 257 | const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { | 268 | useLayoutEffect(() => { |
| 258 | const nextHeight = Math.ceil(event.nativeEvent.layout.height); | 269 | selectedThreadKeyRef.current = selectedThreadKey; |
| 259 | setMeasuredOverlayHeight((current) => | 270 | }, [selectedThreadKey]); |
| 260 | Math.abs(current - nextHeight) > 1 ? nextHeight : current, | 271 | |
| 261 | ); | 272 | useEffect(() => { |
| 262 | }, []); | 273 | setAnchorMessageId(null); |
| 274 | lastScrolledAnchorMessageIdRef.current = null; | ||
| 275 | freeze.set(false); | ||
| 276 | }, [freeze, selectedThreadKey]); | ||
| 277 | |||
| 278 | useEffect(() => { | ||
| 279 | if ( | ||
| 280 | anchorMessageId === null || | ||
| 281 | lastScrolledAnchorMessageIdRef.current === anchorMessageId || | ||
| 282 | contentPresentationKind !== "ready" || | ||
| 283 | !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) | ||
| 284 | ) { | ||
| 285 | return; | ||
| 286 | } | ||
| 287 | |||
| 288 | const targetThreadKey = selectedThreadKey; | ||
| 289 | const frame = requestAnimationFrame(() => { | ||
| 290 | if (selectedThreadKeyRef.current !== targetThreadKey) { | ||
| 291 | return; | ||
| 292 | } | ||
| 293 | lastScrolledAnchorMessageIdRef.current = anchorMessageId; | ||
| 294 | void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { | ||
| 295 | if ( | ||
| 296 | selectedThreadKeyRef.current !== targetThreadKey || | ||
| 297 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | ||
| 298 | ) { | ||
| 299 | return; | ||
| 300 | } | ||
| 301 | lastScrolledAnchorMessageIdRef.current = null; | ||
| 302 | freeze.set(false); | ||
| 303 | }); | ||
| 304 | }); | ||
| 305 | return () => cancelAnimationFrame(frame); | ||
| 306 | }, [ | ||
| 307 | anchorMessageId, | ||
| 308 | freeze, | ||
| 309 | contentPresentationKind, | ||
| 310 | selectedThreadFeed, | ||
| 311 | scrollMessageToEnd, | ||
| 312 | selectedThreadKey, | ||
| 313 | ]); | ||
| 314 | |||
| 315 | const handleSendMessage = useCallback(async () => { | ||
| 316 | const targetThreadKey = selectedThreadKey; | ||
| 317 | const messageId = await props.onSendMessage(); | ||
| 318 | if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { | ||
| 319 | return messageId; | ||
| 320 | } | ||
| 321 | |||
| 322 | setAnchorMessageId(messageId); | ||
| 323 | composerEditorRef.current?.blur(); | ||
| 324 | return messageId; | ||
| 325 | }, [props.onSendMessage, selectedThreadKey]); | ||
| 263 | 326 | ||
| 264 | const collapseComposer = useCallback(() => { | 327 | const collapseComposer = useCallback(() => { |
| 265 | composerRef.current?.blur(); | 328 | composerEditorRef.current?.blur(); |
| 266 | }, []); | 329 | }, []); |
| 267 | 330 | ||
| 268 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { | 331 | const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { |
| ... | @@ -315,10 +378,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -315,10 +378,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 315 | contentPresentation={props.contentPresentation} | 378 | contentPresentation={props.contentPresentation} |
| 316 | agentLabel={agentLabel} | 379 | agentLabel={agentLabel} |
| 317 | latestTurn={props.selectedThread.latestTurn} | 380 | latestTurn={props.selectedThread.latestTurn} |
| 381 | listRef={listRef} | ||
| 382 | freeze={freeze} | ||
| 383 | anchorMessageId={anchorMessageId} | ||
| 384 | contentInsetEndAdjustment={contentInsetEndAdjustment} | ||
| 318 | contentTopInset={headerHeight} | 385 | contentTopInset={headerHeight} |
| 319 | contentBottomInset={feedBottomInset} | 386 | contentBottomInset={estimatedOverlayHeight} |
| 320 | layoutVariant={layoutVariant} | 387 | layoutVariant={layoutVariant} |
| 321 | composerExpanded={composerExpanded} | ||
| 322 | skills={selectedProviderSkills} | 388 | skills={selectedProviderSkills} |
| 323 | /> | 389 | /> |
| 324 | </View> | 390 | </View> |
| ... | @@ -332,7 +398,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -332,7 +398,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 332 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | 398 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 333 | offset={{ closed: 0, opened: 0 }} | 399 | offset={{ closed: 0, opened: 0 }} |
| 334 | > | 400 | > |
| 335 | <View onLayout={handleOverlayLayout}> | 401 | <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}> |
| 336 | {props.activeWorkStartedAt ? ( | 402 | {props.activeWorkStartedAt ? ( |
| 337 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | 403 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> |
| 338 | ) : null} | 404 | ) : null} |
| ... | @@ -361,7 +427,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -361,7 +427,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 361 | ) : null} | 427 | ) : null} |
| 362 | 428 | ||
| 363 | <ThreadComposer | 429 | <ThreadComposer |
| 364 | editorRef={composerRef} | 430 | editorRef={composerEditorRef} |
| 365 | draftMessage={props.draftMessage} | 431 | draftMessage={props.draftMessage} |
| 366 | draftAttachments={props.draftAttachments} | 432 | draftAttachments={props.draftAttachments} |
| 367 | placeholder="Ask the repo agent, or run a command…" | 433 | placeholder="Ask the repo agent, or run a command…" |
| ... | @@ -380,7 +446,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -380,7 +446,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 380 | onNativePasteImages={props.onNativePasteImages} | 446 | onNativePasteImages={props.onNativePasteImages} |
| 381 | onRemoveDraftImage={props.onRemoveDraftImage} | 447 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 382 | onStopThread={props.onStopThread} | 448 | onStopThread={props.onStopThread} |
| 383 | onSendMessage={props.onSendMessage} | 449 | onSendMessage={handleSendMessage} |
| 384 | onReconnectEnvironment={props.onReconnectEnvironment} | 450 | onReconnectEnvironment={props.onReconnectEnvironment} |
| 385 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | 451 | onUpdateModelSelection={props.onUpdateThreadModelSelection} |
| 386 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | 452 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} |
apps/mobile/src/features/threads/ThreadFeed.tsx+62-122| ... | @@ -1,10 +1,20 @@ | ... | @@ -1,10 +1,20 @@ |
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | ||
| 5 | import { SymbolView } from "expo-symbols"; | 6 | import { SymbolView } from "expo-symbols"; |
| 6 | import { useRouter } from "expo-router"; | 7 | import { useRouter } from "expo-router"; |
| 7 | import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 8 | import { |
| 9 | memo, | ||
| 10 | useCallback, | ||
| 11 | useEffect, | ||
| 12 | useMemo, | ||
| 13 | useRef, | ||
| 14 | useState, | ||
| 15 | type ReactNode, | ||
| 16 | type RefObject, | ||
| 17 | } from "react"; | ||
| 8 | import { | 18 | import { |
| 9 | Markdown, | 19 | Markdown, |
| 10 | type CustomRenderers, | 20 | type CustomRenderers, |
| ... | @@ -15,8 +25,6 @@ import { | ... | @@ -15,8 +25,6 @@ import { |
| 15 | ActivityIndicator, | 25 | ActivityIndicator, |
| 16 | Image, | 26 | Image, |
| 17 | Linking, | 27 | Linking, |
| 18 | type NativeScrollEvent, | ||
| 19 | type NativeSyntheticEvent, | ||
| 20 | Pressable, | 28 | Pressable, |
| 21 | ScrollView, | 29 | ScrollView, |
| 22 | StyleSheet, | 30 | StyleSheet, |
| ... | @@ -29,6 +37,7 @@ import { | ... | @@ -29,6 +37,7 @@ import { |
| 29 | import { TouchableOpacity } from "react-native-gesture-handler"; | 37 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 30 | import ImageViewing from "react-native-image-viewing"; | 38 | import ImageViewing from "react-native-image-viewing"; |
| 31 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 39 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 40 | import type { SharedValue } from "react-native-reanimated"; | ||
| 32 | import { useThemeColor } from "../../lib/useThemeColor"; | 41 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 33 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 42 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 34 | import { | 43 | import { |
| ... | @@ -64,14 +73,11 @@ import { | ... | @@ -64,14 +73,11 @@ import { |
| 64 | type ThreadFeedEntry, | 73 | type ThreadFeedEntry, |
| 65 | type ThreadFeedLatestTurn, | 74 | type ThreadFeedLatestTurn, |
| 66 | } from "../../lib/threadActivity"; | 75 | } from "../../lib/threadActivity"; |
| 67 | import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout"; | ||
| 68 | import { relativeTime } from "../../lib/time"; | ||
| 69 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 76 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 70 | import { ThreadWorkLog } from "./thread-work-log"; | 77 | import { ThreadWorkLog } from "./thread-work-log"; |
| 71 | import { useAssetUrl } from "../../state/assets"; | 78 | import { useAssetUrl } from "../../state/assets"; |
| 72 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | 79 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 73 | 80 | ||
| 74 | const THREAD_FEED_END_THRESHOLD = 80; | ||
| 75 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { | 81 | const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { |
| 76 | hour: "numeric", | 82 | hour: "numeric", |
| 77 | minute: "2-digit", | 83 | minute: "2-digit", |
| ... | @@ -93,10 +99,13 @@ export interface ThreadFeedProps { | ... | @@ -93,10 +99,13 @@ export interface ThreadFeedProps { |
| 93 | readonly contentPresentation: ThreadContentPresentation; | 99 | readonly contentPresentation: ThreadContentPresentation; |
| 94 | readonly agentLabel: string; | 100 | readonly agentLabel: string; |
| 95 | readonly latestTurn: ThreadFeedLatestTurn | null; | 101 | readonly latestTurn: ThreadFeedLatestTurn | null; |
| 102 | readonly listRef: RefObject<LegendListRef | null>; | ||
| 103 | readonly freeze: SharedValue<boolean>; | ||
| 104 | readonly anchorMessageId: MessageId | null; | ||
| 105 | readonly contentInsetEndAdjustment: SharedValue<number>; | ||
| 96 | readonly contentTopInset?: number; | 106 | readonly contentTopInset?: number; |
| 97 | readonly contentBottomInset?: number; | 107 | readonly contentBottomInset?: number; |
| 98 | readonly layoutVariant?: LayoutVariant; | 108 | readonly layoutVariant?: LayoutVariant; |
| 99 | readonly composerExpanded?: boolean; | ||
| 100 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; | 109 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 101 | } | 110 | } |
| 102 | 111 | ||
| ... | @@ -812,30 +821,6 @@ function renderFeedEntry( | ... | @@ -812,30 +821,6 @@ function renderFeedEntry( |
| 812 | ); | 821 | ); |
| 813 | } | 822 | } |
| 814 | 823 | ||
| 815 | if (entry.type === "queued-message") { | ||
| 816 | return ( | ||
| 817 | <View className="mb-5 items-end"> | ||
| 818 | <View | ||
| 819 | className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5 opacity-60" | ||
| 820 | style={{ backgroundColor: userBubbleColor }} | ||
| 821 | > | ||
| 822 | <Text className="font-sans text-base leading-[22px] text-white"> | ||
| 823 | {entry.queuedMessage.text} | ||
| 824 | </Text> | ||
| 825 | {entry.queuedMessage.attachments.length > 0 ? ( | ||
| 826 | <Text className="font-t3-medium text-xs text-white/75"> | ||
| 827 | {entry.queuedMessage.attachments.length} image | ||
| 828 | {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached | ||
| 829 | </Text> | ||
| 830 | ) : null} | ||
| 831 | </View> | ||
| 832 | <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | ||
| 833 | {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} | ||
| 834 | </Text> | ||
| 835 | </View> | ||
| 836 | ); | ||
| 837 | } | ||
| 838 | |||
| 839 | return ( | 824 | return ( |
| 840 | <ThreadWorkLog | 825 | <ThreadWorkLog |
| 841 | activities={entry.activities} | 826 | activities={entry.activities} |
| ... | @@ -1122,17 +1107,12 @@ function ThreadFeedPlaceholder(props: { | ... | @@ -1122,17 +1107,12 @@ function ThreadFeedPlaceholder(props: { |
| 1122 | 1107 | ||
| 1123 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | 1108 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1124 | const router = useRouter(); | 1109 | const router = useRouter(); |
| 1125 | const listRef = useRef<LegendListRef>(null); | ||
| 1126 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 1110 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1127 | const scrollFrameRef = useRef<number | null>(null); | ||
| 1128 | const foldSettleFrameRef = useRef<number | null>(null); | 1111 | const foldSettleFrameRef = useRef<number | null>(null); |
| 1129 | const foldSettleSecondFrameRef = useRef<number | null>(null); | 1112 | const foldSettleSecondFrameRef = useRef<number | null>(null); |
| 1130 | const suppressAutoFollowRef = useRef(false); | ||
| 1131 | const previousLatestTurnRef = useRef(props.latestTurn); | 1113 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1132 | const isNearEndRef = useRef(true); | ||
| 1133 | const initialScrollReadyRef = useRef(false); | ||
| 1134 | const lastContentHeightRef = useRef(0); | ||
| 1135 | const { width: viewportWidth } = useWindowDimensions(); | 1114 | const { width: viewportWidth } = useWindowDimensions(); |
| 1115 | const [foldToggleSettling, setFoldToggleSettling] = useState(false); | ||
| 1136 | const [interactionState, setInteractionState] = useState<{ | 1116 | const [interactionState, setInteractionState] = useState<{ |
| 1137 | readonly copiedRowId: string | null; | 1117 | readonly copiedRowId: string | null; |
| 1138 | readonly expandedWorkGroups: Record<string, boolean>; | 1118 | readonly expandedWorkGroups: Record<string, boolean>; |
| ... | @@ -1214,6 +1194,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1214,6 +1194,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1214 | () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), | 1194 | () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), |
| 1215 | [expandedTurnIds, props.feed, props.latestTurn], | 1195 | [expandedTurnIds, props.feed, props.latestTurn], |
| 1216 | ); | 1196 | ); |
| 1197 | const anchoredEndSpace = useMemo( | ||
| 1198 | () => | ||
| 1199 | resolveChatListAnchoredEndSpace( | ||
| 1200 | presentedFeed, | ||
| 1201 | props.anchorMessageId, | ||
| 1202 | (entry) => (entry.type === "message" ? entry.id : null), | ||
| 1203 | { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET }, | ||
| 1204 | ), | ||
| 1205 | [presentedFeed, props.anchorMessageId, topContentInset], | ||
| 1206 | ); | ||
| 1217 | const terminalAssistantMessageIds = useMemo(() => { | 1207 | const terminalAssistantMessageIds = useMemo(() => { |
| 1218 | const terminalIdsByTurn = new Map<TurnId, string>(); | 1208 | const terminalIdsByTurn = new Map<TurnId, string>(); |
| 1219 | for (const entry of props.feed) { | 1209 | for (const entry of props.feed) { |
| ... | @@ -1229,54 +1219,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1229,54 +1219,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1229 | ? props.latestTurn.turnId | 1219 | ? props.latestTurn.turnId |
| 1230 | : null; | 1220 | : null; |
| 1231 | 1221 | ||
| 1232 | const scrollToEnd = useCallback(() => { | ||
| 1233 | if (scrollFrameRef.current !== null) { | ||
| 1234 | return; | ||
| 1235 | } | ||
| 1236 | scrollFrameRef.current = requestAnimationFrame(() => { | ||
| 1237 | scrollFrameRef.current = null; | ||
| 1238 | listRef.current?.scrollToEnd({ animated: false }); | ||
| 1239 | }); | ||
| 1240 | }, []); | ||
| 1241 | |||
| 1242 | const onListScroll = useCallback( | ||
| 1243 | (event: NativeSyntheticEvent<NativeScrollEvent> | NativeScrollEvent) => { | ||
| 1244 | const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; | ||
| 1245 | const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; | ||
| 1246 | isNearEndRef.current = isThreadFeedNearEnd( | ||
| 1247 | { | ||
| 1248 | contentHeight: contentSize.height, | ||
| 1249 | viewportHeight: layoutMeasurement.height, | ||
| 1250 | offsetY: contentOffset.y, | ||
| 1251 | bottomInset: contentInset.bottom, | ||
| 1252 | }, | ||
| 1253 | THREAD_FEED_END_THRESHOLD, | ||
| 1254 | ); | ||
| 1255 | }, | ||
| 1256 | [], | ||
| 1257 | ); | ||
| 1258 | |||
| 1259 | const onListContentSizeChange = useCallback( | ||
| 1260 | (_width: number, height: number) => { | ||
| 1261 | const contentGrew = height > lastContentHeightRef.current + 0.5; | ||
| 1262 | lastContentHeightRef.current = height; | ||
| 1263 | |||
| 1264 | if ( | ||
| 1265 | initialScrollReadyRef.current && | ||
| 1266 | contentGrew && | ||
| 1267 | isNearEndRef.current && | ||
| 1268 | !suppressAutoFollowRef.current | ||
| 1269 | ) { | ||
| 1270 | scrollToEnd(); | ||
| 1271 | } | ||
| 1272 | }, | ||
| 1273 | [scrollToEnd], | ||
| 1274 | ); | ||
| 1275 | |||
| 1276 | const onListLoad = useCallback(() => { | ||
| 1277 | initialScrollReadyRef.current = true; | ||
| 1278 | }, []); | ||
| 1279 | |||
| 1280 | useEffect(() => { | 1222 | useEffect(() => { |
| 1281 | const previous = previousLatestTurnRef.current; | 1223 | const previous = previousLatestTurnRef.current; |
| 1282 | previousLatestTurnRef.current = props.latestTurn; | 1224 | previousLatestTurnRef.current = props.latestTurn; |
| ... | @@ -1308,9 +1250,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1308,9 +1250,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1308 | if (copyFeedbackTimeoutRef.current) { | 1250 | if (copyFeedbackTimeoutRef.current) { |
| 1309 | clearTimeout(copyFeedbackTimeoutRef.current); | 1251 | clearTimeout(copyFeedbackTimeoutRef.current); |
| 1310 | } | 1252 | } |
| 1311 | if (scrollFrameRef.current !== null) { | ||
| 1312 | cancelAnimationFrame(scrollFrameRef.current); | ||
| 1313 | } | ||
| 1314 | if (foldSettleFrameRef.current !== null) { | 1253 | if (foldSettleFrameRef.current !== null) { |
| 1315 | cancelAnimationFrame(foldSettleFrameRef.current); | 1254 | cancelAnimationFrame(foldSettleFrameRef.current); |
| 1316 | } | 1255 | } |
| ... | @@ -1358,7 +1297,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1358,7 +1297,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1358 | }, []); | 1297 | }, []); |
| 1359 | 1298 | ||
| 1360 | const onToggleTurnFold = useCallback((turnId: TurnId) => { | 1299 | const onToggleTurnFold = useCallback((turnId: TurnId) => { |
| 1361 | suppressAutoFollowRef.current = true; | 1300 | setFoldToggleSettling(true); |
| 1362 | if (foldSettleFrameRef.current !== null) { | 1301 | if (foldSettleFrameRef.current !== null) { |
| 1363 | cancelAnimationFrame(foldSettleFrameRef.current); | 1302 | cancelAnimationFrame(foldSettleFrameRef.current); |
| 1364 | } | 1303 | } |
| ... | @@ -1376,7 +1315,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1376,7 +1315,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1376 | }); | 1315 | }); |
| 1377 | foldSettleFrameRef.current = requestAnimationFrame(() => { | 1316 | foldSettleFrameRef.current = requestAnimationFrame(() => { |
| 1378 | foldSettleSecondFrameRef.current = requestAnimationFrame(() => { | 1317 | foldSettleSecondFrameRef.current = requestAnimationFrame(() => { |
| 1379 | suppressAutoFollowRef.current = false; | 1318 | setFoldToggleSettling(false); |
| 1380 | foldSettleFrameRef.current = null; | 1319 | foldSettleFrameRef.current = null; |
| 1381 | foldSettleSecondFrameRef.current = null; | 1320 | foldSettleSecondFrameRef.current = null; |
| 1382 | }); | 1321 | }); |
| ... | @@ -1458,59 +1397,60 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1458,59 +1397,60 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1458 | ); | 1397 | ); |
| 1459 | } | 1398 | } |
| 1460 | 1399 | ||
| 1461 | if (props.feed.length === 0) { | ||
| 1462 | return ( | ||
| 1463 | <ThreadFeedPlaceholder | ||
| 1464 | title="No conversation yet" | ||
| 1465 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." | ||
| 1466 | topInset={topContentInset} | ||
| 1467 | bottomInset={bottomContentInset} | ||
| 1468 | horizontalPadding={horizontalPadding} | ||
| 1469 | /> | ||
| 1470 | ); | ||
| 1471 | } | ||
| 1472 | |||
| 1473 | return ( | 1400 | return ( |
| 1474 | <> | 1401 | <> |
| 1475 | <View style={{ flex: 1 }}> | 1402 | <View style={{ flex: 1 }}> |
| 1476 | <KeyboardAvoidingLegendList | 1403 | <KeyboardAwareLegendList |
| 1477 | ref={listRef} | 1404 | ref={props.listRef} |
| 1478 | key={props.threadId} | 1405 | key={props.threadId} |
| 1479 | style={{ flex: 1 }} | 1406 | style={{ flex: 1 }} |
| 1480 | automaticallyAdjustsScrollIndicatorInsets={false} | 1407 | automaticallyAdjustsScrollIndicatorInsets={false} |
| 1481 | contentInset={{ top: 0, bottom: 0 }} | ||
| 1482 | scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} | 1408 | scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} |
| 1483 | alignItemsAtEnd | 1409 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 1484 | maintainScrollAtEnd={{ | 1410 | contentInsetEndAdjustment={props.contentInsetEndAdjustment} |
| 1485 | animated: false, | 1411 | freeze={props.freeze} |
| 1486 | on: { | 1412 | maintainScrollAtEnd={ |
| 1487 | dataChange: true, | 1413 | foldToggleSettling |
| 1488 | itemLayout: true, | 1414 | ? false |
| 1489 | layout: true, | 1415 | : { |
| 1490 | }, | 1416 | animated: false, |
| 1491 | }} | 1417 | on: { |
| 1418 | dataChange: true, | ||
| 1419 | itemLayout: true, | ||
| 1420 | layout: true, | ||
| 1421 | }, | ||
| 1422 | } | ||
| 1423 | } | ||
| 1424 | maintainVisibleContentPosition | ||
| 1492 | data={presentedFeed} | 1425 | data={presentedFeed} |
| 1493 | extraData={listAppearanceData} | 1426 | extraData={listAppearanceData} |
| 1494 | renderItem={renderItem} | 1427 | renderItem={renderItem} |
| 1495 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} | 1428 | keyExtractor={(entry) => entry.id} |
| 1496 | getItemType={(entry) => | 1429 | getItemType={(entry) => |
| 1497 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | 1430 | entry.type === "message" ? `message:${entry.message.role}` : entry.type |
| 1498 | } | 1431 | } |
| 1499 | keyboardShouldPersistTaps="always" | 1432 | keyboardShouldPersistTaps="always" |
| 1500 | keyboardDismissMode="none" | 1433 | keyboardDismissMode="none" |
| 1434 | keyboardLiftBehavior="whenAtEnd" | ||
| 1501 | estimatedItemSize={180} | 1435 | estimatedItemSize={180} |
| 1502 | initialScrollAtEnd | 1436 | initialScrollAtEnd |
| 1503 | onContentSizeChange={onListContentSizeChange} | ||
| 1504 | onLoad={onListLoad} | ||
| 1505 | onScroll={onListScroll} | ||
| 1506 | scrollEventThrottle={16} | ||
| 1507 | ListHeaderComponent={<View style={{ height: topContentInset }} />} | 1437 | ListHeaderComponent={<View style={{ height: topContentInset }} />} |
| 1508 | contentContainerStyle={{ | 1438 | contentContainerStyle={{ |
| 1509 | paddingTop: 12, | 1439 | paddingTop: 12, |
| 1510 | paddingBottom: bottomContentInset, | ||
| 1511 | paddingHorizontal: horizontalPadding, | 1440 | paddingHorizontal: horizontalPadding, |
| 1512 | }} | 1441 | }} |
| 1513 | /> | 1442 | /> |
| 1443 | {props.feed.length === 0 ? ( | ||
| 1444 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> | ||
| 1445 | <ThreadFeedPlaceholder | ||
| 1446 | title="No conversation yet" | ||
| 1447 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." | ||
| 1448 | topInset={topContentInset} | ||
| 1449 | bottomInset={bottomContentInset} | ||
| 1450 | horizontalPadding={horizontalPadding} | ||
| 1451 | /> | ||
| 1452 | </View> | ||
| 1453 | ) : null} | ||
| 1514 | </View> | 1454 | </View> |
| 1515 | 1455 | ||
| 1516 | <ImageViewing | 1456 | <ImageViewing |
apps/mobile/src/lib/threadActivity.test.ts+6-6| ... | @@ -86,7 +86,7 @@ describe("buildThreadFeed", () => { | ... | @@ -86,7 +86,7 @@ describe("buildThreadFeed", () => { |
| 86 | ], | 86 | ], |
| 87 | }); | 87 | }); |
| 88 | 88 | ||
| 89 | const feed = buildThreadFeed(thread, [], null); | 89 | const feed = buildThreadFeed(thread); |
| 90 | expect(feed).toMatchObject([ | 90 | expect(feed).toMatchObject([ |
| 91 | { | 91 | { |
| 92 | type: "activity-group", | 92 | type: "activity-group", |
| ... | @@ -144,7 +144,7 @@ describe("buildThreadFeed", () => { | ... | @@ -144,7 +144,7 @@ describe("buildThreadFeed", () => { |
| 144 | ], | 144 | ], |
| 145 | }); | 145 | }); |
| 146 | 146 | ||
| 147 | const feed = buildThreadFeed(thread, [], null); | 147 | const feed = buildThreadFeed(thread); |
| 148 | const group = feed[0]; | 148 | const group = feed[0]; |
| 149 | 149 | ||
| 150 | expect(group).toMatchObject({ | 150 | expect(group).toMatchObject({ |
| ... | @@ -209,7 +209,7 @@ describe("buildThreadFeed", () => { | ... | @@ -209,7 +209,7 @@ describe("buildThreadFeed", () => { |
| 209 | ], | 209 | ], |
| 210 | }); | 210 | }); |
| 211 | 211 | ||
| 212 | const group = buildThreadFeed(thread, [], null)[0]; | 212 | const group = buildThreadFeed(thread)[0]; |
| 213 | expect(group).toMatchObject({ type: "activity-group" }); | 213 | expect(group).toMatchObject({ type: "activity-group" }); |
| 214 | if (!group || group.type !== "activity-group") { | 214 | if (!group || group.type !== "activity-group") { |
| 215 | return; | 215 | return; |
| ... | @@ -271,7 +271,7 @@ describe("buildThreadFeed", () => { | ... | @@ -271,7 +271,7 @@ describe("buildThreadFeed", () => { |
| 271 | ], | 271 | ], |
| 272 | }); | 272 | }); |
| 273 | 273 | ||
| 274 | const feed = buildThreadFeed(thread, [], null); | 274 | const feed = buildThreadFeed(thread); |
| 275 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | 275 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); |
| 276 | expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); | 276 | expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); |
| 277 | expect(collapsed[0]).toMatchObject({ | 277 | expect(collapsed[0]).toMatchObject({ |
| ... | @@ -359,7 +359,7 @@ describe("buildThreadFeed", () => { | ... | @@ -359,7 +359,7 @@ describe("buildThreadFeed", () => { |
| 359 | ], | 359 | ], |
| 360 | }); | 360 | }); |
| 361 | 361 | ||
| 362 | const feed = buildThreadFeed(thread, [], null); | 362 | const feed = buildThreadFeed(thread); |
| 363 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | 363 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); |
| 364 | expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ | 364 | expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ |
| 365 | turnId: firstTurnId, | 365 | turnId: firstTurnId, |
| ... | @@ -399,7 +399,7 @@ describe("buildThreadFeed", () => { | ... | @@ -399,7 +399,7 @@ describe("buildThreadFeed", () => { |
| 399 | ], | 399 | ], |
| 400 | }); | 400 | }); |
| 401 | 401 | ||
| 402 | const feed = buildThreadFeed(thread, [], null); | 402 | const feed = buildThreadFeed(thread); |
| 403 | expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); | 403 | expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); |
| 404 | expect(feed[0]).toMatchObject({ | 404 | expect(feed[0]).toMatchObject({ |
| 405 | type: "activity-group", | 405 | type: "activity-group", |
apps/mobile/src/lib/threadActivity.ts+1-19| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; | 1 | import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; |
| 2 | import type { | 2 | import type { |
| 3 | MessageId, | ||
| 4 | OrchestrationLatestTurn, | 3 | OrchestrationLatestTurn, |
| 5 | OrchestrationThread, | 4 | OrchestrationThread, |
| 6 | OrchestrationThreadActivity, | 5 | OrchestrationThreadActivity, |
| ... | @@ -10,7 +9,6 @@ import type { | ... | @@ -10,7 +9,6 @@ import type { |
| 10 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 11 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 10 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 12 | 11 | ||
| 13 | import type { QueuedThreadMessage } from "../state/thread-outbox-model"; | ||
| 14 | import * as Arr from "effect/Array"; | 12 | import * as Arr from "effect/Array"; |
| 15 | import * as Order from "effect/Order"; | 13 | import * as Order from "effect/Order"; |
| 16 | 14 | ||
| ... | @@ -88,13 +86,6 @@ type RawThreadFeedEntry = | ... | @@ -88,13 +86,6 @@ type RawThreadFeedEntry = |
| 88 | readonly createdAt: string; | 86 | readonly createdAt: string; |
| 89 | readonly message: OrchestrationThread["messages"][number]; | 87 | readonly message: OrchestrationThread["messages"][number]; |
| 90 | } | 88 | } |
| 91 | | { | ||
| 92 | readonly type: "queued-message"; | ||
| 93 | readonly id: string; | ||
| 94 | readonly createdAt: string; | ||
| 95 | readonly queuedMessage: QueuedThreadMessage; | ||
| 96 | readonly sending: boolean; | ||
| 97 | } | ||
| 98 | | { | 89 | | { |
| 99 | readonly type: "activity"; | 90 | readonly type: "activity"; |
| 100 | readonly id: string; | 91 | readonly id: string; |
| ... | @@ -104,7 +95,7 @@ type RawThreadFeedEntry = | ... | @@ -104,7 +95,7 @@ type RawThreadFeedEntry = |
| 104 | }; | 95 | }; |
| 105 | 96 | ||
| 106 | export type ThreadFeedEntry = | 97 | export type ThreadFeedEntry = |
| 107 | | Extract<RawThreadFeedEntry, { type: "message" | "queued-message" }> | 98 | | Extract<RawThreadFeedEntry, { type: "message" }> |
| 108 | | { | 99 | | { |
| 109 | readonly type: "activity-group"; | 100 | readonly type: "activity-group"; |
| 110 | readonly id: string; | 101 | readonly id: string; |
| ... | @@ -1255,8 +1246,6 @@ export function buildPendingUserInputAnswers( | ... | @@ -1255,8 +1246,6 @@ export function buildPendingUserInputAnswers( |
| 1255 | 1246 | ||
| 1256 | export function buildThreadFeed( | 1247 | export function buildThreadFeed( |
| 1257 | thread: OrchestrationThread, | 1248 | thread: OrchestrationThread, |
| 1258 | queuedMessages: ReadonlyArray<QueuedThreadMessage>, | ||
| 1259 | dispatchingQueuedMessageId: MessageId | null, | ||
| 1260 | options?: { | 1249 | options?: { |
| 1261 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; | 1250 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; |
| 1262 | }, | 1251 | }, |
| ... | @@ -1273,13 +1262,6 @@ export function buildThreadFeed( | ... | @@ -1273,13 +1262,6 @@ export function buildThreadFeed( |
| 1273 | createdAt: message.createdAt, | 1262 | createdAt: message.createdAt, |
| 1274 | message, | 1263 | message, |
| 1275 | })), | 1264 | })), |
| 1276 | ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({ | ||
| 1277 | type: "queued-message", | ||
| 1278 | id: queuedMessage.messageId, | ||
| 1279 | createdAt: queuedMessage.createdAt, | ||
| 1280 | queuedMessage, | ||
| 1281 | sending: queuedMessage.messageId === dispatchingQueuedMessageId, | ||
| 1282 | })), | ||
| 1283 | ...workLogEntries | 1265 | ...workLogEntries |
| 1284 | .filter((entry) => { | 1266 | .filter((entry) => { |
| 1285 | if (options?.loadedMessages === undefined) { | 1267 | if (options?.loadedMessages === undefined) { |
apps/mobile/src/lib/threadFeedLayout.test.ts deleted-32| ... | @@ -1,32 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | isThreadFeedNearEnd, | ||
| 5 | resolveThreadFeedBottomInset, | ||
| 6 | threadFeedDistanceFromEnd, | ||
| 7 | } from "./threadFeedLayout"; | ||
| 8 | |||
| 9 | describe("thread feed layout", () => { | ||
| 10 | it("accounts for the bottom inset when measuring distance from the end", () => { | ||
| 11 | const metrics = { | ||
| 12 | contentHeight: 900, | ||
| 13 | viewportHeight: 600, | ||
| 14 | offsetY: 380, | ||
| 15 | bottomInset: 100, | ||
| 16 | }; | ||
| 17 | |||
| 18 | expect(threadFeedDistanceFromEnd(metrics)).toBe(20); | ||
| 19 | expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); | ||
| 20 | expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("does not double count chrome already included in the measured composer overlay", () => { | ||
| 24 | expect( | ||
| 25 | resolveThreadFeedBottomInset({ | ||
| 26 | estimatedOverlayHeight: 162, | ||
| 27 | measuredOverlayHeight: 182, | ||
| 28 | gap: 8, | ||
| 29 | }), | ||
| 30 | ).toBe(190); | ||
| 31 | }); | ||
| 32 | }); | ||
apps/mobile/src/lib/threadFeedLayout.ts deleted-22| ... | @@ -1,22 +0,0 @@ | ||
| 1 | export interface ThreadFeedScrollMetrics { | ||
| 2 | readonly contentHeight: number; | ||
| 3 | readonly viewportHeight: number; | ||
| 4 | readonly offsetY: number; | ||
| 5 | readonly bottomInset: number; | ||
| 6 | } | ||
| 7 | |||
| 8 | export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { | ||
| 9 | return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { | ||
| 13 | return threadFeedDistanceFromEnd(metrics) <= threshold; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function resolveThreadFeedBottomInset(input: { | ||
| 17 | readonly estimatedOverlayHeight: number; | ||
| 18 | readonly measuredOverlayHeight: number; | ||
| 19 | readonly gap: number; | ||
| 20 | }): number { | ||
| 21 | return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; | ||
| 22 | } | ||
apps/mobile/src/state/use-thread-composer-state.ts+10-23| ... | @@ -40,7 +40,6 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; | ... | @@ -40,7 +40,6 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; |
| 40 | import { useThreadSelection } from "../state/use-thread-selection"; | 40 | import { useThreadSelection } from "../state/use-thread-selection"; |
| 41 | import { enqueueThreadOutboxMessage } from "./thread-outbox"; | 41 | import { enqueueThreadOutboxMessage } from "./thread-outbox"; |
| 42 | import { useThreadOutboxMessages } from "./use-thread-outbox"; | 42 | import { useThreadOutboxMessages } from "./use-thread-outbox"; |
| 43 | import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain"; | ||
| 44 | 43 | ||
| 45 | export function appendReviewCommentToDraft(input: { | 44 | export function appendReviewCommentToDraft(input: { |
| 46 | readonly environmentId: EnvironmentId; | 45 | readonly environmentId: EnvironmentId; |
| ... | @@ -77,7 +76,6 @@ export function useThreadComposerState() { | ... | @@ -77,7 +76,6 @@ export function useThreadComposerState() { |
| 77 | const { selectedThread: selectedThreadShell } = useThreadSelection(); | 76 | const { selectedThread: selectedThreadShell } = useThreadSelection(); |
| 78 | const selectedThreadDetail = useSelectedThreadDetail(); | 77 | const selectedThreadDetail = useSelectedThreadDetail(); |
| 79 | const composerDrafts = useAtomValue(composerDraftsAtom); | 78 | const composerDrafts = useAtomValue(composerDraftsAtom); |
| 80 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); | ||
| 81 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); | 79 | const queuedMessagesByThreadKey = useThreadOutboxMessages(); |
| 82 | 80 | ||
| 83 | useEffect(() => { | 81 | useEffect(() => { |
| ... | @@ -91,17 +89,9 @@ export function useThreadComposerState() { | ... | @@ -91,17 +89,9 @@ export function useThreadComposerState() { |
| 91 | () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), | 89 | () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), |
| 92 | [queuedMessagesByThreadKey, selectedThreadKey], | 90 | [queuedMessagesByThreadKey, selectedThreadKey], |
| 93 | ); | 91 | ); |
| 94 | |||
| 95 | const selectedThreadFeed = useMemo( | 92 | const selectedThreadFeed = useMemo( |
| 96 | () => | 93 | () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []), |
| 97 | selectedThreadDetail | 94 | [selectedThreadDetail], |
| 98 | ? buildThreadFeed( | ||
| 99 | selectedThreadDetail, | ||
| 100 | selectedThreadQueuedMessages, | ||
| 101 | dispatchingQueuedMessageId, | ||
| 102 | ) | ||
| 103 | : [], | ||
| 104 | [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages], | ||
| 105 | ); | 95 | ); |
| 106 | 96 | ||
| 107 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; | 97 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; |
| ... | @@ -125,7 +115,6 @@ export function useThreadComposerState() { | ... | @@ -125,7 +115,6 @@ export function useThreadComposerState() { |
| 125 | }; | 115 | }; |
| 126 | }, [selectedThreadDetail, selectedThreadShell]); | 116 | }, [selectedThreadDetail, selectedThreadShell]); |
| 127 | 117 | ||
| 128 | const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; | ||
| 129 | const activeWorkStartedAt = useMemo(() => { | 118 | const activeWorkStartedAt = useMemo(() => { |
| 130 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; | 119 | const selectedThread = selectedThreadDetail ?? selectedThreadShell; |
| 131 | if (!selectedThread) { | 120 | if (!selectedThread) { |
| ... | @@ -135,14 +124,9 @@ export function useThreadComposerState() { | ... | @@ -135,14 +124,9 @@ export function useThreadComposerState() { |
| 135 | return deriveActiveWorkStartedAt( | 124 | return deriveActiveWorkStartedAt( |
| 136 | selectedThread.latestTurn, | 125 | selectedThread.latestTurn, |
| 137 | selectedThreadSessionActivity, | 126 | selectedThreadSessionActivity, |
| 138 | queuedSendStartedAt, | 127 | null, |
| 139 | ); | 128 | ); |
| 140 | }, [ | 129 | }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); |
| 141 | queuedSendStartedAt, | ||
| 142 | selectedThreadDetail, | ||
| 143 | selectedThreadSessionActivity, | ||
| 144 | selectedThreadShell, | ||
| 145 | ]); | ||
| 146 | 130 | ||
| 147 | const activeThreadBusy = | 131 | const activeThreadBusy = |
| 148 | !!selectedThread && | 132 | !!selectedThread && |
| ... | @@ -150,7 +134,7 @@ export function useThreadComposerState() { | ... | @@ -150,7 +134,7 @@ export function useThreadComposerState() { |
| 150 | 134 | ||
| 151 | const onSendMessage = useCallback(async () => { | 135 | const onSendMessage = useCallback(async () => { |
| 152 | if (!selectedThreadShell) { | 136 | if (!selectedThreadShell) { |
| 153 | return; | 137 | return null; |
| 154 | } | 138 | } |
| 155 | 139 | ||
| 156 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 140 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| ... | @@ -159,15 +143,16 @@ export function useThreadComposerState() { | ... | @@ -159,15 +143,16 @@ export function useThreadComposerState() { |
| 159 | const text = draft.text.trim(); | 143 | const text = draft.text.trim(); |
| 160 | const attachments = draft.attachments; | 144 | const attachments = draft.attachments; |
| 161 | if (text.length === 0 && attachments.length === 0) { | 145 | if (text.length === 0 && attachments.length === 0) { |
| 162 | return; | 146 | return null; |
| 163 | } | 147 | } |
| 164 | 148 | ||
| 165 | const metadata = makeQueuedMessageMetadata(); | 149 | const metadata = makeQueuedMessageMetadata(); |
| 150 | const messageId = MessageId.make(metadata.messageId); | ||
| 166 | try { | 151 | try { |
| 167 | await enqueueThreadOutboxMessage({ | 152 | await enqueueThreadOutboxMessage({ |
| 168 | environmentId: selectedThreadShell.environmentId, | 153 | environmentId: selectedThreadShell.environmentId, |
| 169 | threadId: selectedThreadShell.id, | 154 | threadId: selectedThreadShell.id, |
| 170 | messageId: MessageId.make(metadata.messageId), | 155 | messageId, |
| 171 | commandId: CommandId.make(metadata.commandId), | 156 | commandId: CommandId.make(metadata.commandId), |
| 172 | text, | 157 | text, |
| 173 | attachments, | 158 | attachments, |
| ... | @@ -177,10 +162,12 @@ export function useThreadComposerState() { | ... | @@ -177,10 +162,12 @@ export function useThreadComposerState() { |
| 177 | createdAt: metadata.createdAt, | 162 | createdAt: metadata.createdAt, |
| 178 | }); | 163 | }); |
| 179 | clearComposerDraftContent(threadKey); | 164 | clearComposerDraftContent(threadKey); |
| 165 | return messageId; | ||
| 180 | } catch (error) { | 166 | } catch (error) { |
| 181 | setPendingConnectionError( | 167 | setPendingConnectionError( |
| 182 | error instanceof Error ? error.message : "Failed to save the queued message.", | 168 | error instanceof Error ? error.message : "Failed to save the queued message.", |
| 183 | ); | 169 | ); |
| 170 | return null; | ||
| 184 | } | 171 | } |
| 185 | }, [selectedThreadDetail, selectedThreadShell]); | 172 | }, [selectedThreadDetail, selectedThreadShell]); |
| 186 | 173 |
apps/web/package.json+1-1| ... | @@ -22,7 +22,7 @@ | ... | @@ -22,7 +22,7 @@ |
| 22 | "@fontsource-variable/dm-sans": "^5.2.8", | 22 | "@fontsource-variable/dm-sans": "^5.2.8", |
| 23 | "@fontsource/jetbrains-mono": "^5.2.8", | 23 | "@fontsource/jetbrains-mono": "^5.2.8", |
| 24 | "@formkit/auto-animate": "^0.9.0", | 24 | "@formkit/auto-animate": "^0.9.0", |
| 25 | "@legendapp/list": "3.0.0-beta.44", | 25 | "@legendapp/list": "3.2.0", |
| 26 | "@lexical/react": "^0.41.0", | 26 | "@lexical/react": "^0.41.0", |
| 27 | "@pierre/diffs": "catalog:", | 27 | "@pierre/diffs": "catalog:", |
| 28 | "@pierre/trees": "1.0.0-beta.4", | 28 | "@pierre/trees": "1.0.0-beta.4", |
apps/web/src/components/BranchToolbarBranchSelector.tsx+9-2| ... | @@ -518,7 +518,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -518,7 +518,7 @@ export function BranchToolbarBranchSelector({ |
| 518 | return; | 518 | return; |
| 519 | } | 519 | } |
| 520 | 520 | ||
| 521 | branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); | 521 | void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); |
| 522 | }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); | 522 | }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); |
| 523 | 523 | ||
| 524 | useEffect(() => { | 524 | useEffect(() => { |
| ... | @@ -628,7 +628,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -628,7 +628,7 @@ export function BranchToolbarBranchSelector({ |
| 628 | if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { | 628 | if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { |
| 629 | return; | 629 | return; |
| 630 | } | 630 | } |
| 631 | branchListRef.current?.scrollIndexIntoView?.({ | 631 | void branchListRef.current?.scrollIndexIntoView?.({ |
| 632 | index: eventDetails.index, | 632 | index: eventDetails.index, |
| 633 | animated: false, | 633 | animated: false, |
| 634 | }); | 634 | }); |
| ... | @@ -696,6 +696,13 @@ export function BranchToolbarBranchSelector({ | ... | @@ -696,6 +696,13 @@ export function BranchToolbarBranchSelector({ |
| 696 | ref={branchListRef} | 696 | ref={branchListRef} |
| 697 | data={filteredBranchPickerItems} | 697 | data={filteredBranchPickerItems} |
| 698 | keyExtractor={(item) => item} | 698 | keyExtractor={(item) => item} |
| 699 | getItemType={(item) => | ||
| 700 | item === checkoutPullRequestItemValue | ||
| 701 | ? "checkout-pull-request" | ||
| 702 | : item === createBranchItemValue | ||
| 703 | ? "create-branch" | ||
| 704 | : "branch" | ||
| 705 | } | ||
| 699 | renderItem={({ item, index }) => renderPickerItem(item, index)} | 706 | renderItem={({ item, index }) => renderPickerItem(item, index)} |
| 700 | estimatedItemSize={28} | 707 | estimatedItemSize={28} |
| 701 | drawDistance={336} | 708 | drawDistance={336} |
apps/web/src/components/ChatView.tsx+178-114| ... | @@ -41,7 +41,17 @@ import { truncate } from "@t3tools/shared/String"; | ... | @@ -41,7 +41,17 @@ import { truncate } from "@t3tools/shared/String"; |
| 41 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | 41 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; |
| 42 | import { Debouncer } from "@tanstack/react-pacer"; | 42 | import { Debouncer } from "@tanstack/react-pacer"; |
| 43 | import { useAtomValue } from "@effect/atom-react"; | 43 | import { useAtomValue } from "@effect/atom-react"; |
| 44 | import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 44 | import { |
| 45 | lazy, | ||
| 46 | memo, | ||
| 47 | Suspense, | ||
| 48 | useCallback, | ||
| 49 | useEffect, | ||
| 50 | useLayoutEffect, | ||
| 51 | useMemo, | ||
| 52 | useRef, | ||
| 53 | useState, | ||
| 54 | } from "react"; | ||
| 45 | import { useNavigate } from "@tanstack/react-router"; | 55 | import { useNavigate } from "@tanstack/react-router"; |
| 46 | import { useShallow } from "zustand/react/shallow"; | 56 | import { useShallow } from "zustand/react/shallow"; |
| 47 | import { | 57 | import { |
| ... | @@ -1137,12 +1147,32 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1137,12 +1147,32 @@ function ChatViewContent(props: ChatViewProps) { |
| 1137 | LastInvokedScriptByProjectSchema, | 1147 | LastInvokedScriptByProjectSchema, |
| 1138 | ); | 1148 | ); |
| 1139 | const legendListRef = useRef<LegendListRef | null>(null); | 1149 | const legendListRef = useRef<LegendListRef | null>(null); |
| 1150 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); | ||
| 1151 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); | ||
| 1140 | const isAtEndRef = useRef(true); | 1152 | const isAtEndRef = useRef(true); |
| 1141 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); | 1153 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 1142 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); | 1154 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| 1143 | const sendInFlightRef = useRef(false); | 1155 | const sendInFlightRef = useRef(false); |
| 1144 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); | 1156 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 1145 | 1157 | ||
| 1158 | useLayoutEffect(() => { | ||
| 1159 | if (!composerOverlayElement) return; | ||
| 1160 | |||
| 1161 | const updateHeight = () => { | ||
| 1162 | const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); | ||
| 1163 | setComposerOverlayHeight((currentHeight) => | ||
| 1164 | currentHeight === nextHeight ? currentHeight : nextHeight, | ||
| 1165 | ); | ||
| 1166 | }; | ||
| 1167 | |||
| 1168 | updateHeight(); | ||
| 1169 | if (typeof ResizeObserver === "undefined") return; | ||
| 1170 | |||
| 1171 | const observer = new ResizeObserver(updateHeight); | ||
| 1172 | observer.observe(composerOverlayElement); | ||
| 1173 | return () => observer.disconnect(); | ||
| 1174 | }, [composerOverlayElement]); | ||
| 1175 | |||
| 1146 | const terminalUiState = useTerminalUiStateStore((state) => | 1176 | const terminalUiState = useTerminalUiStateStore((state) => |
| 1147 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), | 1177 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), |
| 1148 | ); | 1178 | ); |
| ... | @@ -1254,6 +1284,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1254,6 +1284,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 1254 | [activeThread], | 1284 | [activeThread], |
| 1255 | ); | 1285 | ); |
| 1256 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1286 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1287 | const [timelineAnchor, setTimelineAnchor] = useState<{ | ||
| 1288 | readonly threadKey: string | null; | ||
| 1289 | readonly messageId: MessageId | null; | ||
| 1290 | }>({ threadKey: activeThreadKey, messageId: null }); | ||
| 1291 | if (timelineAnchor.threadKey !== activeThreadKey) { | ||
| 1292 | setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); | ||
| 1293 | } | ||
| 1294 | const timelineAnchorMessageId = timelineAnchor.messageId; | ||
| 1257 | const activeRightPanelKind = useRightPanelStore((state) => | 1295 | const activeRightPanelKind = useRightPanelStore((state) => |
| 1258 | selectActiveRightPanel(state.byThreadKey, activeThreadRef), | 1296 | selectActiveRightPanel(state.byThreadKey, activeThreadRef), |
| 1259 | ); | 1297 | ); |
| ... | @@ -3111,7 +3149,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3111,7 +3149,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3111 | 3149 | ||
| 3112 | // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. | 3150 | // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. |
| 3113 | const scrollToEnd = useCallback((animated = false) => { | 3151 | const scrollToEnd = useCallback((animated = false) => { |
| 3114 | legendListRef.current?.scrollToEnd?.({ animated }); | 3152 | void legendListRef.current?.scrollToEnd?.({ animated }); |
| 3115 | }, []); | 3153 | }, []); |
| 3116 | 3154 | ||
| 3117 | // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during | 3155 | // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during |
| ... | @@ -3690,14 +3728,16 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3690,14 +3728,16 @@ function ChatViewContent(props: ChatViewProps) { |
| 3690 | sizeBytes: image.sizeBytes, | 3728 | sizeBytes: image.sizeBytes, |
| 3691 | previewUrl: image.previewUrl, | 3729 | previewUrl: image.previewUrl, |
| 3692 | })); | 3730 | })); |
| 3693 | // Scroll to the current end *before* adding the optimistic message. | 3731 | // Sending always returns to the live edge. The new row becomes the |
| 3694 | // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd | 3732 | // anchored end-space target so it lands near the top while the response |
| 3695 | // automatically pins to the new item when the data changes. | 3733 | // streams into the reserved space below it. |
| 3696 | isAtEndRef.current = true; | 3734 | isAtEndRef.current = true; |
| 3697 | showScrollDebouncer.current.cancel(); | 3735 | showScrollDebouncer.current.cancel(); |
| 3698 | setShowScrollToBottom(false); | 3736 | setShowScrollToBottom(false); |
| 3699 | await legendListRef.current?.scrollToEnd?.({ animated: false }); | 3737 | setTimelineAnchor({ |
| 3700 | 3738 | threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), | |
| 3739 | messageId: messageIdForSend, | ||
| 3740 | }); | ||
| 3701 | setOptimisticUserMessages((existing) => [ | 3741 | setOptimisticUserMessages((existing) => [ |
| 3702 | ...existing, | 3742 | ...existing, |
| 3703 | { | 3743 | { |
| ... | @@ -3711,6 +3751,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3711,6 +3751,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3711 | streaming: false, | 3751 | streaming: false, |
| 3712 | }, | 3752 | }, |
| 3713 | ]); | 3753 | ]); |
| 3754 | void legendListRef.current?.scrollToEnd?.({ animated: false }); | ||
| 3714 | 3755 | ||
| 3715 | setThreadError(threadIdForSend, null); | 3756 | setThreadError(threadIdForSend, null); |
| 3716 | if (expiredTerminalContextCount > 0) { | 3757 | if (expiredTerminalContextCount > 0) { |
| ... | @@ -4722,7 +4763,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4722,7 +4763,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 4722 | {/* Main content area with optional plan sidebar */} | 4763 | {/* Main content area with optional plan sidebar */} |
| 4723 | <div className="flex min-h-0 min-w-0 flex-1"> | 4764 | <div className="flex min-h-0 min-w-0 flex-1"> |
| 4724 | {/* Chat column */} | 4765 | {/* Chat column */} |
| 4725 | <div className="flex min-h-0 min-w-0 flex-1 flex-col"> | 4766 | <div className="relative flex min-h-0 min-w-0 flex-1 flex-col"> |
| 4726 | {/* Messages Wrapper */} | 4767 | {/* Messages Wrapper */} |
| 4727 | <div className="relative flex min-h-0 flex-1 flex-col"> | 4768 | <div className="relative flex min-h-0 flex-1 flex-col"> |
| 4728 | {/* Messages — LegendList handles virtualization and scrolling internally */} | 4769 | {/* Messages — LegendList handles virtualization and scrolling internally */} |
| ... | @@ -4747,12 +4788,17 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4747,12 +4788,17 @@ function ChatViewContent(props: ChatViewProps) { |
| 4747 | timestampFormat={timestampFormat} | 4788 | timestampFormat={timestampFormat} |
| 4748 | workspaceRoot={activeWorkspaceRoot} | 4789 | workspaceRoot={activeWorkspaceRoot} |
| 4749 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | 4790 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} |
| 4791 | anchorMessageId={timelineAnchorMessageId} | ||
| 4792 | contentInsetEndAdjustment={composerOverlayHeight} | ||
| 4750 | onIsAtEndChange={onIsAtEndChange} | 4793 | onIsAtEndChange={onIsAtEndChange} |
| 4751 | /> | 4794 | /> |
| 4752 | 4795 | ||
| 4753 | {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} | 4796 | {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} |
| 4754 | {showScrollToBottom && ( | 4797 | {showScrollToBottom && ( |
| 4755 | <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5"> | 4798 | <div |
| 4799 | className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" | ||
| 4800 | style={{ bottom: composerOverlayHeight + 4 }} | ||
| 4801 | > | ||
| 4756 | <button | 4802 | <button |
| 4757 | type="button" | 4803 | type="button" |
| 4758 | onClick={() => scrollToEnd(true)} | 4804 | onClick={() => scrollToEnd(true)} |
| ... | @@ -4767,115 +4813,133 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4767,115 +4813,133 @@ function ChatViewContent(props: ChatViewProps) { |
| 4767 | 4813 | ||
| 4768 | {/* Input bar */} | 4814 | {/* Input bar */} |
| 4769 | <div | 4815 | <div |
| 4770 | className={cn( | 4816 | ref={setComposerOverlayElement} |
| 4771 | "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2", | 4817 | data-chat-composer-overlay="true" |
| 4772 | isGitRepo | 4818 | className="pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2" |
| 4773 | ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]" | ||
| 4774 | : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", | ||
| 4775 | )} | ||
| 4776 | > | 4819 | > |
| 4777 | <div className="relative isolate"> | 4820 | <div |
| 4778 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> | 4821 | aria-hidden="true" |
| 4779 | <div className="relative z-10"> | 4822 | className="chat-composer-horizontal-inset pointer-events-none absolute inset-x-0 top-1.5 bottom-0 z-0 sm:top-2" |
| 4780 | <ChatComposer | 4823 | > |
| 4781 | composerRef={composerRef} | 4824 | <div className="relative mx-auto h-full w-full max-w-208 overflow-clip rounded-t-[20px]"> |
| 4782 | composerDraftTarget={composerDraftTarget} | 4825 | <div className="chat-composer-shared-blur absolute -inset-8" /> |
| 4783 | environmentId={environmentId} | ||
| 4784 | routeKind={routeKind} | ||
| 4785 | routeThreadRef={routeThreadRef} | ||
| 4786 | draftId={draftId} | ||
| 4787 | activeThreadId={activeThreadId} | ||
| 4788 | activeThreadEnvironmentId={activeThread?.environmentId} | ||
| 4789 | activeThread={activeThread} | ||
| 4790 | isServerThread={isServerThread} | ||
| 4791 | isLocalDraftThread={isLocalDraftThread} | ||
| 4792 | phase={phase} | ||
| 4793 | isConnecting={isConnecting} | ||
| 4794 | isSendBusy={isSendBusy} | ||
| 4795 | isPreparingWorktree={isPreparingWorktree} | ||
| 4796 | environmentUnavailable={activeEnvironmentUnavailableState} | ||
| 4797 | activePendingApproval={activePendingApproval} | ||
| 4798 | pendingApprovals={pendingApprovals} | ||
| 4799 | pendingUserInputs={pendingUserInputs} | ||
| 4800 | activePendingProgress={activePendingProgress} | ||
| 4801 | activePendingResolvedAnswers={activePendingResolvedAnswers} | ||
| 4802 | activePendingIsResponding={activePendingIsResponding} | ||
| 4803 | activePendingDraftAnswers={activePendingDraftAnswers} | ||
| 4804 | activePendingQuestionIndex={activePendingQuestionIndex} | ||
| 4805 | respondingRequestIds={respondingRequestIds} | ||
| 4806 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} | ||
| 4807 | activeProposedPlan={activeProposedPlan} | ||
| 4808 | activePlan={activePlan as { turnId?: TurnId } | null} | ||
| 4809 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | ||
| 4810 | planSidebarLabel={planSidebarLabel} | ||
| 4811 | planSidebarOpen={planSidebarOpen} | ||
| 4812 | runtimeMode={runtimeMode} | ||
| 4813 | interactionMode={interactionMode} | ||
| 4814 | lockedProvider={lockedProvider} | ||
| 4815 | providerStatuses={providerStatuses as ServerProvider[]} | ||
| 4816 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} | ||
| 4817 | activeThreadModelSelection={activeThread?.modelSelection} | ||
| 4818 | activeThreadActivities={activeThread?.activities} | ||
| 4819 | resolvedTheme={resolvedTheme} | ||
| 4820 | settings={settings} | ||
| 4821 | keybindings={keybindings} | ||
| 4822 | terminalOpen={Boolean(terminalUiState.terminalOpen)} | ||
| 4823 | gitCwd={gitCwd} | ||
| 4824 | promptRef={promptRef} | ||
| 4825 | composerImagesRef={composerImagesRef} | ||
| 4826 | composerTerminalContextsRef={composerTerminalContextsRef} | ||
| 4827 | composerElementContextsRef={composerElementContextsRef} | ||
| 4828 | shouldAutoScrollRef={isAtEndRef} | ||
| 4829 | scheduleStickToBottom={scrollToEnd} | ||
| 4830 | onSend={onSend} | ||
| 4831 | onInterrupt={onInterrupt} | ||
| 4832 | onImplementPlanInNewThread={onImplementPlanInNewThread} | ||
| 4833 | onRespondToApproval={onRespondToApproval} | ||
| 4834 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} | ||
| 4835 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} | ||
| 4836 | onPreviousActivePendingUserInputQuestion={ | ||
| 4837 | onPreviousActivePendingUserInputQuestion | ||
| 4838 | } | ||
| 4839 | onChangeActivePendingUserInputCustomAnswer={ | ||
| 4840 | onChangeActivePendingUserInputCustomAnswer | ||
| 4841 | } | ||
| 4842 | onProviderModelSelect={onProviderModelSelect} | ||
| 4843 | getModelDisabledReason={getModelDisabledReason} | ||
| 4844 | toggleInteractionMode={toggleInteractionMode} | ||
| 4845 | handleRuntimeModeChange={handleRuntimeModeChange} | ||
| 4846 | handleInteractionModeChange={handleInteractionModeChange} | ||
| 4847 | togglePlanSidebar={togglePlanSidebar} | ||
| 4848 | focusComposer={focusComposer} | ||
| 4849 | scheduleComposerFocus={scheduleComposerFocus} | ||
| 4850 | setThreadError={setThreadError} | ||
| 4851 | onExpandImage={onExpandTimelineImage} | ||
| 4852 | /> | ||
| 4853 | </div> | 4826 | </div> |
| 4854 | </div> | 4827 | </div> |
| 4855 | {isGitRepo && ( | 4828 | <div className="chat-composer-horizontal-inset"> |
| 4856 | <BranchToolbar | 4829 | <div className="pointer-events-auto relative z-10 isolate"> |
| 4857 | environmentId={activeThread.environmentId} | 4830 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> |
| 4858 | threadId={activeThread.id} | 4831 | <div className="relative z-10"> |
| 4859 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | 4832 | <ChatComposer |
| 4860 | onEnvModeChange={onEnvModeChange} | 4833 | composerRef={composerRef} |
| 4861 | startFromOrigin={startFromOrigin} | 4834 | composerDraftTarget={composerDraftTarget} |
| 4862 | onStartFromOriginChange={onStartFromOriginChange} | 4835 | environmentId={environmentId} |
| 4863 | {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} | 4836 | routeKind={routeKind} |
| 4864 | {...(canOverrideServerThreadEnvMode | 4837 | routeThreadRef={routeThreadRef} |
| 4865 | ? { | 4838 | draftId={draftId} |
| 4866 | activeThreadBranchOverride: activeThreadBranch, | 4839 | activeThreadId={activeThreadId} |
| 4867 | onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, | 4840 | activeThreadEnvironmentId={activeThread?.environmentId} |
| 4841 | activeThread={activeThread} | ||
| 4842 | isServerThread={isServerThread} | ||
| 4843 | isLocalDraftThread={isLocalDraftThread} | ||
| 4844 | phase={phase} | ||
| 4845 | isConnecting={isConnecting} | ||
| 4846 | isSendBusy={isSendBusy} | ||
| 4847 | isPreparingWorktree={isPreparingWorktree} | ||
| 4848 | environmentUnavailable={activeEnvironmentUnavailableState} | ||
| 4849 | activePendingApproval={activePendingApproval} | ||
| 4850 | pendingApprovals={pendingApprovals} | ||
| 4851 | pendingUserInputs={pendingUserInputs} | ||
| 4852 | activePendingProgress={activePendingProgress} | ||
| 4853 | activePendingResolvedAnswers={activePendingResolvedAnswers} | ||
| 4854 | activePendingIsResponding={activePendingIsResponding} | ||
| 4855 | activePendingDraftAnswers={activePendingDraftAnswers} | ||
| 4856 | activePendingQuestionIndex={activePendingQuestionIndex} | ||
| 4857 | respondingRequestIds={respondingRequestIds} | ||
| 4858 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} | ||
| 4859 | activeProposedPlan={activeProposedPlan} | ||
| 4860 | activePlan={activePlan as { turnId?: TurnId } | null} | ||
| 4861 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | ||
| 4862 | planSidebarLabel={planSidebarLabel} | ||
| 4863 | planSidebarOpen={planSidebarOpen} | ||
| 4864 | runtimeMode={runtimeMode} | ||
| 4865 | interactionMode={interactionMode} | ||
| 4866 | lockedProvider={lockedProvider} | ||
| 4867 | providerStatuses={providerStatuses as ServerProvider[]} | ||
| 4868 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} | ||
| 4869 | activeThreadModelSelection={activeThread?.modelSelection} | ||
| 4870 | activeThreadActivities={activeThread?.activities} | ||
| 4871 | resolvedTheme={resolvedTheme} | ||
| 4872 | settings={settings} | ||
| 4873 | keybindings={keybindings} | ||
| 4874 | terminalOpen={Boolean(terminalUiState.terminalOpen)} | ||
| 4875 | gitCwd={gitCwd} | ||
| 4876 | promptRef={promptRef} | ||
| 4877 | composerImagesRef={composerImagesRef} | ||
| 4878 | composerTerminalContextsRef={composerTerminalContextsRef} | ||
| 4879 | composerElementContextsRef={composerElementContextsRef} | ||
| 4880 | onSend={onSend} | ||
| 4881 | onInterrupt={onInterrupt} | ||
| 4882 | onImplementPlanInNewThread={onImplementPlanInNewThread} | ||
| 4883 | onRespondToApproval={onRespondToApproval} | ||
| 4884 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} | ||
| 4885 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} | ||
| 4886 | onPreviousActivePendingUserInputQuestion={ | ||
| 4887 | onPreviousActivePendingUserInputQuestion | ||
| 4868 | } | 4888 | } |
| 4869 | : {})} | 4889 | onChangeActivePendingUserInputCustomAnswer={ |
| 4870 | envLocked={envLocked} | 4890 | onChangeActivePendingUserInputCustomAnswer |
| 4871 | onComposerFocusRequest={scheduleComposerFocus} | 4891 | } |
| 4872 | {...(canCheckoutPullRequestIntoThread | 4892 | onProviderModelSelect={onProviderModelSelect} |
| 4873 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | 4893 | getModelDisabledReason={getModelDisabledReason} |
| 4874 | : {})} | 4894 | toggleInteractionMode={toggleInteractionMode} |
| 4875 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} | 4895 | handleRuntimeModeChange={handleRuntimeModeChange} |
| 4876 | availableEnvironments={logicalProjectEnvironments} | 4896 | handleInteractionModeChange={handleInteractionModeChange} |
| 4877 | /> | 4897 | togglePlanSidebar={togglePlanSidebar} |
| 4878 | )} | 4898 | focusComposer={focusComposer} |
| 4899 | scheduleComposerFocus={scheduleComposerFocus} | ||
| 4900 | setThreadError={setThreadError} | ||
| 4901 | onExpandImage={onExpandTimelineImage} | ||
| 4902 | /> | ||
| 4903 | </div> | ||
| 4904 | </div> | ||
| 4905 | </div> | ||
| 4906 | <div | ||
| 4907 | className={cn( | ||
| 4908 | "chat-composer-horizontal-inset chat-composer-lower-chrome relative z-10", | ||
| 4909 | isGitRepo | ||
| 4910 | ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]" | ||
| 4911 | : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", | ||
| 4912 | )} | ||
| 4913 | > | ||
| 4914 | {isGitRepo && ( | ||
| 4915 | <div className="pointer-events-auto"> | ||
| 4916 | <BranchToolbar | ||
| 4917 | environmentId={activeThread.environmentId} | ||
| 4918 | threadId={activeThread.id} | ||
| 4919 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | ||
| 4920 | onEnvModeChange={onEnvModeChange} | ||
| 4921 | startFromOrigin={startFromOrigin} | ||
| 4922 | onStartFromOriginChange={onStartFromOriginChange} | ||
| 4923 | {...(canOverrideServerThreadEnvMode | ||
| 4924 | ? { effectiveEnvModeOverride: envMode } | ||
| 4925 | : {})} | ||
| 4926 | {...(canOverrideServerThreadEnvMode | ||
| 4927 | ? { | ||
| 4928 | activeThreadBranchOverride: activeThreadBranch, | ||
| 4929 | onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, | ||
| 4930 | } | ||
| 4931 | : {})} | ||
| 4932 | envLocked={envLocked} | ||
| 4933 | onComposerFocusRequest={scheduleComposerFocus} | ||
| 4934 | {...(canCheckoutPullRequestIntoThread | ||
| 4935 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | ||
| 4936 | : {})} | ||
| 4937 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} | ||
| 4938 | availableEnvironments={logicalProjectEnvironments} | ||
| 4939 | /> | ||
| 4940 | </div> | ||
| 4941 | )} | ||
| 4942 | </div> | ||
| 4879 | </div> | 4943 | </div> |
| 4880 | 4944 | ||
| 4881 | {pullRequestDialogState ? ( | 4945 | {pullRequestDialogState ? ( |
apps/web/src/components/chat/ChatComposer.tsx+4-26| ... | @@ -504,10 +504,6 @@ export interface ChatComposerProps { | ... | @@ -504,10 +504,6 @@ export interface ChatComposerProps { |
| 504 | composerElementContextsRef: React.RefObject<ElementContextDraft[]>; | 504 | composerElementContextsRef: React.RefObject<ElementContextDraft[]>; |
| 505 | composerRef: React.RefObject<ChatComposerHandle | null>; | 505 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| 506 | 506 | ||
| 507 | // Scroll | ||
| 508 | shouldAutoScrollRef: React.RefObject<boolean>; | ||
| 509 | scheduleStickToBottom: () => void; | ||
| 510 | |||
| 511 | // Callbacks | 507 | // Callbacks |
| 512 | onSend: (e?: { preventDefault: () => void }) => void; | 508 | onSend: (e?: { preventDefault: () => void }) => void; |
| 513 | onInterrupt: () => void; | 509 | onInterrupt: () => void; |
| ... | @@ -593,8 +589,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -593,8 +589,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 593 | composerImagesRef, | 589 | composerImagesRef, |
| 594 | composerTerminalContextsRef, | 590 | composerTerminalContextsRef, |
| 595 | composerElementContextsRef, | 591 | composerElementContextsRef, |
| 596 | shouldAutoScrollRef, | ||
| 597 | scheduleStickToBottom, | ||
| 598 | onSend, | 592 | onSend, |
| 599 | onInterrupt, | 593 | onInterrupt, |
| 600 | onImplementPlanInNewThread, | 594 | onImplementPlanInNewThread, |
| ... | @@ -896,7 +890,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -896,7 +890,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 896 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | 890 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 897 | const composerFormRef = useRef<HTMLFormElement>(null); | 891 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 898 | const composerSurfaceRef = useRef<HTMLDivElement>(null); | 892 | const composerSurfaceRef = useRef<HTMLDivElement>(null); |
| 899 | const composerFormHeightRef = useRef(0); | ||
| 900 | const composerSelectLockRef = useRef(false); | 893 | const composerSelectLockRef = useRef(false); |
| 901 | const composerMenuOpenRef = useRef(false); | 894 | const composerMenuOpenRef = useRef(false); |
| 902 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); | 895 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); |
| ... | @@ -1329,15 +1322,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1329,15 +1322,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1329 | }; | 1322 | }; |
| 1330 | }; | 1323 | }; |
| 1331 | 1324 | ||
| 1332 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; | ||
| 1333 | const initialCompactness = measureFooterCompactness(); | 1325 | const initialCompactness = measureFooterCompactness(); |
| 1334 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); | 1326 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); |
| 1335 | setIsComposerFooterCompact(initialCompactness.footerCompact); | 1327 | setIsComposerFooterCompact(initialCompactness.footerCompact); |
| 1336 | if (typeof ResizeObserver === "undefined") return; | 1328 | if (typeof ResizeObserver === "undefined") return; |
| 1337 | 1329 | ||
| 1338 | const observer = new ResizeObserver((entries) => { | 1330 | const observer = new ResizeObserver(() => { |
| 1339 | const [entry] = entries; | ||
| 1340 | if (!entry) return; | ||
| 1341 | const nextCompactness = measureFooterCompactness(); | 1331 | const nextCompactness = measureFooterCompactness(); |
| 1342 | setIsComposerPrimaryActionsCompact((previous) => | 1332 | setIsComposerPrimaryActionsCompact((previous) => |
| 1343 | previous === nextCompactness.primaryActionsCompact | 1333 | previous === nextCompactness.primaryActionsCompact |
| ... | @@ -1347,25 +1337,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1347,25 +1337,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1347 | setIsComposerFooterCompact((previous) => | 1337 | setIsComposerFooterCompact((previous) => |
| 1348 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | 1338 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, |
| 1349 | ); | 1339 | ); |
| 1350 | const nextHeight = entry.contentRect.height; | ||
| 1351 | const previousHeight = composerFormHeightRef.current; | ||
| 1352 | composerFormHeightRef.current = nextHeight; | ||
| 1353 | if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; | ||
| 1354 | if (!shouldAutoScrollRef.current) return; | ||
| 1355 | scheduleStickToBottom(); | ||
| 1356 | }); | 1340 | }); |
| 1357 | 1341 | ||
| 1358 | observer.observe(composerForm); | 1342 | observer.observe(composerForm); |
| 1359 | return () => { | 1343 | return () => { |
| 1360 | observer.disconnect(); | 1344 | observer.disconnect(); |
| 1361 | }; | 1345 | }; |
| 1362 | }, [ | 1346 | }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]); |
| 1363 | activeThreadId, | ||
| 1364 | composerFooterActionLayoutKey, | ||
| 1365 | composerFooterHasWideActions, | ||
| 1366 | scheduleStickToBottom, | ||
| 1367 | shouldAutoScrollRef, | ||
| 1368 | ]); | ||
| 1369 | 1347 | ||
| 1370 | // ------------------------------------------------------------------ | 1348 | // ------------------------------------------------------------------ |
| 1371 | // Image persist effect | 1349 | // Image persist effect |
| ... | @@ -2083,8 +2061,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2083,8 +2061,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2083 | ref={composerSurfaceRef} | 2061 | ref={composerSurfaceRef} |
| 2084 | data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} | 2062 | data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} |
| 2085 | className={cn( | 2063 | className={cn( |
| 2086 | "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45", | 2064 | "chat-composer-glass rounded-[20px] border transition-colors duration-200 has-focus-visible:border-ring/45", |
| 2087 | isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", | 2065 | isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", |
| 2088 | environmentUnavailable ? "opacity-75" : null, | 2066 | environmentUnavailable ? "opacity-75" : null, |
| 2089 | composerProviderState.composerSurfaceClassName, | 2067 | composerProviderState.composerSurfaceClassName, |
| 2090 | )} | 2068 | )} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+51-1| ... | @@ -13,9 +13,21 @@ vi.mock("@legendapp/list/react", async () => { | ... | @@ -13,9 +13,21 @@ vi.mock("@legendapp/list/react", async () => { |
| 13 | renderItem: (args: { item: { id: string } }) => ReactNode; | 13 | renderItem: (args: { item: { id: string } }) => ReactNode; |
| 14 | ListHeaderComponent?: ReactNode; | 14 | ListHeaderComponent?: ReactNode; |
| 15 | ListFooterComponent?: ReactNode; | 15 | ListFooterComponent?: ReactNode; |
| 16 | anchoredEndSpace?: { | ||
| 17 | anchorIndex: number; | ||
| 18 | anchorMaxSize?: number; | ||
| 19 | anchorOffset?: number; | ||
| 20 | }; | ||
| 21 | contentInsetEndAdjustment?: number; | ||
| 16 | ref?: Ref<LegendListRef>; | 22 | ref?: Ref<LegendListRef>; |
| 17 | }) => ( | 23 | }) => ( |
| 18 | <div data-testid={legendListTestId}> | 24 | <div |
| 25 | data-testid={legendListTestId} | ||
| 26 | data-anchor-index={props.anchoredEndSpace?.anchorIndex} | ||
| 27 | data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize} | ||
| 28 | data-anchor-offset={props.anchoredEndSpace?.anchorOffset} | ||
| 29 | data-content-inset-end={props.contentInsetEndAdjustment} | ||
| 30 | > | ||
| 19 | {props.ListHeaderComponent} | 31 | {props.ListHeaderComponent} |
| 20 | {props.data.map((item) => ( | 32 | {props.data.map((item) => ( |
| 21 | <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div> | 33 | <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div> |
| ... | @@ -109,6 +121,8 @@ function buildProps() { | ... | @@ -109,6 +121,8 @@ function buildProps() { |
| 109 | resolvedTheme: "light" as const, | 121 | resolvedTheme: "light" as const, |
| 110 | timestampFormat: "locale" as const, | 122 | timestampFormat: "locale" as const, |
| 111 | workspaceRoot: undefined, | 123 | workspaceRoot: undefined, |
| 124 | anchorMessageId: null, | ||
| 125 | contentInsetEndAdjustment: 0, | ||
| 112 | onIsAtEndChange: () => {}, | 126 | onIsAtEndChange: () => {}, |
| 113 | }; | 127 | }; |
| 114 | } | 128 | } |
| ... | @@ -137,6 +151,42 @@ function buildUserTimelineEntry(text: string) { | ... | @@ -137,6 +151,42 @@ function buildUserTimelineEntry(text: string) { |
| 137 | } | 151 | } |
| 138 | 152 | ||
| 139 | describe("MessagesTimeline", () => { | 153 | describe("MessagesTimeline", () => { |
| 154 | it("anchors a sent attachment message using its measured height", async () => { | ||
| 155 | const { MessagesTimeline } = await import("./MessagesTimeline"); | ||
| 156 | const firstEntry = buildUserTimelineEntry("First prompt."); | ||
| 157 | const secondEntry = { | ||
| 158 | ...buildUserTimelineEntry("Newest prompt."), | ||
| 159 | id: "entry-2", | ||
| 160 | message: { | ||
| 161 | ...buildUserTimelineEntry("Newest prompt.").message, | ||
| 162 | id: MessageId.make("message-2"), | ||
| 163 | attachments: [ | ||
| 164 | { | ||
| 165 | type: "image" as const, | ||
| 166 | id: "attachment-1", | ||
| 167 | name: "screenshot.png", | ||
| 168 | mimeType: "image/png", | ||
| 169 | sizeBytes: 1, | ||
| 170 | previewUrl: "data:image/png;base64,iVBORw0KGgo=", | ||
| 171 | }, | ||
| 172 | ], | ||
| 173 | }, | ||
| 174 | }; | ||
| 175 | const markup = renderToStaticMarkup( | ||
| 176 | <MessagesTimeline | ||
| 177 | {...buildProps()} | ||
| 178 | anchorMessageId={secondEntry.message.id} | ||
| 179 | contentInsetEndAdjustment={144} | ||
| 180 | timelineEntries={[firstEntry, secondEntry]} | ||
| 181 | />, | ||
| 182 | ); | ||
| 183 | |||
| 184 | expect(markup).toContain('data-anchor-index="1"'); | ||
| 185 | expect(markup).toContain('data-anchor-offset="16"'); | ||
| 186 | expect(markup).not.toContain("data-anchor-max-size="); | ||
| 187 | expect(markup).toContain('data-content-inset-end="144"'); | ||
| 188 | }); | ||
| 189 | |||
| 140 | it("renders collapse controls for long user messages", async () => { | 190 | it("renders collapse controls for long user messages", async () => { |
| 141 | const { MessagesTimeline } = await import("./MessagesTimeline"); | 191 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 142 | const markup = renderToStaticMarkup( | 192 | const markup = renderToStaticMarkup( |
apps/web/src/components/chat/MessagesTimeline.tsx+20-19| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | type TurnId, | 6 | type TurnId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 8 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 9 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | ||
| 9 | import { | 10 | import { |
| 10 | createContext, | 11 | createContext, |
| 11 | Fragment, | 12 | Fragment, |
| ... | @@ -165,6 +166,8 @@ interface MessagesTimelineProps { | ... | @@ -165,6 +166,8 @@ interface MessagesTimelineProps { |
| 165 | timestampFormat: TimestampFormat; | 166 | timestampFormat: TimestampFormat; |
| 166 | workspaceRoot: string | undefined; | 167 | workspaceRoot: string | undefined; |
| 167 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | 168 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 169 | anchorMessageId: MessageId | null; | ||
| 170 | contentInsetEndAdjustment: number; | ||
| 168 | onIsAtEndChange: (isAtEnd: boolean) => void; | 171 | onIsAtEndChange: (isAtEnd: boolean) => void; |
| 169 | } | 172 | } |
| 170 | 173 | ||
| ... | @@ -192,6 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -192,6 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 192 | timestampFormat, | 195 | timestampFormat, |
| 193 | workspaceRoot, | 196 | workspaceRoot, |
| 194 | skills = EMPTY_TIMELINE_SKILLS, | 197 | skills = EMPTY_TIMELINE_SKILLS, |
| 198 | anchorMessageId, | ||
| 199 | contentInsetEndAdjustment, | ||
| 195 | onIsAtEndChange, | 200 | onIsAtEndChange, |
| 196 | }: MessagesTimelineProps) { | 201 | }: MessagesTimelineProps) { |
| 197 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); | 202 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| ... | @@ -284,6 +289,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -284,6 +289,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 284 | ], | 289 | ], |
| 285 | ); | 290 | ); |
| 286 | const rows = useStableRows(rawRows); | 291 | const rows = useStableRows(rawRows); |
| 292 | const anchoredEndSpace = useMemo( | ||
| 293 | () => | ||
| 294 | resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => | ||
| 295 | row.kind === "message" ? row.message.id : null, | ||
| 296 | ), | ||
| 297 | [anchorMessageId, rows], | ||
| 298 | ); | ||
| 287 | 299 | ||
| 288 | const handleScroll = useCallback(() => { | 300 | const handleScroll = useCallback(() => { |
| 289 | const state = listRef.current?.getState?.(); | 301 | const state = listRef.current?.getState?.(); |
| ... | @@ -292,24 +304,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -292,24 +304,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 292 | } | 304 | } |
| 293 | }, [listRef, onIsAtEndChange]); | 305 | }, [listRef, onIsAtEndChange]); |
| 294 | 306 | ||
| 295 | const previousRowCountRef = useRef(rows.length); | ||
| 296 | useEffect(() => { | ||
| 297 | const previousRowCount = previousRowCountRef.current; | ||
| 298 | previousRowCountRef.current = rows.length; | ||
| 299 | |||
| 300 | if (previousRowCount > 0 || rows.length === 0) { | ||
| 301 | return; | ||
| 302 | } | ||
| 303 | |||
| 304 | onIsAtEndChange(true); | ||
| 305 | const frameId = window.requestAnimationFrame(() => { | ||
| 306 | void listRef.current?.scrollToEnd?.({ animated: false }); | ||
| 307 | }); | ||
| 308 | return () => { | ||
| 309 | window.cancelAnimationFrame(frameId); | ||
| 310 | }; | ||
| 311 | }, [listRef, onIsAtEndChange, rows.length]); | ||
| 312 | |||
| 313 | const sharedState = useMemo<TimelineRowSharedState>( | 307 | const sharedState = useMemo<TimelineRowSharedState>( |
| 314 | () => ({ | 308 | () => ({ |
| 315 | timestampFormat, | 309 | timestampFormat, |
| ... | @@ -376,14 +370,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -376,14 +370,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 376 | ref={listRef} | 370 | ref={listRef} |
| 377 | data={rows} | 371 | data={rows} |
| 378 | keyExtractor={keyExtractor} | 372 | keyExtractor={keyExtractor} |
| 373 | getItemType={getItemType} | ||
| 379 | renderItem={renderItem} | 374 | renderItem={renderItem} |
| 380 | estimatedItemSize={90} | 375 | estimatedItemSize={90} |
| 381 | initialScrollAtEnd | 376 | initialScrollAtEnd |
| 377 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | ||
| 378 | contentInsetEndAdjustment={contentInsetEndAdjustment} | ||
| 382 | maintainScrollAtEnd={!foldToggleSettling} | 379 | maintainScrollAtEnd={!foldToggleSettling} |
| 383 | maintainScrollAtEndThreshold={0.1} | 380 | maintainScrollAtEndThreshold={0.1} |
| 384 | maintainVisibleContentPosition | 381 | maintainVisibleContentPosition |
| 385 | onScroll={handleScroll} | 382 | onScroll={handleScroll} |
| 386 | className="scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" | 383 | className="scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 sm:px-5" |
| 387 | ListHeaderComponent={TIMELINE_LIST_HEADER} | 384 | ListHeaderComponent={TIMELINE_LIST_HEADER} |
| 388 | ListFooterComponent={TIMELINE_LIST_FOOTER} | 385 | ListFooterComponent={TIMELINE_LIST_FOOTER} |
| 389 | /> | 386 | /> |
| ... | @@ -396,6 +393,10 @@ function keyExtractor(item: MessagesTimelineRow) { | ... | @@ -396,6 +393,10 @@ function keyExtractor(item: MessagesTimelineRow) { |
| 396 | return item.id; | 393 | return item.id; |
| 397 | } | 394 | } |
| 398 | 395 | ||
| 396 | function getItemType(item: MessagesTimelineRow) { | ||
| 397 | return item.kind === "message" ? `message:${item.message.role}` : item.kind; | ||
| 398 | } | ||
| 399 | |||
| 399 | // --------------------------------------------------------------------------- | 400 | // --------------------------------------------------------------------------- |
| 400 | // TimelineRowContent — the actual row component | 401 | // TimelineRowContent — the actual row component |
| 401 | // --------------------------------------------------------------------------- | 402 | // --------------------------------------------------------------------------- |
apps/web/src/components/chat/ModelPickerContent.tsx+1-1| ... | @@ -554,7 +554,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -554,7 +554,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 554 | onItemHighlighted={(modelKey, eventDetails) => { | 554 | onItemHighlighted={(modelKey, eventDetails) => { |
| 555 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; | 555 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; |
| 556 | if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { | 556 | if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { |
| 557 | modelListRef.current?.scrollIndexIntoView?.({ | 557 | void modelListRef.current?.scrollIndexIntoView?.({ |
| 558 | index: eventDetails.index, | 558 | index: eventDetails.index, |
| 559 | animated: false, | 559 | animated: false, |
| 560 | }); | 560 | }); |
apps/web/src/index.css+54-1| ... | @@ -5,6 +5,7 @@ | ... | @@ -5,6 +5,7 @@ |
| 5 | @custom-variant wco (&:is(.wco, .wco *)); | 5 | @custom-variant wco (&:is(.wco, .wco *)); |
| 6 | 6 | ||
| 7 | :root { | 7 | :root { |
| 8 | --app-scrollbar-width: 6px; | ||
| 8 | --workspace-topbar-height: 52px; | 9 | --workspace-topbar-height: 52px; |
| 9 | --workspace-controls-top: 0px; | 10 | --workspace-controls-top: 0px; |
| 10 | --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); | 11 | --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); |
| ... | @@ -144,6 +145,58 @@ | ... | @@ -144,6 +145,58 @@ |
| 144 | .surface-subheader { | 145 | .surface-subheader { |
| 145 | @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; | 146 | @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; |
| 146 | } | 147 | } |
| 148 | |||
| 149 | .chat-composer-horizontal-inset { | ||
| 150 | padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem); | ||
| 151 | padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); | ||
| 152 | } | ||
| 153 | |||
| 154 | .chat-composer-glass, | ||
| 155 | .chat-composer-lower-chrome { | ||
| 156 | background: color-mix(in srgb, var(--card) 20%, transparent); | ||
| 157 | } | ||
| 158 | |||
| 159 | .chat-composer-glass { | ||
| 160 | box-shadow: | ||
| 161 | 0 18px 48px -20px rgb(0 0 0 / 28%), | ||
| 162 | 0 4px 14px -7px rgb(0 0 0 / 22%); | ||
| 163 | } | ||
| 164 | |||
| 165 | .chat-composer-shared-blur { | ||
| 166 | -webkit-backdrop-filter: blur(16px); | ||
| 167 | backdrop-filter: blur(16px); | ||
| 168 | } | ||
| 169 | |||
| 170 | .dark .chat-composer-glass, | ||
| 171 | .dark .chat-composer-lower-chrome { | ||
| 172 | background: color-mix(in srgb, var(--card) 45%, transparent); | ||
| 173 | } | ||
| 174 | |||
| 175 | .dark .chat-composer-glass { | ||
| 176 | box-shadow: | ||
| 177 | 0 18px 48px -20px rgb(0 0 0 / 60%), | ||
| 178 | 0 4px 14px -7px rgb(0 0 0 / 40%); | ||
| 179 | } | ||
| 180 | |||
| 181 | .chat-composer-lower-chrome { | ||
| 182 | margin-top: -1px; | ||
| 183 | margin-inline-end: var(--app-scrollbar-width); | ||
| 184 | padding-top: 1px; | ||
| 185 | } | ||
| 186 | |||
| 187 | @media (min-width: 40rem) { | ||
| 188 | .chat-composer-horizontal-inset { | ||
| 189 | padding-inline-start: calc(env(safe-area-inset-left) + 1.25rem); | ||
| 190 | padding-inline-end: calc(env(safe-area-inset-right) + 1.25rem); | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { | ||
| 195 | .chat-composer-glass, | ||
| 196 | .chat-composer-lower-chrome { | ||
| 197 | background: var(--card); | ||
| 198 | } | ||
| 199 | } | ||
| 147 | } | 200 | } |
| 148 | 201 | ||
| 149 | /* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering. | 202 | /* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering. |
| ... | @@ -299,7 +352,7 @@ code { | ... | @@ -299,7 +352,7 @@ code { |
| 299 | 352 | ||
| 300 | /* Scrollbar styling */ | 353 | /* Scrollbar styling */ |
| 301 | ::-webkit-scrollbar { | 354 | ::-webkit-scrollbar { |
| 302 | width: 6px; | 355 | width: var(--app-scrollbar-width); |
| 303 | } | 356 | } |
| 304 | 357 | ||
| 305 | ::-webkit-scrollbar-track { | 358 | ::-webkit-scrollbar-track { |
packages/shared/package.json+4| ... | @@ -167,6 +167,10 @@ | ... | @@ -167,6 +167,10 @@ |
| 167 | "types": "./src/filePreview.ts", | 167 | "types": "./src/filePreview.ts", |
| 168 | "import": "./src/filePreview.ts" | 168 | "import": "./src/filePreview.ts" |
| 169 | }, | 169 | }, |
| 170 | "./chatList": { | ||
| 171 | "types": "./src/chatList.ts", | ||
| 172 | "import": "./src/chatList.ts" | ||
| 173 | }, | ||
| 170 | "./hostProcess": { | 174 | "./hostProcess": { |
| 171 | "types": "./src/hostProcess.ts", | 175 | "types": "./src/hostProcess.ts", |
| 172 | "import": "./src/hostProcess.ts" | 176 | "import": "./src/hostProcess.ts" |
packages/shared/src/chatList.test.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "./chatList.js"; | ||
| 4 | |||
| 5 | interface Row { | ||
| 6 | readonly id: string; | ||
| 7 | readonly anchorable: boolean; | ||
| 8 | } | ||
| 9 | |||
| 10 | const rows: ReadonlyArray<Row> = [ | ||
| 11 | { id: "first", anchorable: true }, | ||
| 12 | { id: "ignored", anchorable: false }, | ||
| 13 | { id: "latest", anchorable: true }, | ||
| 14 | ]; | ||
| 15 | |||
| 16 | const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); | ||
| 17 | |||
| 18 | describe("resolveChatListAnchoredEndSpace", () => { | ||
| 19 | it("anchors the matching row using its measured height", () => { | ||
| 20 | expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({ | ||
| 21 | anchorIndex: 2, | ||
| 22 | anchorOffset: CHAT_LIST_ANCHOR_OFFSET, | ||
| 23 | }); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("allows a surface to keep the anchor below its own header", () => { | ||
| 27 | expect( | ||
| 28 | resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId, { | ||
| 29 | anchorOffset: 132, | ||
| 30 | }), | ||
| 31 | ).toEqual({ | ||
| 32 | anchorIndex: 2, | ||
| 33 | anchorOffset: 132, | ||
| 34 | }); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("ignores ineligible rows and missing anchors", () => { | ||
| 38 | expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined(); | ||
| 39 | expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined(); | ||
| 40 | expect(resolveChatListAnchoredEndSpace(rows, null, getAnchorId)).toBeUndefined(); | ||
| 41 | }); | ||
| 42 | }); | ||
packages/shared/src/chatList.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | export const CHAT_LIST_ANCHOR_OFFSET = 16; | ||
| 2 | |||
| 3 | export interface ChatListAnchoredEndSpace { | ||
| 4 | readonly anchorIndex: number; | ||
| 5 | readonly anchorOffset: number; | ||
| 6 | } | ||
| 7 | |||
| 8 | export interface ChatListAnchorOptions { | ||
| 9 | readonly anchorOffset?: number; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function resolveChatListAnchoredEndSpace<Item, AnchorId>( | ||
| 13 | items: ReadonlyArray<Item>, | ||
| 14 | anchorId: AnchorId | null, | ||
| 15 | getAnchorId: (item: Item) => AnchorId | null, | ||
| 16 | options: ChatListAnchorOptions = {}, | ||
| 17 | ): ChatListAnchoredEndSpace | undefined { | ||
| 18 | if (anchorId === null) { | ||
| 19 | return undefined; | ||
| 20 | } | ||
| 21 | |||
| 22 | for (let index = items.length - 1; index >= 0; index -= 1) { | ||
| 23 | const item = items[index]; | ||
| 24 | if (item !== undefined && getAnchorId(item) === anchorId) { | ||
| 25 | return { | ||
| 26 | anchorIndex: index, | ||
| 27 | anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | return undefined; | ||
| 33 | } | ||
pnpm-lock.yaml+14-14| ... | @@ -208,8 +208,8 @@ importers: | ... | @@ -208,8 +208,8 @@ importers: |
| 208 | specifier: ~56.0.8 | 208 | specifier: ~56.0.8 |
| 209 | version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) | 209 | version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) |
| 210 | '@legendapp/list': | 210 | '@legendapp/list': |
| 211 | specifier: 3.0.0-beta.44 | 211 | specifier: 3.2.0 |
| 212 | version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 212 | version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 213 | '@noble/curves': | 213 | '@noble/curves': |
| 214 | specifier: 'catalog:' | 214 | specifier: 'catalog:' |
| 215 | version: 1.9.1 | 215 | version: 1.9.1 |
| ... | @@ -355,13 +355,13 @@ importers: | ... | @@ -355,13 +355,13 @@ importers: |
| 355 | specifier: ^0.2.2 | 355 | specifier: ^0.2.2 |
| 356 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 356 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 357 | react-native-keyboard-controller: | 357 | react-native-keyboard-controller: |
| 358 | specifier: 1.21.6 | 358 | specifier: 1.21.7 |
| 359 | version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 359 | version: 1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 360 | react-native-nitro-markdown: | 360 | react-native-nitro-markdown: |
| 361 | specifier: ^0.5.0 | 361 | specifier: ^0.5.0 |
| 362 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 362 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 363 | react-native-nitro-modules: | 363 | react-native-nitro-modules: |
| 364 | specifier: ^0.35.4 | 364 | specifier: 0.35.9 |
| 365 | version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 365 | version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 366 | react-native-reanimated: | 366 | react-native-reanimated: |
| 367 | specifier: 4.3.1 | 367 | specifier: 4.3.1 |
| ... | @@ -513,8 +513,8 @@ importers: | ... | @@ -513,8 +513,8 @@ importers: |
| 513 | specifier: ^0.9.0 | 513 | specifier: ^0.9.0 |
| 514 | version: 0.9.0 | 514 | version: 0.9.0 |
| 515 | '@legendapp/list': | 515 | '@legendapp/list': |
| 516 | specifier: 3.0.0-beta.44 | 516 | specifier: 3.2.0 |
| 517 | version: 3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 517 | version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 518 | '@lexical/react': | 518 | '@lexical/react': |
| 519 | specifier: ^0.41.0 | 519 | specifier: ^0.41.0 |
| 520 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) | 520 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) |
| ... | @@ -2745,8 +2745,8 @@ packages: | ... | @@ -2745,8 +2745,8 @@ packages: |
| 2745 | resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} | 2745 | resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} |
| 2746 | engines: {node: '>=12'} | 2746 | engines: {node: '>=12'} |
| 2747 | 2747 | ||
| 2748 | '@legendapp/list@3.0.0-beta.44': | 2748 | '@legendapp/list@3.2.0': |
| 2749 | resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==} | 2749 | resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==} |
| 2750 | peerDependencies: | 2750 | peerDependencies: |
| 2751 | react: '*' | 2751 | react: '*' |
| 2752 | react-dom: '*' | 2752 | react-dom: '*' |
| ... | @@ -8516,8 +8516,8 @@ packages: | ... | @@ -8516,8 +8516,8 @@ packages: |
| 8516 | react: '*' | 8516 | react: '*' |
| 8517 | react-native: '*' | 8517 | react-native: '*' |
| 8518 | 8518 | ||
| 8519 | react-native-keyboard-controller@1.21.6: | 8519 | react-native-keyboard-controller@1.21.7: |
| 8520 | resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==} | 8520 | resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==} |
| 8521 | peerDependencies: | 8521 | peerDependencies: |
| 8522 | react: '*' | 8522 | react: '*' |
| 8523 | react-native: '*' | 8523 | react-native: '*' |
| ... | @@ -12155,7 +12155,7 @@ snapshots: | ... | @@ -12155,7 +12155,7 @@ snapshots: |
| 12155 | dependencies: | 12155 | dependencies: |
| 12156 | jsbi: 4.3.2 | 12156 | jsbi: 4.3.2 |
| 12157 | 12157 | ||
| 12158 | '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12158 | '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12159 | dependencies: | 12159 | dependencies: |
| 12160 | react: 19.2.3 | 12160 | react: 19.2.3 |
| 12161 | use-sync-external-store: 1.6.0(react@19.2.3) | 12161 | use-sync-external-store: 1.6.0(react@19.2.3) |
| ... | @@ -12163,7 +12163,7 @@ snapshots: | ... | @@ -12163,7 +12163,7 @@ snapshots: |
| 12163 | react-dom: 19.2.3(react@19.2.3) | 12163 | react-dom: 19.2.3(react@19.2.3) |
| 12164 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12164 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12165 | 12165 | ||
| 12166 | '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 12166 | '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 12167 | dependencies: | 12167 | dependencies: |
| 12168 | react: 19.2.6 | 12168 | react: 19.2.6 |
| 12169 | use-sync-external-store: 1.6.0(react@19.2.6) | 12169 | use-sync-external-store: 1.6.0(react@19.2.6) |
| ... | @@ -18311,7 +18311,7 @@ snapshots: | ... | @@ -18311,7 +18311,7 @@ snapshots: |
| 18311 | react: 19.2.3 | 18311 | react: 19.2.3 |
| 18312 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18312 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18313 | 18313 | ||
| 18314 | react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18314 | react-native-keyboard-controller@1.21.7(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18315 | dependencies: | 18315 | dependencies: |
| 18316 | react: 19.2.3 | 18316 | react: 19.2.3 |
| 18317 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18317 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |