Skip to content
Open
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
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 @@ -371,6 +374,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
39 changes: 38 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,17 +1803,41 @@ 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,
paddingHorizontal: contentHorizontalPadding,
}}
/>
</View>
{props.feed.length === 0 && hasMoreOlder ? (
// The window can derive zero visible entries while older history
// exists — without scrollable content `onStartReached` can never
// fire, so give the user an explicit affordance instead of the
// empty-state placeholder.
<View style={StyleSheet.absoluteFill}>
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
{loadingOlder ? (
<ActivityIndicator />
) : (
<TouchableOpacity accessibilityRole="button" onPress={() => onLoadOlder?.()}>
<Text className="text-sm text-muted-foreground">Load older history</Text>
</TouchableOpacity>
)}
</View>
</View>
) : null}
{props.feed.length === 0 &&
!hasMoreOlder &&
props.activeWorkStartedAt === null &&
props.contentPresentation.kind === "ready" ? (
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ function ThreadRouteContent(
const composer = useThreadComposerState();
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
// Derive pending requests from the FULL loaded set (older pages + live
// window) so a prompt the user scrolled back to load still surfaces.
const requests = useSelectedThreadRequests(composer.mergedActivities);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const navigation = useNavigation();
const params = props.route.params;
Expand Down Expand Up @@ -767,6 +769,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
35 changes: 27 additions & 8 deletions apps/mobile/src/state/use-selected-thread-requests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useAtomValue } from "@effect/atom-react";
import { useCallback, useMemo, useState } from "react";

import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";
import {
ApprovalRequestId,
type OrchestrationThreadActivity,
type ProviderApprovalDecision,
} from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";

import { threadEnvironment } from "../state/threads";
Expand Down Expand Up @@ -53,7 +57,18 @@ function setUserInputDraftCustomAnswer(
});
}

