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 @@
11 import { Connection } from "@t3tools/client-runtime/connection";
22 import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3−import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
3+import {
4+ boundedThreadSnapshotLoaderLayer,
5+ threadHistoryControllerLayer,
6+} from "@t3tools/client-runtime/state/threads";
47 import * as Layer from "effect/Layer";
58 import { Atom } from "effect/unstable/reactivity";
69
@@ -20,7 +23,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( · 13 unmodified lines
2023 Layer.provide(runtimeContextLayer),
2124 );
2225
23−const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
26+const snapshotLoaderLayer = Layer.mergeAll(
27+ boundedThreadSnapshotLoaderLayer,
28+ shellSnapshotLoaderLayer,
29+ threadHistoryControllerLayer,
30+);
2431
2532 type ConnectionLayerSource =
2633 | typeof Connection.layer
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4−1
@@ -79,7 +79,7 @@ import { · 78 unmodified lines
7979 COMPOSER_EXPANDED_CHROME,
8080 ThreadComposer,
8181 } from "./ThreadComposer";
82−import { ThreadFeed } from "./ThreadFeed";
82+import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
8383 import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";
8484 import { ThreadQueueControl } from "./ThreadQueueControl";
8585 import type { ThreadContentPresentation } from "./threadContentPresentation";
@@ -104,6 +104,8 @@ export interface ThreadDetailScreenProps { · 18 unmodified lines
104104 readonly connectionStateLabel: EnvironmentConnectionPhase;
105105 /** Message sync status for the selected thread (drives the composer status pill). */
106106 readonly threadSyncStatus?: EnvironmentThreadStatus;
107+ /** Progressive history controls for oversized mobile thread opens. */
108+ readonly historyControls?: ThreadFeedHistoryControls;
107109 readonly activeThreadBusy: boolean;
108110 readonly canStopThread: boolean;
109111 readonly environmentId: EnvironmentId;
@@ -650,6 +652,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 540 unmodified lines
650652 contentTopInset={0}
651653 contentBottomInset={estimatedOverlayHeight}
652654 contentMaxWidth={contentMaxWidth}
655+ historyControls={props.historyControls}
653656 topAccessory={
654657 <ThreadRelationshipsBanner
655658 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
5555 import { isPdfFile } from "../../lib/filePreview";
5656 import { PresentationSource } from "../../components/NativePresentation";
5757 import { useSafeAreaInsets } from "react-native-safe-area-context";
58−import Animated, {
59− FadeIn,
60− FadeInUp,
61− useSharedValue,
62− withTiming,
63− type LayoutAnimationsValues,
64− type SharedValue,
65−} from "react-native-reanimated";
58+import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
6659 import { useThemeColor } from "../../lib/useThemeColor";
6760 import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
6861 import { useFontFamily } from "../../lib/useFontFamily";
@@ -167,14 +160,6 @@ function formatMessageTime(input: string): string { · 98 unmodified lines
167160 return MESSAGE_TIME_FORMATTER.format(timestamp);
168161 }
169162
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.
176−const FEED_ITEM_LAYOUT_DURATION_MS = 180;
177−
178163 // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
179164 // classNames. The fold row's min-h-11 (44px) stays taller than its single
180165 // 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
194179 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
195180 }
196181
182+export interface ThreadFeedHistoryControls {
183+ readonly hasMoreHistory: boolean;
184+ readonly loading: boolean;
185+ readonly error: string | null;
186+ readonly onLoadEarlier: () => void;
187+}
188+
197189 export interface ThreadFeedProps {
198190 readonly environmentId: EnvironmentId;
199191 readonly threadId: ThreadId;
@@ -210,6 +202,7 @@ export interface ThreadFeedProps { · 10 unmodified lines
210202 readonly contentInsetEndAdjustment: SharedValue<number>;
211203 readonly contentTopInset?: number;
212204 readonly contentBottomInset?: number;
205+ readonly historyControls?: ThreadFeedHistoryControls;
213206 readonly topAccessory?: ReactNode;
214207 readonly contentMaxWidth?: number;
215208 readonly layoutVariant?: LayoutVariant;
@@ -1642,6 +1635,41 @@ function compactFileName(filePath: string): string { · 1426 unmodified lines
16421635 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;
16431636 }
16441637
1638+function 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+
16451673 function ThreadFeedPlaceholder(props: {
16461674 readonly bottomInset: number;
16471675 readonly detail: string;
@@ -1891,11 +1919,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 243 unmodified lines
18911919 },
18921920 [props.onHeaderMaterialVisibilityChange],
18931921 );
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−
18991922 const handleScroll = useCallback(
19001923 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
19011924 // anchorTopInset, not topContentInset: under automatic insets the list
@@ -1903,9 +1926,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line
19031926 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
19041927 // header height back or the material toggles a full header too late.
19051928 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;
19091929 // LegendList recomputes its inset-aware end distance before invoking
19101930 // this handler, so getState() is current. Only the actual end re-arms
19111931 // 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
19211941 });
19221942 }
19231943 },
1924− [
1925− reportHeaderMaterialVisibility,
1926− anchorTopInset,
1927− nearListEnd,
1928− props.listRef,
1929− transitionEndFollow,
1930− ],
1944+ [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
19311945 );
19321946 const clearUserScrollSettle = useCallback(() => {
19331947 if (userScrollSettleTimerRef.current !== null) {
@@ -1999,33 +2013,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 65 unmodified lines
19992013 }
20002014 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);
20012015
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]);
20292016 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
20302017 const nextWidth = Math.round(event.nativeEvent.layout.width);
20312018 const nextHeight = Math.round(event.nativeEvent.layout.height);
@@ -2378,7 +2365,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 346 unmodified lines
23782365 }
23792366 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
23802367 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2381− itemLayoutAnimation={feedItemLayoutTransition}
23822368 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
23832369 // lets its scroll math clamp programmatic scrolls to -headerInset
23842370 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
@@ -2460,6 +2446,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 75 unmodified lines
24602446 ListHeaderComponent={
24612447 <>
24622448 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
2449+ {props.historyControls ? (
2450+ <ThreadFeedLoadEarlierControl {...props.historyControls} />
2451+ ) : null}
24632452 {props.topAccessory}
24642453 </>
24652454 }
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+25−0
@@ -197,6 +197,30 @@ function ThreadRouteContent( · 196 unmodified lines
197197 const gitActions = useSelectedThreadGitActions();
198198 const requests = useSelectedThreadRequests();
199199 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]);
200224 const navigation = useNavigation();
201225 const params = props.route.params;
202226 const environmentIdRaw = firstRouteParam(params.environmentId);
@@ -763,6 +787,7 @@ function ThreadRouteContent( · 560 unmodified lines
763787 draftAttachments={composer.draftAttachments}
764788 connectionStateLabel={routeConnectionState}
765789 threadSyncStatus={selectedThreadDetailState.status}
790+ historyControls={historyControls}
766791 activeThreadBusy={composer.activeThreadBusy}
767792 canStopThread={composer.interruptibleRunId !== null}
768793 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
750750 runtimeMode: settings.runtimeMode,
751751 interactionMode: settings.interactionMode,
752752 createdAt: queuedMessage.createdAt,
753+ dispatchMode: "start",
753754 },
754755 });
755756 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