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 @@
4444 "@effect/atom-react": "catalog:",
4545 "@expo-google-fonts/dm-sans": "^0.4.2",
4646 "@expo/ui": "~56.0.8",
47 "@legendapp/list": "3.0.0-beta.44",
47 "@legendapp/list": "3.2.0",
4848 "@noble/curves": "catalog:",
4949 "@noble/hashes": "catalog:",
5050 "@pierre/diffs": "catalog:",
......@@ -93,9 +93,9 @@
9393 "react-native": "0.85.3",
9494 "react-native-gesture-handler": "~2.31.1",
9595 "react-native-image-viewing": "^0.2.2",
96 "react-native-keyboard-controller": "1.21.6",
96 "react-native-keyboard-controller": "1.21.7",
9797 "react-native-nitro-markdown": "^0.5.0",
98 "react-native-nitro-modules": "^0.35.4",
98 "react-native-nitro-modules": "0.35.9",
9999 "react-native-reanimated": "4.3.1",
100100 "react-native-safe-area-context": "~5.7.0",
101101 "react-native-screens": "4.25.2",
apps/mobile/src/features/threads/ThreadComposer.tsx+3-4
......@@ -1,6 +1,7 @@
11import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
22import type {
33 EnvironmentId,
4 MessageId,
45 ModelSelection,
56 OrchestrationThreadShell,
67 ProviderInteractionMode,
......@@ -91,7 +92,7 @@ export interface ThreadComposerProps {
9192 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
9293 readonly onRemoveDraftImage: (imageId: string) => void;
9394 readonly onStopThread: () => void;
94 readonly onSendMessage: () => Promise<void>;
95 readonly onSendMessage: () => Promise<MessageId | null>;
9596 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
9697 readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
9798 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
......@@ -447,9 +448,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
447448 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
448449
449450 const handleSend = useCallback(() => {
450 void onSendMessage().then(() => {
451 inputRef.current?.blur();
452 });
451 void onSendMessage();
453452 }, [onSendMessage]);
454453 const handleCommandSelect = useCallback(
455454 (item: ComposerCommandItem) => {
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+90-24
......@@ -1,7 +1,10 @@
11import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
3import type { LegendListRef } from "@legendapp/list/react-native";
24import type {
35 ApprovalRequestId,
46 EnvironmentId,
7 MessageId,
58 ModelSelection,
69 OrchestrationThreadShell,
710 ProviderApprovalDecision,
......@@ -13,8 +16,8 @@ import type {
1316import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
1417import * as Haptics from "expo-haptics";
1518import { useHeaderHeight } from "expo-router/build/react-navigation/elements";
16import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
17import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native";
19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20import { View, type GestureResponderEvent } from "react-native";
1821import { Gesture, GestureDetector } from "react-native-gesture-handler";
1922import { KeyboardStickyView } from "react-native-keyboard-controller";
2023import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -25,7 +28,7 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor";
2528import type { StatusTone } from "../../components/StatusPill";
2629import type { DraftComposerImageAttachment } from "../../lib/composerImages";
2730import type { LayoutVariant } from "../../lib/layout";
28import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout";
31import { scopedThreadKey } from "../../lib/scopedEntities";
2932import type {
3033 PendingApproval,
3134 PendingUserInput,
......@@ -73,7 +76,7 @@ export interface ThreadDetailScreenProps {
7376 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
7477 readonly onRemoveDraftImage: (imageId: string) => void;
7578 readonly onStopThread: () => void;
76 readonly onSendMessage: () => Promise<void>;
79 readonly onSendMessage: () => Promise<MessageId | null>;
7780 readonly onReconnectEnvironment: () => void;
7881 readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
7982 readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
......@@ -206,25 +209,33 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
206209 const insets = useSafeAreaInsets();
207210 const headerHeight = useHeaderHeight();
208211 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);
210216 const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
217 const selectedThreadKeyRef = useRef(selectedThreadKey);
218 const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
211219 const [composerExpanded, setComposerExpanded] = useState(false);
220 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
212221 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
222 const contentPresentationKind = props.contentPresentation.kind;
223 const selectedThreadFeed = props.selectedThreadFeed;
213224 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
214225 const composerOverlapHeight = composerChrome + composerBottomInset;
215226 const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
216 const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
217 const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0);
227 const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8;
228 const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
229 listRef,
230 composerOverlayRef,
231 estimatedOverlayHeight,
232 );
233 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
218234 const showContent = props.showContent ?? true;
219235 const layoutVariant = props.layoutVariant ?? "compact";
220236 const isSplitLayout = layoutVariant === "split";
221237 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
222238 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
223 const feedBottomInset = resolveThreadFeedBottomInset({
224 estimatedOverlayHeight,
225 measuredOverlayHeight,
226 gap: 8,
227 });
228239 const selectedProviderSkills = useMemo(
229240 () =>
230241 props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
......@@ -254,15 +265,67 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
254265 [completeDrawerGesture, isSplitLayout],
255266 );
256267
257 const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => {
258 const nextHeight = Math.ceil(event.nativeEvent.layout.height);
259 setMeasuredOverlayHeight((current) =>
260 Math.abs(current - nextHeight) > 1 ? nextHeight : current,
261 );
262 }, []);
268 useLayoutEffect(() => {
269 selectedThreadKeyRef.current = selectedThreadKey;
270 }, [selectedThreadKey]);
271
272 useEffect(() => {
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
264327 const collapseComposer = useCallback(() => {
265 composerRef.current?.blur();
328 composerEditorRef.current?.blur();
266329 }, []);
267330
268331 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
......@@ -315,10 +378,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
315378 contentPresentation={props.contentPresentation}
316379 agentLabel={agentLabel}
317380 latestTurn={props.selectedThread.latestTurn}
381 listRef={listRef}
382 freeze={freeze}
383 anchorMessageId={anchorMessageId}
384 contentInsetEndAdjustment={contentInsetEndAdjustment}
318385 contentTopInset={headerHeight}
319 contentBottomInset={feedBottomInset}
386 contentBottomInset={estimatedOverlayHeight}
320387 layoutVariant={layoutVariant}
321 composerExpanded={composerExpanded}
322388 skills={selectedProviderSkills}
323389 />
324390 </View>
......@@ -332,7 +398,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
332398 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
333399 offset={{ closed: 0, opened: 0 }}
334400 >
335 <View onLayout={handleOverlayLayout}>
401 <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ paddingTop: 8 }}>
336402 {props.activeWorkStartedAt ? (
337403 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
338404 ) : null}
......@@ -361,7 +427,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
361427 ) : null}
362428
363429 <ThreadComposer
364 editorRef={composerRef}
430 editorRef={composerEditorRef}
365431 draftMessage={props.draftMessage}
366432 draftAttachments={props.draftAttachments}
367433 placeholder="Ask the repo agent, or run a command…"
......@@ -380,7 +446,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
380446 onNativePasteImages={props.onNativePasteImages}
381447 onRemoveDraftImage={props.onRemoveDraftImage}
382448 onStopThread={props.onStopThread}
383 onSendMessage={props.onSendMessage}
449 onSendMessage={handleSendMessage}
384450 onReconnectEnvironment={props.onReconnectEnvironment}
385451 onUpdateModelSelection={props.onUpdateThreadModelSelection}
386452 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
apps/mobile/src/features/threads/ThreadFeed.tsx+62-122
......@@ -1,10 +1,20 @@
11import * as Haptics from "expo-haptics";
2import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
2import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33import { 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";
56import { SymbolView } from "expo-symbols";
67import { 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";
818import {
919 Markdown,
1020 type CustomRenderers,
......@@ -15,8 +25,6 @@ import {
1525 ActivityIndicator,
1626 Image,
1727 Linking,
18 type NativeScrollEvent,
19 type NativeSyntheticEvent,
2028 Pressable,
2129 ScrollView,
2230 StyleSheet,
......@@ -29,6 +37,7 @@ import {
2937import { TouchableOpacity } from "react-native-gesture-handler";
3038import ImageViewing from "react-native-image-viewing";
3139import { useSafeAreaInsets } from "react-native-safe-area-context";
40import type { SharedValue } from "react-native-reanimated";
3241import { useThemeColor } from "../../lib/useThemeColor";
3342import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
3443import {
......@@ -64,14 +73,11 @@ import {
6473 type ThreadFeedEntry,
6574 type ThreadFeedLatestTurn,
6675} from "../../lib/threadActivity";
67import { isThreadFeedNearEnd } from "../../lib/threadFeedLayout";
68import { relativeTime } from "../../lib/time";
6976import type { ThreadContentPresentation } from "./threadContentPresentation";
7077import { ThreadWorkLog } from "./thread-work-log";
7178import { useAssetUrl } from "../../state/assets";
7279import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
7380
74const THREAD_FEED_END_THRESHOLD = 80;
7581const MESSAGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
7682 hour: "numeric",
7783 minute: "2-digit",
......@@ -93,10 +99,13 @@ export interface ThreadFeedProps {
9399 readonly contentPresentation: ThreadContentPresentation;
94100 readonly agentLabel: string;
95101 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>;
96106 readonly contentTopInset?: number;
97107 readonly contentBottomInset?: number;
98108 readonly layoutVariant?: LayoutVariant;
99 readonly composerExpanded?: boolean;
100109 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
101110}
102111
......@@ -812,30 +821,6 @@ function renderFeedEntry(
812821 );
813822 }
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
839824 return (
840825 <ThreadWorkLog
841826 activities={entry.activities}
......@@ -1122,17 +1107,12 @@ function ThreadFeedPlaceholder(props: {
11221107
11231108export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
11241109 const router = useRouter();
1125 const listRef = useRef<LegendListRef>(null);
11261110 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1127 const scrollFrameRef = useRef<number | null>(null);
11281111 const foldSettleFrameRef = useRef<number | null>(null);
11291112 const foldSettleSecondFrameRef = useRef<number | null>(null);
1130 const suppressAutoFollowRef = useRef(false);
11311113 const previousLatestTurnRef = useRef(props.latestTurn);
1132 const isNearEndRef = useRef(true);
1133 const initialScrollReadyRef = useRef(false);
1134 const lastContentHeightRef = useRef(0);
11351114 const { width: viewportWidth } = useWindowDimensions();
1115 const [foldToggleSettling, setFoldToggleSettling] = useState(false);
11361116 const [interactionState, setInteractionState] = useState<{
11371117 readonly copiedRowId: string | null;
11381118 readonly expandedWorkGroups: Record<string, boolean>;
......@@ -1214,6 +1194,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12141194 () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds),
12151195 [expandedTurnIds, props.feed, props.latestTurn],
12161196 );
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 );
12171207 const terminalAssistantMessageIds = useMemo(() => {
12181208 const terminalIdsByTurn = new Map<TurnId, string>();
12191209 for (const entry of props.feed) {
......@@ -1229,54 +1219,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
12291219 ? props.latestTurn.turnId
12301220 : 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
12801222 useEffect(() => {
12811223 const previous = previousLatestTurnRef.current;
12821224 previousLatestTurnRef.current = props.latestTurn;
......@@ -1308,9 +1250,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13081250 if (copyFeedbackTimeoutRef.current) {
13091251 clearTimeout(copyFeedbackTimeoutRef.current);
13101252 }
1311 if (scrollFrameRef.current !== null) {
1312 cancelAnimationFrame(scrollFrameRef.current);
1313 }
13141253 if (foldSettleFrameRef.current !== null) {
13151254 cancelAnimationFrame(foldSettleFrameRef.current);
13161255 }
......@@ -1358,7 +1297,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13581297 }, []);
13591298
13601299 const onToggleTurnFold = useCallback((turnId: TurnId) => {
1361 suppressAutoFollowRef.current = true;
1300 setFoldToggleSettling(true);
13621301 if (foldSettleFrameRef.current !== null) {
13631302 cancelAnimationFrame(foldSettleFrameRef.current);
13641303 }
......@@ -1376,7 +1315,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13761315 });
13771316 foldSettleFrameRef.current = requestAnimationFrame(() => {
13781317 foldSettleSecondFrameRef.current = requestAnimationFrame(() => {
1379 suppressAutoFollowRef.current = false;
1318 setFoldToggleSettling(false);
13801319 foldSettleFrameRef.current = null;
13811320 foldSettleSecondFrameRef.current = null;
13821321 });
......@@ -1458,59 +1397,60 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14581397 );
14591398 }
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
14731400 return (
14741401 <>
14751402 <View style={{ flex: 1 }}>
1476 <KeyboardAvoidingLegendList
1477 ref={listRef}
1403 <KeyboardAwareLegendList
1404 ref={props.listRef}
14781405 key={props.threadId}
14791406 style={{ flex: 1 }}
14801407 automaticallyAdjustsScrollIndicatorInsets={false}
1481 contentInset={{ top: 0, bottom: 0 }}
14821408 scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }}
1483 alignItemsAtEnd
1484 maintainScrollAtEnd={{
1485 animated: false,
1486 on: {
1487 dataChange: true,
1488 itemLayout: true,
1489 layout: true,
1490 },
1491 }}
1409 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1410 contentInsetEndAdjustment={props.contentInsetEndAdjustment}
1411 freeze={props.freeze}
1412 maintainScrollAtEnd={
1413 foldToggleSettling
1414 ? false
1415 : {
1416 animated: false,
1417 on: {
1418 dataChange: true,
1419 itemLayout: true,
1420 layout: true,
1421 },
1422 }
1423 }
1424 maintainVisibleContentPosition
14921425 data={presentedFeed}
14931426 extraData={listAppearanceData}
14941427 renderItem={renderItem}
1495 keyExtractor={(entry) => `${entry.type}:${entry.id}`}
1428 keyExtractor={(entry) => entry.id}
14961429 getItemType={(entry) =>
14971430 entry.type === "message" ? `message:${entry.message.role}` : entry.type
14981431 }
14991432 keyboardShouldPersistTaps="always"
15001433 keyboardDismissMode="none"
1434 keyboardLiftBehavior="whenAtEnd"
15011435 estimatedItemSize={180}
15021436 initialScrollAtEnd
1503 onContentSizeChange={onListContentSizeChange}
1504 onLoad={onListLoad}
1505 onScroll={onListScroll}
1506 scrollEventThrottle={16}
15071437 ListHeaderComponent={<View style={{ height: topContentInset }} />}
15081438 contentContainerStyle={{
15091439 paddingTop: 12,
1510 paddingBottom: bottomContentInset,
15111440 paddingHorizontal: horizontalPadding,
15121441 }}
15131442 />
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}
15141454 </View>
15151455
15161456 <ImageViewing
apps/mobile/src/lib/threadActivity.test.ts+6-6
......@@ -86,7 +86,7 @@ describe("buildThreadFeed", () => {
8686 ],
8787 });
8888
89 const feed = buildThreadFeed(thread, [], null);
89 const feed = buildThreadFeed(thread);
9090 expect(feed).toMatchObject([
9191 {
9292 type: "activity-group",
......@@ -144,7 +144,7 @@ describe("buildThreadFeed", () => {
144144 ],
145145 });
146146
147 const feed = buildThreadFeed(thread, [], null);
147 const feed = buildThreadFeed(thread);
148148 const group = feed[0];
149149
150150 expect(group).toMatchObject({
......@@ -209,7 +209,7 @@ describe("buildThreadFeed", () => {
209209 ],
210210 });
211211
212 const group = buildThreadFeed(thread, [], null)[0];
212 const group = buildThreadFeed(thread)[0];
213213 expect(group).toMatchObject({ type: "activity-group" });
214214 if (!group || group.type !== "activity-group") {
215215 return;
......@@ -271,7 +271,7 @@ describe("buildThreadFeed", () => {
271271 ],
272272 });
273273
274 const feed = buildThreadFeed(thread, [], null);
274 const feed = buildThreadFeed(thread);
275275 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
276276 expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]);
277277 expect(collapsed[0]).toMatchObject({
......@@ -359,7 +359,7 @@ describe("buildThreadFeed", () => {
359359 ],
360360 });
361361
362 const feed = buildThreadFeed(thread, [], null);
362 const feed = buildThreadFeed(thread);
363363 const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set());
364364 expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({
365365 turnId: firstTurnId,
......@@ -399,7 +399,7 @@ describe("buildThreadFeed", () => {
399399 ],
400400 });
401401
402 const feed = buildThreadFeed(thread, [], null);
402 const feed = buildThreadFeed(thread);
403403 expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed);
404404 expect(feed[0]).toMatchObject({
405405 type: "activity-group",
apps/mobile/src/lib/threadActivity.ts+1-19
......@@ -1,6 +1,5 @@
11import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts";
22import type {
3 MessageId,
43 OrchestrationLatestTurn,
54 OrchestrationThread,
65 OrchestrationThreadActivity,
......@@ -10,7 +9,6 @@ import type {
109} from "@t3tools/contracts";
1110import { formatDuration } from "@t3tools/shared/orchestrationTiming";
1211
13import type { QueuedThreadMessage } from "../state/thread-outbox-model";
1412import * as Arr from "effect/Array";
1513import * as Order from "effect/Order";
1614
......@@ -88,13 +86,6 @@ type RawThreadFeedEntry =
8886 readonly createdAt: string;
8987 readonly message: OrchestrationThread["messages"][number];
9088 }
91 | {
92 readonly type: "queued-message";
93 readonly id: string;
94 readonly createdAt: string;
95 readonly queuedMessage: QueuedThreadMessage;
96 readonly sending: boolean;
97 }
9889 | {
9990 readonly type: "activity";
10091 readonly id: string;
......@@ -104,7 +95,7 @@ type RawThreadFeedEntry =
10495 };
10596
10697export type ThreadFeedEntry =
107 | Extract<RawThreadFeedEntry, { type: "message" | "queued-message" }>
98 | Extract<RawThreadFeedEntry, { type: "message" }>
10899 | {
109100 readonly type: "activity-group";
110101 readonly id: string;
......@@ -1255,8 +1246,6 @@ export function buildPendingUserInputAnswers(
12551246
12561247export function buildThreadFeed(
12571248 thread: OrchestrationThread,
1258 queuedMessages: ReadonlyArray<QueuedThreadMessage>,
1259 dispatchingQueuedMessageId: MessageId | null,
12601249 options?: {
12611250 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
12621251 },
......@@ -1273,13 +1262,6 @@ export function buildThreadFeed(
12731262 createdAt: message.createdAt,
12741263 message,
12751264 })),
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 })),
12831265 ...workLogEntries
12841266 .filter((entry) => {
12851267 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";
4040import { useThreadSelection } from "../state/use-thread-selection";
4141import { enqueueThreadOutboxMessage } from "./thread-outbox";
4242import { useThreadOutboxMessages } from "./use-thread-outbox";
43import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain";
4443
4544export function appendReviewCommentToDraft(input: {
4645 readonly environmentId: EnvironmentId;
......@@ -77,7 +76,6 @@ export function useThreadComposerState() {
7776 const { selectedThread: selectedThreadShell } = useThreadSelection();
7877 const selectedThreadDetail = useSelectedThreadDetail();
7978 const composerDrafts = useAtomValue(composerDraftsAtom);
80 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
8179 const queuedMessagesByThreadKey = useThreadOutboxMessages();
8280
8381 useEffect(() => {
......@@ -91,17 +89,9 @@ export function useThreadComposerState() {
9189 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
9290 [queuedMessagesByThreadKey, selectedThreadKey],
9391 );
94
9592 const selectedThreadFeed = useMemo(
96 () =>
97 selectedThreadDetail
98 ? buildThreadFeed(
99 selectedThreadDetail,
100 selectedThreadQueuedMessages,
101 dispatchingQueuedMessageId,
102 )
103 : [],
104 [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages],
93 () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),
94 [selectedThreadDetail],
10595 );
10696
10797 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
......@@ -125,7 +115,6 @@ export function useThreadComposerState() {
125115 };
126116 }, [selectedThreadDetail, selectedThreadShell]);
127117
128 const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null;
129118 const activeWorkStartedAt = useMemo(() => {
130119 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
131120 if (!selectedThread) {
......@@ -135,14 +124,9 @@ export function useThreadComposerState() {
135124 return deriveActiveWorkStartedAt(
136125 selectedThread.latestTurn,
137126 selectedThreadSessionActivity,
138 queuedSendStartedAt,
127 null,
139128 );
140 }, [
141 queuedSendStartedAt,
142 selectedThreadDetail,
143 selectedThreadSessionActivity,
144 selectedThreadShell,
145 ]);
129 }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]);
146130
147131 const activeThreadBusy =
148132 !!selectedThread &&
......@@ -150,7 +134,7 @@ export function useThreadComposerState() {
150134
151135 const onSendMessage = useCallback(async () => {
152136 if (!selectedThreadShell) {
153 return;
137 return null;
154138 }
155139
156140 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
......@@ -159,15 +143,16 @@ export function useThreadComposerState() {
159143 const text = draft.text.trim();
160144 const attachments = draft.attachments;
161145 if (text.length === 0 && attachments.length === 0) {
162 return;
146 return null;
163147 }
164148
165149 const metadata = makeQueuedMessageMetadata();
150 const messageId = MessageId.make(metadata.messageId);
166151 try {
167152 await enqueueThreadOutboxMessage({
168153 environmentId: selectedThreadShell.environmentId,
169154 threadId: selectedThreadShell.id,
170 messageId: MessageId.make(metadata.messageId),
155 messageId,
171156 commandId: CommandId.make(metadata.commandId),
172157 text,
173158 attachments,
......@@ -177,10 +162,12 @@ export function useThreadComposerState() {
177162 createdAt: metadata.createdAt,
178163 });
179164 clearComposerDraftContent(threadKey);
165 return messageId;
180166 } catch (error) {
181167 setPendingConnectionError(
182168 error instanceof Error ? error.message : "Failed to save the queued message.",
183169 );
170 return null;
184171 }
185172 }, [selectedThreadDetail, selectedThreadShell]);
186173
apps/web/package.json+1-1
......@@ -22,7 +22,7 @@
2222 "@fontsource-variable/dm-sans": "^5.2.8",
2323 "@fontsource/jetbrains-mono": "^5.2.8",
2424 "@formkit/auto-animate": "^0.9.0",
25 "@legendapp/list": "3.0.0-beta.44",
25 "@legendapp/list": "3.2.0",
2626 "@lexical/react": "^0.41.0",
2727 "@pierre/diffs": "catalog:",
2828 "@pierre/trees": "1.0.0-beta.4",
apps/web/src/components/BranchToolbarBranchSelector.tsx+9-2
......@@ -518,7 +518,7 @@ export function BranchToolbarBranchSelector({
518518 return;
519519 }
520520
521 branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
521 void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
522522 }, [deferredTrimmedBranchQuery, isBranchMenuOpen]);
523523
524524 useEffect(() => {
......@@ -628,7 +628,7 @@ export function BranchToolbarBranchSelector({
628628 if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") {
629629 return;
630630 }
631 branchListRef.current?.scrollIndexIntoView?.({
631 void branchListRef.current?.scrollIndexIntoView?.({
632632 index: eventDetails.index,
633633 animated: false,
634634 });
......@@ -696,6 +696,13 @@ export function BranchToolbarBranchSelector({
696696 ref={branchListRef}
697697 data={filteredBranchPickerItems}
698698 keyExtractor={(item) => item}
699 getItemType={(item) =>
700 item === checkoutPullRequestItemValue
701 ? "checkout-pull-request"
702 : item === createBranchItemValue
703 ? "create-branch"
704 : "branch"
705 }
699706 renderItem={({ item, index }) => renderPickerItem(item, index)}
700707 estimatedItemSize={28}
701708 drawDistance={336}
apps/web/src/components/ChatView.tsx+178-114
......@@ -41,7 +41,17 @@ import { truncate } from "@t3tools/shared/String";
4141import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
4242import { Debouncer } from "@tanstack/react-pacer";
4343import { 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";
4555import { useNavigate } from "@tanstack/react-router";
4656import { useShallow } from "zustand/react/shallow";
4757import {
......@@ -1137,12 +1147,32 @@ function ChatViewContent(props: ChatViewProps) {
11371147 LastInvokedScriptByProjectSchema,
11381148 );
11391149 const legendListRef = useRef<LegendListRef | null>(null);
1150 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
1151 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
11401152 const isAtEndRef = useRef(true);
11411153 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
11421154 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
11431155 const sendInFlightRef = useRef(false);
11441156 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
11461176 const terminalUiState = useTerminalUiStateStore((state) =>
11471177 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef),
11481178 );
......@@ -1254,6 +1284,14 @@ function ChatViewContent(props: ChatViewProps) {
12541284 [activeThread],
12551285 );
12561286 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;
12571295 const activeRightPanelKind = useRightPanelStore((state) =>
12581296 selectActiveRightPanel(state.byThreadKey, activeThreadRef),
12591297 );
......@@ -3111,7 +3149,7 @@ function ChatViewContent(props: ChatViewProps) {
31113149
31123150 // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd.
31133151 const scrollToEnd = useCallback((animated = false) => {
3114 legendListRef.current?.scrollToEnd?.({ animated });
3152 void legendListRef.current?.scrollToEnd?.({ animated });
31153153 }, []);
31163154
31173155 // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
......@@ -3690,14 +3728,16 @@ function ChatViewContent(props: ChatViewProps) {
36903728 sizeBytes: image.sizeBytes,
36913729 previewUrl: image.previewUrl,
36923730 }));
3693 // Scroll to the current end *before* adding the optimistic message.
3694 // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd
3695 // automatically pins to the new item when the data changes.
3731 // Sending always returns to the live edge. The new row becomes the
3732 // anchored end-space target so it lands near the top while the response
3733 // streams into the reserved space below it.
36963734 isAtEndRef.current = true;
36973735 showScrollDebouncer.current.cancel();
36983736 setShowScrollToBottom(false);
3699 await legendListRef.current?.scrollToEnd?.({ animated: false });
3700
3737 setTimelineAnchor({
3738 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
3739 messageId: messageIdForSend,
3740 });
37013741 setOptimisticUserMessages((existing) => [
37023742 ...existing,
37033743 {
......@@ -3711,6 +3751,7 @@ function ChatViewContent(props: ChatViewProps) {
37113751 streaming: false,
37123752 },
37133753 ]);
3754 void legendListRef.current?.scrollToEnd?.({ animated: false });
37143755
37153756 setThreadError(threadIdForSend, null);
37163757 if (expiredTerminalContextCount > 0) {
......@@ -4722,7 +4763,7 @@ function ChatViewContent(props: ChatViewProps) {
47224763 {/* Main content area with optional plan sidebar */}
47234764 <div className="flex min-h-0 min-w-0 flex-1">
47244765 {/* 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">
47264767 {/* Messages Wrapper */}
47274768 <div className="relative flex min-h-0 flex-1 flex-col">
47284769 {/* Messages — LegendList handles virtualization and scrolling internally */}
......@@ -4747,12 +4788,17 @@ function ChatViewContent(props: ChatViewProps) {
47474788 timestampFormat={timestampFormat}
47484789 workspaceRoot={activeWorkspaceRoot}
47494790 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
4791 anchorMessageId={timelineAnchorMessageId}
4792 contentInsetEndAdjustment={composerOverlayHeight}
47504793 onIsAtEndChange={onIsAtEndChange}
47514794 />
47524795
47534796 {/* scroll to bottom pill — shown when user has scrolled away from the bottom */}
47544797 {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 >
47564802 <button
47574803 type="button"
47584804 onClick={() => scrollToEnd(true)}
......@@ -4767,115 +4813,133 @@ function ChatViewContent(props: ChatViewProps) {
47674813
47684814 {/* Input bar */}
47694815 <div
4770 className={cn(
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",
4772 isGitRepo
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 )}
4816 ref={setComposerOverlayElement}
4817 data-chat-composer-overlay="true"
4818 className="pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2"
47764819 >
4777 <div className="relative isolate">
4778 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
4779 <div className="relative z-10">
4780 <ChatComposer
4781 composerRef={composerRef}
4782 composerDraftTarget={composerDraftTarget}
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 />
4820 <div
4821 aria-hidden="true"
4822 className="chat-composer-horizontal-inset pointer-events-none absolute inset-x-0 top-1.5 bottom-0 z-0 sm:top-2"
4823 >
4824 <div className="relative mx-auto h-full w-full max-w-208 overflow-clip rounded-t-[20px]">
4825 <div className="chat-composer-shared-blur absolute -inset-8" />
48534826 </div>
48544827 </div>
4855 {isGitRepo && (
4856 <BranchToolbar
4857 environmentId={activeThread.environmentId}
4858 threadId={activeThread.id}
4859 {...(routeKind === "draft" && draftId ? { draftId } : {})}
4860 onEnvModeChange={onEnvModeChange}
4861 startFromOrigin={startFromOrigin}
4862 onStartFromOriginChange={onStartFromOriginChange}
4863 {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}
4864 {...(canOverrideServerThreadEnvMode
4865 ? {
4866 activeThreadBranchOverride: activeThreadBranch,
4867 onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,
4828 <div className="chat-composer-horizontal-inset">
4829 <div className="pointer-events-auto relative z-10 isolate">
4830 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
4831 <div className="relative z-10">
4832 <ChatComposer
4833 composerRef={composerRef}
4834 composerDraftTarget={composerDraftTarget}
4835 environmentId={environmentId}
4836 routeKind={routeKind}
4837 routeThreadRef={routeThreadRef}
4838 draftId={draftId}
4839 activeThreadId={activeThreadId}
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
48684888 }
4869 : {})}
4870 envLocked={envLocked}
4871 onComposerFocusRequest={scheduleComposerFocus}
4872 {...(canCheckoutPullRequestIntoThread
4873 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
4874 : {})}
4875 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
4876 availableEnvironments={logicalProjectEnvironments}
4877 />
4878 )}
4889 onChangeActivePendingUserInputCustomAnswer={
4890 onChangeActivePendingUserInputCustomAnswer
4891 }
4892 onProviderModelSelect={onProviderModelSelect}
4893 getModelDisabledReason={getModelDisabledReason}
4894 toggleInteractionMode={toggleInteractionMode}
4895 handleRuntimeModeChange={handleRuntimeModeChange}
4896 handleInteractionModeChange={handleInteractionModeChange}
4897 togglePlanSidebar={togglePlanSidebar}
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>
48794943 </div>
48804944
48814945 {pullRequestDialogState ? (
apps/web/src/components/chat/ChatComposer.tsx+4-26
......@@ -504,10 +504,6 @@ export interface ChatComposerProps {
504504 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
505505 composerRef: React.RefObject<ChatComposerHandle | null>;
506506
507 // Scroll
508 shouldAutoScrollRef: React.RefObject<boolean>;
509 scheduleStickToBottom: () => void;
510
511507 // Callbacks
512508 onSend: (e?: { preventDefault: () => void }) => void;
513509 onInterrupt: () => void;
......@@ -593,8 +589,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
593589 composerImagesRef,
594590 composerTerminalContextsRef,
595591 composerElementContextsRef,
596 shouldAutoScrollRef,
597 scheduleStickToBottom,
598592 onSend,
599593 onInterrupt,
600594 onImplementPlanInNewThread,
......@@ -896,7 +890,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
896890 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
897891 const composerFormRef = useRef<HTMLFormElement>(null);
898892 const composerSurfaceRef = useRef<HTMLDivElement>(null);
899 const composerFormHeightRef = useRef(0);
900893 const composerSelectLockRef = useRef(false);
901894 const composerMenuOpenRef = useRef(false);
902895 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
......@@ -1329,15 +1322,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13291322 };
13301323 };
13311324
1332 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
13331325 const initialCompactness = measureFooterCompactness();
13341326 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
13351327 setIsComposerFooterCompact(initialCompactness.footerCompact);
13361328 if (typeof ResizeObserver === "undefined") return;
13371329
1338 const observer = new ResizeObserver((entries) => {
1339 const [entry] = entries;
1340 if (!entry) return;
1330 const observer = new ResizeObserver(() => {
13411331 const nextCompactness = measureFooterCompactness();
13421332 setIsComposerPrimaryActionsCompact((previous) =>
13431333 previous === nextCompactness.primaryActionsCompact
......@@ -1347,25 +1337,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13471337 setIsComposerFooterCompact((previous) =>
13481338 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
13491339 );
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();
13561340 });
13571341
13581342 observer.observe(composerForm);
13591343 return () => {
13601344 observer.disconnect();
13611345 };
1362 }, [
1363 activeThreadId,
1364 composerFooterActionLayoutKey,
1365 composerFooterHasWideActions,
1366 scheduleStickToBottom,
1367 shouldAutoScrollRef,
1368 ]);
1346 }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]);
13691347
13701348 // ------------------------------------------------------------------
13711349 // Image persist effect
......@@ -2083,8 +2061,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20832061 ref={composerSurfaceRef}
20842062 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
20852063 className={cn(
2086 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
2087 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
2064 "chat-composer-glass rounded-[20px] border transition-colors duration-200 has-focus-visible:border-ring/45",
2065 isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border",
20882066 environmentUnavailable ? "opacity-75" : null,
20892067 composerProviderState.composerSurfaceClassName,
20902068 )}
apps/web/src/components/chat/MessagesTimeline.test.tsx+51-1
......@@ -13,9 +13,21 @@ vi.mock("@legendapp/list/react", async () => {
1313 renderItem: (args: { item: { id: string } }) => ReactNode;
1414 ListHeaderComponent?: ReactNode;
1515 ListFooterComponent?: ReactNode;
16 anchoredEndSpace?: {
17 anchorIndex: number;
18 anchorMaxSize?: number;
19 anchorOffset?: number;
20 };
21 contentInsetEndAdjustment?: number;
1622 ref?: Ref<LegendListRef>;
1723 }) => (
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 >
1931 {props.ListHeaderComponent}
2032 {props.data.map((item) => (
2133 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
......@@ -109,6 +121,8 @@ function buildProps() {
109121 resolvedTheme: "light" as const,
110122 timestampFormat: "locale" as const,
111123 workspaceRoot: undefined,
124 anchorMessageId: null,
125 contentInsetEndAdjustment: 0,
112126 onIsAtEndChange: () => {},
113127 };
114128}
......@@ -137,6 +151,42 @@ function buildUserTimelineEntry(text: string) {
137151}
138152
139153describe("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
140190 it("renders collapse controls for long user messages", async () => {
141191 const { MessagesTimeline } = await import("./MessagesTimeline");
142192 const markup = renderToStaticMarkup(
apps/web/src/components/chat/MessagesTimeline.tsx+20-19
......@@ -6,6 +6,7 @@ import {
66 type TurnId,
77} from "@t3tools/contracts";
88import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
9import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
910import {
1011 createContext,
1112 Fragment,
......@@ -165,6 +166,8 @@ interface MessagesTimelineProps {
165166 timestampFormat: TimestampFormat;
166167 workspaceRoot: string | undefined;
167168 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
169 anchorMessageId: MessageId | null;
170 contentInsetEndAdjustment: number;
168171 onIsAtEndChange: (isAtEnd: boolean) => void;
169172}
170173
......@@ -192,6 +195,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
192195 timestampFormat,
193196 workspaceRoot,
194197 skills = EMPTY_TIMELINE_SKILLS,
198 anchorMessageId,
199 contentInsetEndAdjustment,
195200 onIsAtEndChange,
196201}: MessagesTimelineProps) {
197202 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
......@@ -284,6 +289,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({
284289 ],
285290 );
286291 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
288300 const handleScroll = useCallback(() => {
289301 const state = listRef.current?.getState?.();
......@@ -292,24 +304,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
292304 }
293305 }, [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
313307 const sharedState = useMemo<TimelineRowSharedState>(
314308 () => ({
315309 timestampFormat,
......@@ -376,14 +370,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({
376370 ref={listRef}
377371 data={rows}
378372 keyExtractor={keyExtractor}
373 getItemType={getItemType}
379374 renderItem={renderItem}
380375 estimatedItemSize={90}
381376 initialScrollAtEnd
377 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
378 contentInsetEndAdjustment={contentInsetEndAdjustment}
382379 maintainScrollAtEnd={!foldToggleSettling}
383380 maintainScrollAtEndThreshold={0.1}
384381 maintainVisibleContentPosition
385382 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"
387384 ListHeaderComponent={TIMELINE_LIST_HEADER}
388385 ListFooterComponent={TIMELINE_LIST_FOOTER}
389386 />
......@@ -396,6 +393,10 @@ function keyExtractor(item: MessagesTimelineRow) {
396393 return item.id;
397394}
398395
396function getItemType(item: MessagesTimelineRow) {
397 return item.kind === "message" ? `message:${item.message.role}` : item.kind;
398}
399
399400// ---------------------------------------------------------------------------
400401// TimelineRowContent — the actual row component
401402// ---------------------------------------------------------------------------
apps/web/src/components/chat/ModelPickerContent.tsx+1-1
......@@ -554,7 +554,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
554554 onItemHighlighted={(modelKey, eventDetails) => {
555555 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
556556 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {
557 modelListRef.current?.scrollIndexIntoView?.({
557 void modelListRef.current?.scrollIndexIntoView?.({
558558 index: eventDetails.index,
559559 animated: false,
560560 });
apps/web/src/index.css+54-1
......@@ -5,6 +5,7 @@
55@custom-variant wco (&:is(.wco, .wco *));
66
77:root {
8 --app-scrollbar-width: 6px;
89 --workspace-topbar-height: 52px;
910 --workspace-controls-top: 0px;
1011 --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem);
......@@ -144,6 +145,58 @@
144145 .surface-subheader {
145146 @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background;
146147 }
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 }
147200}
148201
149202/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.
......@@ -299,7 +352,7 @@ code {
299352
300353/* Scrollbar styling */
301354::-webkit-scrollbar {
302 width: 6px;
355 width: var(--app-scrollbar-width);
303356}
304357
305358::-webkit-scrollbar-track {
packages/shared/package.json+4
......@@ -167,6 +167,10 @@
167167 "types": "./src/filePreview.ts",
168168 "import": "./src/filePreview.ts"
169169 },
170 "./chatList": {
171 "types": "./src/chatList.ts",
172 "import": "./src/chatList.ts"
173 },
170174 "./hostProcess": {
171175 "types": "./src/hostProcess.ts",
172176 "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:
208208 specifier: ~56.0.8
209209 version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)
210210 '@legendapp/list':
211 specifier: 3.0.0-beta.44
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)
211 specifier: 3.2.0
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)
213213 '@noble/curves':
214214 specifier: 'catalog:'
215215 version: 1.9.1
......@@ -355,13 +355,13 @@ importers:
355355 specifier: ^0.2.2
356356 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)
357357 react-native-keyboard-controller:
358 specifier: 1.21.6
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)
358 specifier: 1.21.7
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)
360360 react-native-nitro-markdown:
361361 specifier: ^0.5.0
362362 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)
363363 react-native-nitro-modules:
364 specifier: ^0.35.4
364 specifier: 0.35.9
365365 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)
366366 react-native-reanimated:
367367 specifier: 4.3.1
......@@ -513,8 +513,8 @@ importers:
513513 specifier: ^0.9.0
514514 version: 0.9.0
515515 '@legendapp/list':
516 specifier: 3.0.0-beta.44
517 version: 3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
516 specifier: 3.2.0
517 version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
518518 '@lexical/react':
519519 specifier: ^0.41.0
520520 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:
27452745 resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}
27462746 engines: {node: '>=12'}
27472747
2748 '@legendapp/list@3.0.0-beta.44':
2749 resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==}
2748 '@legendapp/list@3.2.0':
2749 resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==}
27502750 peerDependencies:
27512751 react: '*'
27522752 react-dom: '*'
......@@ -8516,8 +8516,8 @@ packages:
85168516 react: '*'
85178517 react-native: '*'
85188518
8519 react-native-keyboard-controller@1.21.6:
8520 resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==}
8519 react-native-keyboard-controller@1.21.7:
8520 resolution: {integrity: sha512-gs+8nI8HYnRdDt4NWbk1iVuS6kDLf2taJvp+h/TjM1FBdtnQmlYLJ6buNiUqSnkIH4OFEAxdNr3/GOOYdLfkUQ==}
85218521 peerDependencies:
85228522 react: '*'
85238523 react-native: '*'
......@@ -12155,7 +12155,7 @@ snapshots:
1215512155 dependencies:
1215612156 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)':
1215912159 dependencies:
1216012160 react: 19.2.3
1216112161 use-sync-external-store: 1.6.0(react@19.2.3)
......@@ -12163,7 +12163,7 @@ snapshots:
1216312163 react-dom: 19.2.3(react@19.2.3)
1216412164 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)':
1216712167 dependencies:
1216812168 react: 19.2.6
1216912169 use-sync-external-store: 1.6.0(react@19.2.6)
......@@ -18311,7 +18311,7 @@ snapshots:
1831118311 react: 19.2.3
1831218312 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):
1831518315 dependencies:
1831618316 react: 19.2.3
1831718317 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)