export function useSelectedThreadRequests() {
/**
* Pending approval / user-input requests for the selected thread.
*
* `activities` should be the FULL loaded set (lazy-loaded older pages + the
* windowed live view, i.e. `useThreadComposerState().mergedActivities`): the
* detail snapshot windows activities to the most recent page, so deriving from
* `selectedThread.activities` alone would hide a prompt the user scrolled back
* to load. Falls back to the live window when not provided. Deriving from the
* merged set is sound — resolutions are always newer than their requests, so a
* loaded request whose resolution exists always has that resolution loaded too.
*/
export function useSelectedThreadRequests(activities?: ReadonlyArray<OrchestrationThreadActivity>) {
const respondToApproval = useAtomCommand(
threadEnvironment.respondToApproval,
"thread approval response",
Expand All @@ -70,16 +85,20 @@ export function useSelectedThreadRequests() {
null,
);

const requestActivities = activities ?? selectedThread?.activities ?? null;
const activePendingApprovals = useMemo(
() => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []),
[selectedThread],
() => (requestActivities ? derivePendingApprovals(requestActivities) : []),
[requestActivities],
);
const activePendingApproval = activePendingApprovals[0] ?? null;
// The derivations sort ascending by createdAt; surface the NEWEST open
// request. With lazy-loaded older pages in the set, index 0 could be an
// ancient dangling request hijacking the prompt for the current one.
const activePendingApproval = activePendingApprovals.at(-1) ?? null;
const activePendingUserInputs = useMemo(
() => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []),
[selectedThread],
() => (requestActivities ? derivePendingUserInputs(requestActivities) : []),
[requestActivities],
);
const activePendingUserInput = activePendingUserInputs[0] ?? null;
const activePendingUserInput = activePendingUserInputs.at(-1) ?? null;
const activePendingUserInputDrafts =
activePendingUserInput && selectedThreadShell
? (userInputDraftsByRequestKey[
Expand Down
68 changes: 66 additions & 2 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import {
MessageId,
type EnvironmentId,
type ModelSelection,
type OrchestrationThreadActivity,
type ProviderInteractionMode,
type RuntimeMode,
type ThreadId,
} from "@t3tools/contracts";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import {
useOlderThreadActivities,
type OlderActivitiesCursor,
} from "@t3tools/client-runtime/state/older-thread-activities";
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";

import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
Expand All @@ -36,11 +42,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 +99,56 @@ export function useThreadComposerState() {
() => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
[queuedMessagesByThreadKey, selectedThreadKey],
);

// ── Older-history lazy-load (shared engine; see useOlderThreadActivities) ──
// The detail snapshot windows activities to the most recent page (the server
// sets `hasMoreActivities`); older pages are fetched on demand and prepended.
const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, {
reportFailure: false,
});
const selectedEnvironmentIdForActivities = selectedThreadShell?.environmentId ?? null;
const selectedThreadIdForActivities = selectedThreadShell?.id ?? null;
const loadOlderActivitiesPage = useCallback(
async (cursor: OlderActivitiesCursor) => {
if (selectedEnvironmentIdForActivities === null || selectedThreadIdForActivities === null) {
return null;
}
const result = await loadThreadActivities({
environmentId: selectedEnvironmentIdForActivities,
input: { threadId: selectedThreadIdForActivities, ...cursor },
});
if (result._tag !== "Success") {
// Surface real failures (a spinner that quietly gives up reads as
// missing history); keep `hasMore` so scrolling back retries.
if (!isAtomCommandInterrupted(result)) {
setPendingConnectionError("Could not load older thread history.");
}
return null;
}
return result.value;
},
[selectedEnvironmentIdForActivities, selectedThreadIdForActivities, loadThreadActivities],
);
const {
mergedActivities,
hasMoreOlder: hasMoreOlderActivities,
loadingOlder: loadingOlderActivities,
loadOlder: onLoadOlderActivities,
} = useOlderThreadActivities({
threadKey: selectedThreadShell
? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}`
: null,
liveActivities: selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES,
hasMoreLiveActivities: selectedThreadDetail?.hasMoreActivities ?? false,
loadPage: loadOlderActivitiesPage,
});

const selectedThreadFeed = useMemo(
() => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),
[selectedThreadDetail],
() =>
selectedThreadDetail
? buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities })
: [],
[selectedThreadDetail, mergedActivities],
Comment thread
cursor[bot] marked this conversation as resolved.
);

const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
Expand Down Expand Up @@ -299,6 +356,13 @@ export function useThreadComposerState() {
runtimeMode,
interactionMode,
activeThreadBusy,
// Lazy-loaded older pages + the live window — the full loaded activity set.
// Request derivations must run over this (not the windowed live set alone)
// so prompts pulled in by scroll-up still surface, matching web.
mergedActivities,
hasMoreOlderActivities,
loadingOlderActivities,
onLoadOlderActivities,
onChangeDraftMessage,
onPickDraftImages,
onPasteIntoDraft,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -185,6 +187,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -268,6 +272,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -336,6 +342,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down Expand Up @@ -389,6 +397,8 @@ describe("CheckpointDiffQuery.layer", () => {
Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
getCommandReadModel: () =>
Effect.die("CheckpointDiffQuery should not request the command read model"),
getThreadActivitiesPage: () =>
Effect.die("CheckpointDiffQuery should not request thread activities"),
getSnapshot: () =>
Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
getShellSnapshot: () =>
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/cli/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,9 @@ const dispatchLiveOrchestrationCommand = (

const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
return yield* projectionSnapshotQuery.getSnapshot();
// Project resolution only reads `.projects`; the command read model returns the
// same shape without loading the heavy per-thread activity/message tables.
return yield* projectionSnapshotQuery.getCommandReadModel();
});

const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ describe("OrchestrationEngine", () => {
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
getThreadActivitiesPage: () => Effect.die("unused"),
}),
),
Layer.provide(
Expand Down
Loading
Loading