Skip to content
Merged
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
12 changes: 8 additions & 4 deletions .github/upstream-candidates.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
{
"upstreamPr": 4018,
"sourceSha": "de8fd65934768173819b93adcd6b92af3e8c7fc3",
"status": "active",
"purpose": "Bound server thread history and lazily page older web activity"
"status": "superseded",
"purpose": "Bound server thread history and lazily page older web activity",
"supersededBy": "upstream #5493 (6b73b3def): user-anchored turn-window pagination",
"note": "Client usage removed in the 6b73b3def sync merge. The server getThreadActivities RPC is retained only for deployed mobile clients; remove it once fleet mobile builds predate it no longer."
},
{
"upstreamPr": 3510,
"sourceSha": "034f4936d7a1435887bb62ac3f2db61f08928cbf",
"status": "active",
"purpose": "Page mobile history and bound stale subscription catch-up"
"status": "partially-superseded",
"purpose": "Page mobile history and bound stale subscription catch-up",
"supersededBy": "upstream #5493 (6b73b3def): mobile paging replaced by loadEarlier turn windows",
"note": "The mobile scroll-up paging half was removed in the 6b73b3def sync merge. The server bounded-replay half in OrchestrationEngine remains active."
},
{
"upstreamPr": 4176,
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import * as Schema from "effect/Schema";
import * as MobileDatabase from "../persistence/mobile-database";

const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
// makes pre-pagination clients discard the record instead of decoding a
// partial thread as complete (rollback safety).
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;

Expand Down
9 changes: 3 additions & 6 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,10 @@ export interface ThreadDetailScreenProps {
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
/** A send made now would be held in the steering queue, not open a turn. */
readonly sendEntersQueue: 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 @@ -391,9 +390,7 @@ 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}
loadEarlier={props.loadEarlier ?? null}
/>
</View>
) : (
Expand Down
59 changes: 28 additions & 31 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,11 @@ 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;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: {
readonly loading: boolean;
readonly onLoadEarlier: () => void;
} | null;
}

