History

feat(orchestration): bound thread history and resume payloads

- Add paginated thread history with bounded snapshots and replay limits - Trim oversized wire payloads and support progressive mobile history loading

Julius Marminge committed 3 months ago

Commit 7d42d311 parent e3883a943 files · +4239 −211Browse files

Showing the changes to apps/mobile/src (5 of 43 files). Show all

Unified · Split

5 files
  1. apps/mobile/src/connection/runtime.ts +9 −2
  2. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +4 −1
  3. apps/mobile/src/features/threads/ThreadFeed.tsx +48 −59
  4. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +25 −0
  5. apps/mobile/src/state/use-thread-outbox-drain.ts +1 −0
apps/mobile/src/connection/runtime.ts+9−2
@@ -1,6 +1,9 @@
1import { Connection } from "@t3tools/client-runtime/connection";1import { Connection } from "@t3tools/client-runtime/connection";
2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";3import {
4 boundedThreadSnapshotLoaderLayer,
5 threadHistoryControllerLayer,
6} from "@t3tools/client-runtime/state/threads";
4import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
5import { Atom } from "effect/unstable/reactivity";8import { Atom } from "effect/unstable/reactivity";
6 9
@@ -20,7 +23,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( · 13 unmodified lines
20 Layer.provide(runtimeContextLayer),23 Layer.provide(runtimeContextLayer),
21);24);
22 25
23const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);26const snapshotLoaderLayer = Layer.mergeAll(
27 boundedThreadSnapshotLoaderLayer,
28 shellSnapshotLoaderLayer,
29 threadHistoryControllerLayer,
30);
24 31
25type ConnectionLayerSource =32type ConnectionLayerSource =
26 | typeof Connection.layer33 | typeof Connection.layer
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4−1
@@ -79,7 +79,7 @@ import { · 78 unmodified lines
79 COMPOSER_EXPANDED_CHROME,79 COMPOSER_EXPANDED_CHROME,
80 ThreadComposer,80 ThreadComposer,
81} from "./ThreadComposer";81} from "./ThreadComposer";
82import { ThreadFeed } from "./ThreadFeed";82import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
83import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";83import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";
84import { ThreadQueueControl } from "./ThreadQueueControl";84import { ThreadQueueControl } from "./ThreadQueueControl";
85import type { ThreadContentPresentation } from "./threadContentPresentation";85import type { ThreadContentPresentation } from "./threadContentPresentation";
@@ -104,6 +104,8 @@ export interface ThreadDetailScreenProps { · 18 unmodified lines
104 readonly connectionStateLabel: EnvironmentConnectionPhase;104 readonly connectionStateLabel: EnvironmentConnectionPhase;
105 /** Message sync status for the selected thread (drives the composer status pill). */105 /** Message sync status for the selected thread (drives the composer status pill). */
106 readonly threadSyncStatus?: EnvironmentThreadStatus;106 readonly threadSyncStatus?: EnvironmentThreadStatus;
107 /** Progressive history controls for oversized mobile thread opens. */
108 readonly historyControls?: ThreadFeedHistoryControls;
107 readonly activeThreadBusy: boolean;109 readonly activeThreadBusy: boolean;
108 readonly canStopThread: boolean;110 readonly canStopThread: boolean;
109 readonly environmentId: EnvironmentId;111 readonly environmentId: EnvironmentId;
@@ -650,6 +652,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 540 unmodified lines
650 contentTopInset={0}652 contentTopInset={0}
651 contentBottomInset={estimatedOverlayHeight}653 contentBottomInset={estimatedOverlayHeight}
652 contentMaxWidth={contentMaxWidth}654 contentMaxWidth={contentMaxWidth}
655 historyControls={props.historyControls}
653 topAccessory={656 topAccessory={
654 <ThreadRelationshipsBanner657 <ThreadRelationshipsBanner
655 environmentId={props.environmentId}658 environmentId={props.environmentId}
apps/mobile/src/features/threads/ThreadFeed.tsx+48−59
@@ -55,14 +55,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP · 54 unmodified lines
55import { isPdfFile } from "../../lib/filePreview";55import { isPdfFile } from "../../lib/filePreview";
56import { PresentationSource } from "../../components/NativePresentation";56import { PresentationSource } from "../../components/NativePresentation";
57import { useSafeAreaInsets } from "react-native-safe-area-context";57import { useSafeAreaInsets } from "react-native-safe-area-context";
58import Animated, {58import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
59 FadeIn,
60 FadeInUp,
61 useSharedValue,
62 withTiming,
63 type LayoutAnimationsValues,
64 type SharedValue,
65} from "react-native-reanimated";
66import { useThemeColor } from "../../lib/useThemeColor";59import { useThemeColor } from "../../lib/useThemeColor";
67import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";60import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
68import { useFontFamily } from "../../lib/useFontFamily";61import { useFontFamily } from "../../lib/useFontFamily";
@@ -167,14 +160,6 @@ function formatMessageTime(input: string): string { · 98 unmodified lines
167 return MESSAGE_TIME_FORMATTER.format(timestamp);160 return MESSAGE_TIME_FORMATTER.format(timestamp);
168}161}
169 162
170// Rows shift when content above them grows (streaming text, work-log folds);
171// animating the container position turns those jumps into slides. Applied
172// conditionally — see the gated transition in ThreadFeed: while browsing
173// history the animation must NOT run, or every estimate→actual size
174// correction plays as a visible slide against the instant scroll-offset
175// compensation from maintainVisibleContentPosition.
176const FEED_ITEM_LAYOUT_DURATION_MS = 180;
177
178// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's163// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
179// classNames. The fold row's min-h-11 (44px) stays taller than its single164// classNames. The fold row's min-h-11 (44px) stays taller than its single
180// text-sm line at every supported base font size (26px at the 22pt maximum),165// text-sm line at every supported base font size (26px at the 22pt maximum),
@@ -194,6 +179,13 @@ function isFreshTimestamp(input: string): boolean { · 13 unmodified lines
194 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;179 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
195}180}
196 181
182export interface ThreadFeedHistoryControls {
183 readonly hasMoreHistory: boolean;
184 readonly loading: boolean;
185 readonly error: string | null;
186 readonly onLoadEarlier: () => void;
187}
188
197export interface ThreadFeedProps {189export interface ThreadFeedProps {
198 readonly environmentId: EnvironmentId;190 readonly environmentId: EnvironmentId;
199 readonly threadId: ThreadId;191 readonly threadId: ThreadId;
@@ -210,6 +202,7 @@ export interface ThreadFeedProps { · 10 unmodified lines
210 readonly contentInsetEndAdjustment: SharedValue<number>;202 readonly contentInsetEndAdjustment: SharedValue<number>;
211 readonly contentTopInset?: number;203 readonly contentTopInset?: number;
212 readonly contentBottomInset?: number;204 readonly contentBottomInset?: number;
205 readonly historyControls?: ThreadFeedHistoryControls;
213 readonly topAccessory?: ReactNode;206 readonly topAccessory?: ReactNode;
214 readonly contentMaxWidth?: number;207 readonly contentMaxWidth?: number;
215 readonly layoutVariant?: LayoutVariant;208 readonly layoutVariant?: LayoutVariant;
@@ -1642,6 +1635,41 @@ function compactFileName(filePath: string): string { · 1426 unmodified lines
1642 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;1635 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;
1643}1636}
1644 1637
1638function ThreadFeedLoadEarlierControl(props: ThreadFeedHistoryControls) {
1639 const mutedColor = useThemeColor("--color-icon-subtle");
1640 const accentColor = useThemeColor("--color-primary");
1641 if (!props.hasMoreHistory && props.error === null) {
1642 return null;
1643 }
1644 return (
1645 <View className="mb-3 items-center gap-1.5 px-2">
1646 {props.hasMoreHistory ? (
1647 <Pressable
1648 accessibilityRole="button"
1649 accessibilityLabel="Load earlier activity"
1650 disabled={props.loading}
1651 onPress={props.onLoadEarlier}
1652 className="min-h-9 flex-row items-center justify-center gap-2 rounded-full border border-border/60 bg-surface/80 px-4 py-2 disabled:opacity-50"
1653 >
1654 {props.loading ? (
1655 <ActivityIndicator size="small" color={accentColor} />
1656 ) : (
1657 <SymbolView name="chevron.up" size={12} tintColor={accentColor} type="monochrome" />
1658 )}
1659 <Text className="text-sm font-medium text-foreground">
1660 {props.loading ? "Loading earlier activity…" : "Load earlier activity"}
1661 </Text>
1662 </Pressable>
1663 ) : null}
1664 {props.error !== null ? (
1665 <Text className="text-center text-xs" style={{ color: mutedColor }}>
1666 {props.error}
1667 </Text>
1668 ) : null}
1669 </View>
1670 );
1671}
1672
1645function ThreadFeedPlaceholder(props: {1673function ThreadFeedPlaceholder(props: {
1646 readonly bottomInset: number;1674 readonly bottomInset: number;
1647 readonly detail: string;1675 readonly detail: string;
@@ -1891,11 +1919,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 243 unmodified lines
1891 },1919 },
1892 [props.onHeaderMaterialVisibilityChange],1920 [props.onHeaderMaterialVisibilityChange],
1893 );1921 );
1894 // True while the viewport sits within ~one screen of the list end — the
1895 // only region where layout shifts should animate. Starts true because the
1896 // list opens pinned to the end.
1897 const nearListEnd = useSharedValue(true);
1898
1899 const handleScroll = useCallback(1922 const handleScroll = useCallback(
1900 (event: NativeSyntheticEvent<NativeScrollEvent>) => {1923 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1901 // anchorTopInset, not topContentInset: under automatic insets the list1924 // anchorTopInset, not topContentInset: under automatic insets the list
@@ -1903,9 +1926,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line
1903 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the1926 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
1904 // header height back or the material toggles a full header too late.1927 // header height back or the material toggles a full header too late.
1905 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);1928 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
1906 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
1907 nearListEnd.value =
1908 contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
1909 // LegendList recomputes its inset-aware end distance before invoking1929 // LegendList recomputes its inset-aware end distance before invoking
1910 // this handler, so getState() is current. Only the actual end re-arms1930 // this handler, so getState() is current. Only the actual end re-arms
1911 // follow: its broader maintain-scroll threshold is large enough for a1931 // follow: its broader maintain-scroll threshold is large enough for a
@@ -1921,13 +1941,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 9 unmodified lines
1921 });1941 });
1922 }1942 }
1923 },1943 },
1924 [1944 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
1925 reportHeaderMaterialVisibility,
1926 anchorTopInset,
1927 nearListEnd,
1928 props.listRef,
1929 transitionEndFollow,
1930 ],
1931 );1945 );
1932 const clearUserScrollSettle = useCallback(() => {1946 const clearUserScrollSettle = useCallback(() => {
1933 if (userScrollSettleTimerRef.current !== null) {1947 if (userScrollSettleTimerRef.current !== null) {
@@ -1999,33 +2013,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 65 unmodified lines
1999 }2013 }
2000 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);2014 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);
2001 2015
2002 // Gated variant of the 180ms feed layout slide. Instant while browsing
2003 // history: maintainVisibleContentPosition compensates the scroll offset in
2004 // the same frame a row's measured size lands, so an instant reposition is
2005 // invisible — animating it is exactly what made cold upward scrolls slide
2006 // and jump. Near the end the slide stays on: streaming growth and sends
2007 // shift rows at rest, where the animation is the thing preventing a hard
2008 // visual snap.
2009 const feedItemLayoutTransition = useMemo(() => {
2010 return (values: LayoutAnimationsValues) => {
2011 "worklet";
2012 const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0;
2013 return {
2014 initialValues: {
2015 originX: values.currentOriginX,
2016 originY: values.currentOriginY,
2017 width: values.currentWidth,
2018 height: values.currentHeight,
2019 },
2020 animations: {
2021 originX: withTiming(values.targetOriginX, { duration }),
2022 originY: withTiming(values.targetOriginY, { duration }),
2023 width: withTiming(values.targetWidth, { duration }),
2024 height: withTiming(values.targetHeight, { duration }),
2025 },
2026 };
2027 };
2028 }, [nearListEnd]);
2029 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {2016 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
2030 const nextWidth = Math.round(event.nativeEvent.layout.width);2017 const nextWidth = Math.round(event.nativeEvent.layout.width);
2031 const nextHeight = Math.round(event.nativeEvent.layout.height);2018 const nextHeight = Math.round(event.nativeEvent.layout.height);
@@ -2378,7 +2365,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 346 unmodified lines
2378 }2365 }
2379 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}2366 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
2380 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}2367 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2381 itemLayoutAnimation={feedItemLayoutTransition}
2382 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):2368 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
2383 // lets its scroll math clamp programmatic scrolls to -headerInset2369 // lets its scroll math clamp programmatic scrolls to -headerInset
2384 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short2370 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
@@ -2460,6 +2446,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 75 unmodified lines
2460 ListHeaderComponent={2446 ListHeaderComponent={
2461 <>2447 <>
2462 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}2448 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
2449 {props.historyControls ? (
2450 <ThreadFeedLoadEarlierControl {...props.historyControls} />
2451 ) : null}
2463 {props.topAccessory}2452 {props.topAccessory}
2464 </>2453 </>
2465 }2454 }
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+25−0
@@ -197,6 +197,30 @@ function ThreadRouteContent( · 196 unmodified lines
197 const gitActions = useSelectedThreadGitActions();197 const gitActions = useSelectedThreadGitActions();
198 const requests = useSelectedThreadRequests();198 const requests = useSelectedThreadRequests();
199 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");199 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
200 const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
201 label: "load earlier thread history",
202 reportFailure: false,
203 });
204 const historyControls = useMemo(() => {
205 const history = selectedThreadDetailState.history;
206 if (!selectedThread) {
207 return undefined;
208 }
209 if (!history.hasMoreHistory && history.error === null) {
210 return undefined;
211 }
212 return {
213 hasMoreHistory: history.hasMoreHistory,
214 loading: history.loading,
215 error: history.error,
216 onLoadEarlier: () => {
217 void loadEarlierHistory({
218 environmentId: selectedThread.environmentId,
219 input: { threadId: selectedThread.id },
220 });
221 },
222 };
223 }, [loadEarlierHistory, selectedThread, selectedThreadDetailState.history]);
200 const navigation = useNavigation();224 const navigation = useNavigation();
201 const params = props.route.params;225 const params = props.route.params;
202 const environmentIdRaw = firstRouteParam(params.environmentId);226 const environmentIdRaw = firstRouteParam(params.environmentId);
@@ -763,6 +787,7 @@ function ThreadRouteContent( · 560 unmodified lines
763 draftAttachments={composer.draftAttachments}787 draftAttachments={composer.draftAttachments}
764 connectionStateLabel={routeConnectionState}788 connectionStateLabel={routeConnectionState}
765 threadSyncStatus={selectedThreadDetailState.status}789 threadSyncStatus={selectedThreadDetailState.status}
790 historyControls={historyControls}
766 activeThreadBusy={composer.activeThreadBusy}791 activeThreadBusy={composer.activeThreadBusy}
767 canStopThread={composer.interruptibleRunId !== null}792 canStopThread={composer.interruptibleRunId !== null}
768 environmentId={selectedThread.environmentId}793 environmentId={selectedThread.environmentId}
apps/mobile/src/state/use-thread-outbox-drain.ts+1−0
@@ -750,6 +750,7 @@ export function useThreadOutboxDrain(): void { · 749 unmodified lines
750 runtimeMode: settings.runtimeMode,750 runtimeMode: settings.runtimeMode,
751 interactionMode: settings.interactionMode,751 interactionMode: settings.interactionMode,
752 createdAt: queuedMessage.createdAt,752 createdAt: queuedMessage.createdAt,
753 dispatchMode: "start",
753 },754 },
754 });755 });
755 const failure = reportFailure(deliveryResult, "start-turn");756 const failure = reportFailure(deliveryResult, "start-turn");

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t