authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-24 15:39:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-24 15:39:17-07:00
log22f021ed697f4021462fcbc65fad31e4c5c77ce0
tree05d654550b63dba2be0c235d49030f459d27fa6e
parenta4964b3b363dc515fad04458e5936cd32053c659
signature Signed by PGP key B5690EEEBB952194

[codex] Upgrade Legend List chat scrolling (#3545)

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 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type {2import 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;
448449
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 @@
1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";1import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
3import type { LegendListRef } from "@legendapp/list/react-native";
2import type {4import 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 {
13import { formatElapsed } from "@t3tools/shared/orchestrationTiming";16import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
14import * as Haptics from "expo-haptics";17import * as Haptics from "expo-haptics";
15import { useHeaderHeight } from "expo-router/build/react-navigation/elements";18import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
16import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
17import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native";20import { View, type GestureResponderEvent } from "react-native";
18import { Gesture, GestureDetector } from "react-native-gesture-handler";21import { Gesture, GestureDetector } from "react-native-gesture-handler";
19import { KeyboardStickyView } from "react-native-keyboard-controller";22import { KeyboardStickyView } from "react-native-keyboard-controller";
20import { useSafeAreaInsets } from "react-native-safe-area-context";23import { 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";
25import type { StatusTone } from "../../components/StatusPill";28import type { StatusTone } from "../../components/StatusPill";
26import type { DraftComposerImageAttachment } from "../../lib/composerImages";29import type { DraftComposerImageAttachment } from "../../lib/composerImages";
27import type { LayoutVariant } from "../../lib/layout";30import type { LayoutVariant } from "../../lib/layout";
28import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout";31import { scopedThreadKey } from "../../lib/scopedEntities";
29import type {32import 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 );
256267
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]);
263326
264 const collapseComposer = useCallback(() => {327 const collapseComposer = useCallback(() => {
265 composerRef.current?.blur();328 composerEditorRef.current?.blur();
266 }, []);329 }, []);
267330
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}
362428
363 <ThreadComposer429 <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 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
5import { SymbolView } from "expo-symbols";6import { SymbolView } from "expo-symbols";
6import { useRouter } from "expo-router";7import { useRouter } from "expo-router";
7import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";8import {
9 memo,
10 useCallback,
11 useEffect,
12 useMemo,
13 useRef,
14 useState,
15 type ReactNode,
16 type RefObject,
17} from "react";
8import {18import {
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 {
29import { TouchableOpacity } from "react-native-gesture-handler";37import { TouchableOpacity } from "react-native-gesture-handler";
30import ImageViewing from "react-native-image-viewing";38import ImageViewing from "react-native-image-viewing";
31import { useSafeAreaInsets } from "react-native-safe-area-context";39import { useSafeAreaInsets } from "react-native-safe-area-context";
40import type { SharedValue } from "react-native-reanimated";
32import { useThemeColor } from "../../lib/useThemeColor";41import { useThemeColor } from "../../lib/useThemeColor";
33import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";42import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
34import {43import {
...@@ -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";
67import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";
68import { relativeTime } from "../../lib/time";
69import type { ThreadContentPresentation } from "./threadContentPresentation";76import type { ThreadContentPresentation } from "./threadContentPresentation";
70import { ThreadWorkLog } from "./thread-work-log";77import { ThreadWorkLog } from "./thread-work-log";
71import { useAssetUrl } from "../../state/assets";78import { useAssetUrl } from "../../state/assets";
72import { resolveWorkspaceRelativeFilePath } from "../files/filePath";79import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
7380
74const THREAD_FEED_END_THRESHOLD = 80;
75const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {81const 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}
102111
...@@ -812,30 +821,6 @@ function renderFeedEntry(...@@ -812,30 +821,6 @@ function renderFeedEntry(
812 );821 );
813 }822 }
814823
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 <ThreadWorkLog825 <ThreadWorkLog
841 activities={entry.activities}826 activities={entry.activities}
...@@ -1122,17 +1107,12 @@ function ThreadFeedPlaceholder(props: {...@@ -1122,17 +1107,12 @@ function ThreadFeedPlaceholder(props: {
11221107
1123export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {1108export 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.turnId1219 ? props.latestTurn.turnId
1230 : null;1220 : null;
12311221
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 }, []);
13591298
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 }
14601399
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 <KeyboardAvoidingLegendList1403 <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 alignItemsAtEnd1409 {...(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.type1430 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 initialScrollAtEnd1436 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>
15151455
1516 <ImageViewing1456 <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 });
8888
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 });
146146
147 const feed = buildThreadFeed(thread, [], null);147 const feed = buildThreadFeed(thread);
148 const group = feed[0];148 const group = feed[0];
149149
150 expect(group).toMatchObject({150 expect(group).toMatchObject({
...@@ -209,7 +209,7 @@ describe("buildThreadFeed", () => {...@@ -209,7 +209,7 @@ describe("buildThreadFeed", () => {
209 ],209 ],
210 });210 });
211211
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 });
273273
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 });
361361
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 });
401401
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 @@
1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";1import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
2import type {2import 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";
11import { formatDuration } from "@t3tools/shared/orchestrationTiming";10import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1211
13import type { QueuedThreadMessage } from "../state/thread-outbox-model";
14import * as Arr from "effect/Array";12import * as Arr from "effect/Array";
15import * as Order from "effect/Order";13import * as Order from "effect/Order";
1614
...@@ -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 };
10596
106export type ThreadFeedEntry =97export 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(
12551246
1256export function buildThreadFeed(1247export 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 ...workLogEntries1265 ...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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 isThreadFeedNearEnd,
5 resolveThreadFeedBottomInset,
6 threadFeedDistanceFromEnd,
7} from "./threadFeedLayout";
8
9describe("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 @@
1export interface ThreadFeedScrollMetrics {
2 readonly contentHeight: number;
3 readonly viewportHeight: number;
4 readonly offsetY: number;
5 readonly bottomInset: number;
6}
7
8export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number {
9 return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY;
10}
11
12export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean {
13 return threadFeedDistanceFromEnd(metrics) <= threshold;
14}
15
16export 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";
40import { useThreadSelection } from "../state/use-thread-selection";40import { useThreadSelection } from "../state/use-thread-selection";
41import { enqueueThreadOutboxMessage } from "./thread-outbox";41import { enqueueThreadOutboxMessage } from "./thread-outbox";
42import { useThreadOutboxMessages } from "./use-thread-outbox";42import { useThreadOutboxMessages } from "./use-thread-outbox";
43import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain";
4443
45export function appendReviewCommentToDraft(input: {44export 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();
8280
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 selectedThreadDetail94 [selectedThreadDetail],
98 ? buildThreadFeed(
99 selectedThreadDetail,
100 selectedThreadQueuedMessages,
101 dispatchingQueuedMessageId,
102 )
103 : [],
104 [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages],
105 );95 );
10696
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]);
127117
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 ]);
146130
147 const activeThreadBusy =131 const activeThreadBusy =
148 !!selectedThread &&132 !!selectedThread &&
...@@ -150,7 +134,7 @@ export function useThreadComposerState() {...@@ -150,7 +134,7 @@ export function useThreadComposerState() {
150134
151 const onSendMessage = useCallback(async () => {135 const onSendMessage = useCallback(async () => {
152 if (!selectedThreadShell) {136 if (!selectedThreadShell) {
153 return;137 return null;
154 }138 }
155139
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 }
164148
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]);
186173
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 }
520520
521 branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });521 void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
522 }, [deferredTrimmedBranchQuery, isBranchMenuOpen]);522 }, [deferredTrimmedBranchQuery, isBranchMenuOpen]);
523523
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";
41import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";41import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
42import { Debouncer } from "@tanstack/react-pacer";42import { Debouncer } from "@tanstack/react-pacer";
43import { useAtomValue } from "@effect/atom-react";43import { useAtomValue } from "@effect/atom-react";
44import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";44import {
45 lazy,
46 memo,
47 Suspense,
48 useCallback,
49 useEffect,
50 useLayoutEffect,
51 useMemo,
52 useRef,
53 useState,
54} from "react";
45import { useNavigate } from "@tanstack/react-router";55import { useNavigate } from "@tanstack/react-router";
46import { useShallow } from "zustand/react/shallow";56import { useShallow } from "zustand/react/shallow";
47import {57import {
...@@ -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>>({});
11451157
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) {
31113149
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 }, []);
31163154
3117 // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during3155 // 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 maintainScrollAtEnd3732 // 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({
37003738 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 });
37143755
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 />
47524795
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 <button4802 <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) {
47674813
4768 {/* Input bar */}4814 {/* Input bar */}
4769 <div4815 <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 isGitRepo4818 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 <ChatComposer4823 >
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 <BranchToolbar4829 <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 {...(canOverrideServerThreadEnvMode4837 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 {...(canCheckoutPullRequestIntoThread4892 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>
48804944
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>;
506506
507 // Scroll
508 shouldAutoScrollRef: React.RefObject<boolean>;
509 scheduleStickToBottom: () => void;
510
511 // Callbacks507 // 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 };
13311324
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;
13371329
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.primaryActionsCompact1333 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 });
13571341
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 ]);
13691347
1370 // ------------------------------------------------------------------1348 // ------------------------------------------------------------------
1371 // Image persist effect1349 // 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}
138152
139describe("MessagesTimeline", () => {153describe("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";
8import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";8import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
9import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
9import {10import {
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}
170173
...@@ -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 );
287299
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]);
294306
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 initialScrollAtEnd376 initialScrollAtEnd
377 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
378 contentInsetEndAdjustment={contentInsetEndAdjustment}
382 maintainScrollAtEnd={!foldToggleSettling}379 maintainScrollAtEnd={!foldToggleSettling}
383 maintainScrollAtEndThreshold={0.1}380 maintainScrollAtEndThreshold={0.1}
384 maintainVisibleContentPosition381 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}
398395
396function getItemType(item: MessagesTimelineRow) {
397 return item.kind === "message" ? `message:${item.message.role}` : item.kind;
398}
399
399// ---------------------------------------------------------------------------400// ---------------------------------------------------------------------------
400// TimelineRowContent — the actual row component401// 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 *));
66
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}
148201
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 {
299352
300/* Scrollbar styling */353/* Scrollbar styling */
301::-webkit-scrollbar {354::-webkit-scrollbar {
302 width: 6px;355 width: var(--app-scrollbar-width);
303}356}
304357
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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "./chatList.js";
4
5interface Row {
6 readonly id: string;
7 readonly anchorable: boolean;
8}
9
10const rows: ReadonlyArray<Row> = [
11 { id: "first", anchorable: true },
12 { id: "ignored", anchorable: false },
13 { id: "latest", anchorable: true },
14];
15
16const getAnchorId = (row: Row) => (row.anchorable ? row.id : null);
17
18describe("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 @@
1export const CHAT_LIST_ANCHOR_OFFSET = 16;
2
3export interface ChatListAnchoredEndSpace {
4 readonly anchorIndex: number;
5 readonly anchorOffset: number;
6}
7
8export interface ChatListAnchorOptions {
9 readonly anchorOffset?: number;
10}
11
12export 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.8208 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.44211 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.1215 version: 1.9.1
...@@ -355,13 +355,13 @@ importers:...@@ -355,13 +355,13 @@ importers:
355 specifier: ^0.2.2355 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.6358 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.0361 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.4364 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.1367 specifier: 4.3.1
...@@ -513,8 +513,8 @@ importers:...@@ -513,8 +513,8 @@ importers:
513 specifier: ^0.9.0513 specifier: ^0.9.0
514 version: 0.9.0514 version: 0.9.0
515 '@legendapp/list':515 '@legendapp/list':
516 specifier: 3.0.0-beta.44516 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.0519 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'}
27472747
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: '*'
85188518
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.212156 jsbi: 4.3.2
1215712157
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.312160 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)
1216512165
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.612168 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.318311 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)
1831318313
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.318316 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)