function MessageAttachmentImage(props: {
Expand Down Expand Up @@ -1636,15 +1637,6 @@ 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 @@ -1980,22 +1972,25 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
alignItemsAtEnd
initialScrollAtEnd
onScroll={handleScroll}
onStartReached={onStartReachedOlderHistory}
onStartReachedThreshold={0.5}
onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16}
// Under automatic insets the spacer is UIKit's job, but the
// older-history spinner still belongs at the top of the content.
ListHeaderComponent={
usesNativeAutomaticInsets ? (
loadingOlder ? (
<ActivityIndicator style={{ marginTop: 8 }} />
) : null
) : (
<View style={{ height: topContentInset }}>
{loadingOlder ? <ActivityIndicator style={{ marginTop: 8 }} /> : null}
</View>
)
<>
{usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
{props.loadEarlier != null ? (
<Pressable
onPress={props.loadEarlier.onLoadEarlier}
disabled={props.loadEarlier.loading}
className="items-center py-2"
>
<Text className="text-xs text-foreground-secondary">
{props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"}
</Text>
</Pressable>
) : null}
</>
}
contentContainerStyle={{
paddingTop: 12,
Expand Down Expand Up @@ -2041,25 +2036,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
</View>
) : null}
</View>
{props.feed.length === 0 && hasMoreOlder ? (
{props.feed.length === 0 && props.loadEarlier != null ? (
// 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
// exists — 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 ? (
{props.loadEarlier.loading ? (
<ActivityIndicator />
) : (
<TouchableOpacity accessibilityRole="button" onPress={() => onLoadOlder?.()}>
<Text className="text-sm text-muted-foreground">Load older history</Text>
<TouchableOpacity
accessibilityRole="button"
onPress={props.loadEarlier.onLoadEarlier}
>
<Text className="text-sm text-muted-foreground">Load earlier turns</Text>
</TouchableOpacity>
)}
</View>
</View>
) : null}
{props.feed.length === 0 &&
!hasMoreOlder &&
props.loadEarlier == null &&
props.activeWorkStartedAt === null &&
props.contentPresentation.kind === "ready" ? (
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
Expand Down
26 changes: 20 additions & 6 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import * as Option from "effect/Option";
import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
import {
requestOlderThreadTurns,
threadHasOlderTurns,
} from "@t3tools/client-runtime/state/threads";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -190,13 +194,25 @@ function ThreadRouteContent(
useThreadSelection();
const selectedThreadDetailState = props.selectedThreadDetailState;
const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
// "Load earlier turns" header state for windowed (paginated) thread loads.
const loadEarlierTurns = useMemo(() => {
if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) {
return null;
}
return {
loading:
selectedThreadDetailState.page._tag === "Some" &&
selectedThreadDetailState.page.value.loadingOlder,
onLoadEarlier: () => {
requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id);
},
};
}, [selectedThread, selectedThreadDetailState]);
const { selectedThreadCwd } = useSelectedThreadWorktree();
const composer = useThreadComposerState();
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
// 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);
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const navigation = useNavigation();
const params = props.route.params;
Expand Down Expand Up @@ -788,11 +804,9 @@ function ThreadRouteContent(
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
loadEarlier={loadEarlierTurns}
sendEntersQueue={composer.sendEntersQueue}
composerQueueItems={composer.composerQueueItems}
hasMoreOlderActivities={composer.hasMoreOlderActivities}
loadingOlderActivities={composer.loadingOlderActivities}
onLoadOlderActivities={composer.onLoadOlderActivities}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/mobileSurfaceExistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("mobile surface existence (anti stack-drop)", () => {
// The feed and the chip list both read the promoted detail, so one piece
// of state moves the message and one revert puts it back.
expect(composerState).toContain("promoteSteeredQueuedMessages(selectedThreadDetail");
expect(composerState).toMatch(/buildThreadFeed\(\{ \.\.\.steeredDetail/);
expect(composerState).toMatch(/buildThreadFeed\(steeredDetail\)/);
expect(composerState).toMatch(/timelineIds = new Set\(steeredDetail\?\.messages/);
// Failure puts it back rather than leaving a bubble the agent never got.
expect(composerState).toMatch(
Expand Down
52 changes: 2 additions & 50 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ import {
} 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 { sendEntersSteeringQueue } from "@t3tools/shared/chatList";
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";

Expand Down Expand Up @@ -178,43 +174,6 @@ export function useThreadComposerState() {
const removeServerQueuedMessage = useAtomCommand(threadEnvironment.removeQueuedMessage, {
label: "remove queued message",
});
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,
});

// "Send now" promotes a queued message into the conversation before the
// server confirms the dispatch; the chip goes with it. See
// promoteSteeredQueuedMessages.
Expand All @@ -232,8 +191,8 @@ export function useThreadComposerState() {
if (!steeredDetail) {
return [];
}
return buildThreadFeed({ ...steeredDetail, activities: mergedActivities });
}, [steeredDetail, mergedActivities]);
return buildThreadFeed(steeredDetail);
}, [steeredDetail]);

const composerQueueItems = useMemo(() => {
type QueueItem = {
Expand Down Expand Up @@ -571,13 +530,6 @@ export function useThreadComposerState() {
runtimeMode,
interactionMode,
sendEntersQueue,
// 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
8 changes: 8 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export class GitWorkflowService extends Context.Service<
readonly cwd: string;
readonly remoteName: string;
}) => Effect.Effect<void, GitCommandError>;
readonly remoteExists: (input: {
readonly cwd: string;
readonly remoteName: string;
}) => Effect.Effect<boolean, GitCommandError>;
readonly resolveRemoteTrackingCommit: (input: {
readonly cwd: string;
readonly refName: string;
Expand Down Expand Up @@ -385,6 +389,10 @@ export const make = Effect.gen(function* () {
ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd, { allowBare: true }).pipe(
Effect.andThen(git.fetchRemote(input)),
),
remoteExists: (input) =>
ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe(
Effect.andThen(git.remoteExists(input)),
),
resolveRemoteTrackingCommit: (input) =>
ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd, {
allowBare: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
assert.deepEqual(settledRows, [
{ state: "completed", completedAt: "2026-01-01T00:01:00.000Z" },
]);

const threadRows = yield* sql<{ readonly latestTurnId: string | null }>`
SELECT latest_turn_id AS "latestTurnId"
FROM projection_threads
WHERE thread_id = ${threadId}
`;
assert.deepEqual(threadRows, [{ latestTurnId: turnId }]);
}),
);

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
event.payload.session.activeTurnId ?? existingRow.value.latestTurnId;
yield* projectionThreadRepository.upsert({
...existingRow.value,
// activeTurnId describes current work; a terminal session must not erase history.
latestTurnId: nextLatestTurnId,
updatedAt: event.occurredAt,
});
Expand Down
Loading
Loading