Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/upstream-candidates.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"version": 1,
"candidates": [
{
"upstreamPr": 4018,
"sourceSha": "de8fd65934768173819b93adcd6b92af3e8c7fc3",
"status": "active",
"purpose": "Bound server thread history and lazily page older web activity"
},
{
"upstreamPr": 3510,
"sourceSha": "034f4936d7a1435887bb62ac3f2db61f08928cbf",
"status": "active",
"purpose": "Page mobile history and bound stale subscription catch-up"
},
{
"upstreamPr": 4176,
"sourceSha": "56b6615afdfe3804a466e33cbab9056b8981f217",
"status": "active",
"purpose": "Bound long-lived orchestration, browser, preview, and VCS in-memory state"
},
{
"upstreamPr": 4506,
"sourceSha": "f7eaa00b99e67a9c1caf09e2fe6ff1f536f66b91",
"status": "active",
"purpose": "Show answered provider questions in the web thread timeline"
},
{
"upstreamPr": 4245,
"sourceSha": "6be48eb238cf310a60cc9571240601e774c7d381",
"status": "active",
"purpose": "Queue follow-up messages server-side during active turns with explicit steer controls"
}
]
}
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export interface ThreadDetailScreenProps {
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
readonly activeThreadBusy: boolean;
readonly hasMoreOlderActivities: boolean;
readonly loadingOlderActivities: boolean;
readonly onLoadOlderActivities: () => void;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
Expand Down Expand Up @@ -372,6 +375,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
skills={selectedProviderSkills}
hasMoreOlder={props.hasMoreOlderActivities}
loadingOlder={props.loadingOlderActivities}
onLoadOlder={props.onLoadOlderActivities}
/>
</View>
) : (
Expand Down
21 changes: 20 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ export interface ThreadFeedProps {
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
/** Older history beyond the live activity window can be lazy-loaded on scroll-up. */
readonly hasMoreOlder?: boolean;
readonly loadingOlder?: boolean;
readonly onLoadOlder?: () => void;
}

function MessageAttachmentImage(props: {
Expand Down Expand Up @@ -1498,6 +1502,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
? props.latestTurn.turnId
: null;

// Reaching the top (oldest) lazy-loads older history. The hook keys an
// in-flight guard by thread, so repeated fires during scroll coalesce.
const { hasMoreOlder, loadingOlder, onLoadOlder } = props;
const onStartReachedOlderHistory = useCallback(() => {
if (hasMoreOlder && !loadingOlder) {
onLoadOlder?.();
}
}, [hasMoreOlder, loadingOlder, onLoadOlder]);

useEffect(() => {
const previous = previousLatestTurnRef.current;
previousLatestTurnRef.current = props.latestTurn;
Expand Down Expand Up @@ -1790,9 +1803,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
estimatedItemSize={180}
initialScrollAtEnd
onScroll={handleScroll}
onStartReached={onStartReachedOlderHistory}
onStartReachedThreshold={0.5}
scrollEventThrottle={16}
ListHeaderComponent={
usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
usesNativeAutomaticInsets && !loadingOlder ? null : (
<View style={{ height: usesNativeAutomaticInsets ? undefined : topContentInset }}>
{loadingOlder ? <ActivityIndicator style={{ marginTop: 8 }} /> : null}
</View>
)
}
contentContainerStyle={{
paddingTop: 12,
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,9 @@ function ThreadRouteContent(
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
activeThreadBusy={composer.activeThreadBusy}
hasMoreOlderActivities={composer.hasMoreOlderActivities}
loadingOlderActivities={composer.loadingOlderActivities}
onLoadOlderActivities={composer.onLoadOlderActivities}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ function makeThread(
archivedAt: null,
deletedAt: null,
messages: [],
queuedMessages: [],
pendingTurnStart: null,
proposedPlans: [],
activities: [],
checkpoints: [],
Expand Down
118 changes: 115 additions & 3 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useAtomValue } from "@effect/atom-react";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import {
CommandId,
MessageId,
type EnvironmentId,
type ModelSelection,
type OrchestrationThreadActivity,
type ProviderInteractionMode,
type RuntimeMode,
type ThreadId,
Expand Down Expand Up @@ -36,11 +37,15 @@ import {
useComposerDraft,
} from "./use-composer-drafts";
import { setPendingConnectionError } from "../state/use-remote-environment-registry";
import { orchestrationEnvironment } from "../state/orchestration";
import { useSelectedThreadDetail } from "../state/use-thread-detail";
import { useThreadSelection } from "../state/use-thread-selection";
import { useAtomCommand } from "./use-atom-command";
import { enqueueThreadOutboxMessage } from "./thread-outbox";
import { useThreadOutboxMessages } from "./use-thread-outbox";

const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = [];

export function appendReviewCommentToDraft(input: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
Expand Down Expand Up @@ -89,9 +94,113 @@ export function useThreadComposerState() {
() => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
[queuedMessagesByThreadKey, selectedThreadKey],
);

// ── Older-history lazy-load (mirrors web ChatView) ──────────────────────────
// The detail snapshot windows activities to the most recent page (the server
// sets `hasMoreActivities`); older pages are fetched on demand and prepended.
const [olderActivities, setOlderActivities] = useState<
ReadonlyArray<OrchestrationThreadActivity>
>([]);
const [olderLoaded, setOlderLoaded] = useState(false);
const [olderHasMore, setOlderHasMore] = useState(false);
const [loadingOlderActivities, setLoadingOlderActivities] = useState(false);
const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, {
reportFailure: false,
});

const activityRequestKey = selectedThreadShell
? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}`
: null;
const activityRequestKeyRef = useRef(activityRequestKey);
activityRequestKeyRef.current = activityRequestKey;
useEffect(() => {
setOlderActivities([]);
setOlderLoaded(false);
setOlderHasMore(false);
setLoadingOlderActivities(false);
}, [activityRequestKey]);

const liveActivities = selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES;
const mergedActivities = useMemo(
() => (olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities),
[olderActivities, liveActivities],
);
// Before any page is loaded, the server tells us whether older history exists.
const hasMoreOlderActivities = olderLoaded
? olderHasMore
: (selectedThreadDetail?.hasMoreActivities ?? false);

// Synchronous in-flight guard keyed by thread: the list fires onLoadOlder
// repeatedly while pinned at the top, but loading *state* only updates next
// render, so without this a fast scroll dispatches duplicate same-cursor calls.
const inFlightOlderKeyRef = useRef<string | null>(null);
const onLoadOlderActivities = useCallback(() => {
if (!selectedThreadShell || !hasMoreOlderActivities) {
return;
}
const oldestActivity = mergedActivities[0];
if (!oldestActivity || !activityRequestKey) {
return;
}
if (inFlightOlderKeyRef.current === activityRequestKey) {
return;
}
const cursorInput =
oldestActivity.sequence !== undefined
? { beforeSequence: oldestActivity.sequence }
: { beforeCreatedAt: oldestActivity.createdAt, beforeActivityId: oldestActivity.id };
const requestKey = activityRequestKey;
inFlightOlderKeyRef.current = requestKey;
setLoadingOlderActivities(true);
void loadThreadActivities({
environmentId: selectedThreadShell.environmentId,
input: { threadId: selectedThreadShell.id, ...cursorInput },
})
.then((result) => {
if (activityRequestKeyRef.current !== requestKey) {
return;
}
if (result._tag !== "Success") {
return;
}
const page = result.value;
setOlderActivities((prev) => {
// Dedup against both already-loaded older pages and the live window,
// since mobile merges everything into one array (duplicate ids would
// produce duplicate React keys in the feed).
const seen = new Set(prev.map((activity) => activity.id));
for (const activity of liveActivities) {
seen.add(activity.id);
}
const fresh = page.activities.filter((activity) => !seen.has(activity.id));
return [...fresh, ...prev];
});
setOlderLoaded(true);
setOlderHasMore(page.hasMore);
})
.finally(() => {
if (inFlightOlderKeyRef.current === requestKey) {
inFlightOlderKeyRef.current = null;
}
if (activityRequestKeyRef.current === requestKey) {
setLoadingOlderActivities(false);
}
});
}, [
selectedThreadShell,
hasMoreOlderActivities,
mergedActivities,
activityRequestKey,
liveActivities,
loadThreadActivities,
]);

const selectedThreadFeed = useMemo(
() => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),
[selectedThreadDetail],
() =>
selectedThreadDetail
? buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities })
: [],
[selectedThreadDetail, mergedActivities],
);

const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
Expand Down Expand Up @@ -299,6 +408,9 @@ export function useThreadComposerState() {
runtimeMode,
interactionMode,
activeThreadBusy,
hasMoreOlderActivities,
loadingOlderActivities,
onLoadOlderActivities,
onChangeDraftMessage,
onPickDraftImages,
onPasteIntoDraft,
Expand Down
Loading