Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
331c6dc
fix(server): skip origin fetch when creating worktrees in repos witho…
t3dotgg Aug 7, 2026
ea50b69
fix(web): update tooltip no longer dismisses when scrolling release n…
t3dotgg Aug 7, 2026
0ec4fbc
fix(server): stop showing commit/push/PR notices as errors in the wor…
t3dotgg Aug 7, 2026
64a991a
fix(web): show remote environment for non-Git projects (#5555)
t3dotgg Aug 7, 2026
c471145
fix(server): stopping a Claude thread no longer shows an ede_diagnost…
t3dotgg Aug 7, 2026
6da9224
fix(web): show one toast when snoozing threads in bulk (#5560)
t3dotgg Aug 7, 2026
7aad791
fix(server): let stopped threads settle immediately (#5553)
t3dotgg Aug 7, 2026
6b73b3d
feat: paginate thread loading with user-anchored turn windows (#5493)
t3dotgg Aug 7, 2026
ae7b27d
fix: prevent reconnect loops during server stalls (#5561)
gfsaaser24 Aug 7, 2026
6fa4576
fix(server): settle stopped Claude subagents (#5568)
t3dotgg Aug 7, 2026
1c7d059
fix: scrolling up during a running thread no longer snaps back to the…
t3dotgg Aug 7, 2026
2160eb5
sync(upstream): merge upstream/main 1c7d059f5 into fork/dev
github-actions[bot] Aug 7, 2026
c64a989
fix(client-runtime): align connection tests with upstream thread sync
github-actions[bot] Aug 7, 2026
abe9952
fix(mobile): restore fork thread surface markers after upstream sync
github-actions[bot] Aug 7, 2026
79e23a4
fix(server): record upstream migration 37 in fork migration bootstrap
github-actions[bot] Aug 7, 2026
2eeed17
fix(server): restore identity source projection and streaming shell skip
github-actions[bot] Aug 7, 2026
a39e979
fix(server): include message source_json in windowed thread detail reads
github-actions[bot] Aug 7, 2026
4274e79
fix(server): skip origin fetch when bootstrap has no origin remote
github-actions[bot] Aug 7, 2026
d102c76
fix(server): default remoteExists mock for bootstrap tests
github-actions[bot] Aug 7, 2026
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
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
11 changes: 8 additions & 3 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export interface ThreadComposerProps {
readonly threadSyncPhase?: "loading" | "syncing" | null;
readonly selectedThread: OrchestrationThreadShell;
readonly serverConfig: T3ServerConfig | null;
readonly queueCount: number;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectCwd: string | null;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
Expand All @@ -112,7 +114,7 @@ export interface ThreadComposerProps {
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onStartNewThread: () => void;
readonly onStartNewThread?: () => void;
readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
Expand Down Expand Up @@ -269,6 +271,9 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
});

export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
// Upstream surface: queue depth + busy gate used by detail screen.
void props.queueCount;
void props.activeThreadBusy;
const isDarkMode = useColorScheme() === "dark";
const foregroundColor = useThemeColor("--color-foreground");
const bodyText = useScaledTextRole("body");
Expand Down Expand Up @@ -530,7 +535,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const handleSend = useCallback(async () => {
if (parseStandaloneComposerSlashCommand(draftMessage) === "new") {
onChangeDraftMessage("");
onStartNewThread();
onStartNewThread?.();
return;
}
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
Expand Down Expand Up @@ -588,7 +593,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
);
setComposerSelection({ start: result.cursor, end: result.cursor });
onChangeDraftMessage(result.text);
onStartNewThread();
onStartNewThread?.();
return;
}

Expand Down
193 changes: 86 additions & 107 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
} from "@t3tools/contracts";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, StyleSheet, View, type GestureResponderEvent } from "react-native";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand All @@ -32,7 +32,6 @@ import type {
PendingUserInputDraftAnswer,
ThreadFeedEntry,
} from "../../lib/threadActivity";
import { ComposerQueuedMessages } from "./ComposerQueuedMessages";
import { PendingApprovalCard } from "./PendingApprovalCard";
import { PendingUserInputCard } from "./PendingUserInputCard";
import {
Expand Down Expand Up @@ -62,14 +61,15 @@ export interface ThreadDetailScreenProps {
readonly connectionStateLabel: EnvironmentConnectionPhase;
/** Message sync status for the selected thread (drives the composer status pill). */
readonly threadSyncStatus?: EnvironmentThreadStatus;
/** 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;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
/** True when the next send will be held in the server steering queue. */
readonly sendEntersQueue?: boolean;
readonly activeThreadBusy: boolean;
readonly environmentId: EnvironmentId;
readonly projectWorkspaceRoot: string | null;
readonly threadCwd: string | null;
readonly selectedThreadQueueCount: number;
readonly serverConfig: T3ServerConfig | null;
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
Expand All @@ -81,16 +81,6 @@ export interface ThreadDetailScreenProps {
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly composerQueueItems: ReadonlyArray<{
readonly messageId: MessageId;
readonly text: string;
readonly attachmentCount: number;
readonly deliveryState: "waiting" | "sending" | "queued";
readonly queueSource: "local" | "server";
}>;
readonly onSteerQueuedMessage: (messageId: MessageId) => Promise<void>;
readonly onEditQueuedMessage: (messageId: MessageId, source: "local" | "server") => Promise<void>;
readonly onStartNewThread: () => void;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
Expand Down Expand Up @@ -257,11 +247,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}, [freeze, selectedThreadKey]);

useEffect(() => {
// Anchor as soon as the target row exists in the feed — including local
// outbox "Sending" bubbles painted before thread detail has finished loading.
if (
anchorMessageId === null ||
lastScrolledAnchorMessageIdRef.current === anchorMessageId ||
contentPresentationKind !== "ready" ||
!selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId)
) {
return;
Expand Down Expand Up @@ -300,9 +289,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
});
});
return () => cancelAnimationFrame(frame);
}, [anchorMessageId, freeze, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey]);
}, [
anchorMessageId,
freeze,
contentPresentationKind,
selectedThreadFeed,
scrollMessageToEnd,
selectedThreadKey,
]);

const sendEntersQueue = props.sendEntersQueue;
const sendEntersQueue = props.sendEntersQueue === true;
const handleSendMessage = useCallback(async () => {
const targetThreadKey = selectedThreadKey;
const sendWillQueue = sendEntersQueue;
Expand Down Expand Up @@ -391,100 +387,83 @@ 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>
) : (
<View className="flex-1" />
)}

{/*
Pin the composer to the bottom of a full-screen overlay host.
KeyboardStickyView only applies translateY for the IME — it must sit in a
full-height column (not `position: absolute; bottom: 0` on itself), or a
stale keyboard height leaves the input floating mid-thread with the feed
scrolling behind it.
*/}
{/* Floating composer — sticks to keyboard via KeyboardStickyView */}
{showContent ? (
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
<View pointerEvents="none" style={{ flex: 1 }} />
<KeyboardStickyView offset={{ closed: 0, opened: 0 }}>
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
className="shrink-0 gap-3 px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
>
{props.activePendingApproval ? (
<PendingApprovalCard
approval={props.activePendingApproval}
respondingApprovalId={props.respondingApprovalId}
onRespond={props.onRespondToApproval}
/>
) : null}
{props.activePendingUserInput ? (
<PendingUserInputCard
pendingUserInput={props.activePendingUserInput}
drafts={props.activePendingUserInputDrafts}
answers={props.activePendingUserInputAnswers}
respondingUserInputId={props.respondingUserInputId}
onSelectOption={props.onSelectUserInputOption}
onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
onSubmit={props.onSubmitUserInput}
/>
) : null}
</Animated.View>
) : null}
<ComposerQueuedMessages
items={props.composerQueueItems}
disabled={props.connectionStateLabel !== "connected"}
onSteer={(messageId) => {
void props.onSteerQueuedMessage(messageId);
}}
onEdit={(messageId, source) => {
void props.onEditQueuedMessage(messageId, source);
}}
/>
</View>

<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
connectionError={props.connectionError}
environmentLabel={props.environmentLabel}
threadSyncPhase={threadSyncPhase}
selectedThread={props.selectedThread}
serverConfig={props.serverConfig}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
onNativePasteImages={props.onNativePasteImages}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onStartNewThread={props.onStartNewThread}
onReconnectEnvironment={props.onReconnectEnvironment}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
/>
<KeyboardStickyView
style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
offset={{ closed: 0, opened: 0 }}
>
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
className="shrink-0 gap-3 px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
>
{props.activePendingApproval ? (
<PendingApprovalCard
approval={props.activePendingApproval}
respondingApprovalId={props.respondingApprovalId}
onRespond={props.onRespondToApproval}
/>
) : null}
{props.activePendingUserInput ? (
<PendingUserInputCard
pendingUserInput={props.activePendingUserInput}
drafts={props.activePendingUserInputDrafts}
answers={props.activePendingUserInputAnswers}
respondingUserInputId={props.respondingUserInputId}
onSelectOption={props.onSelectUserInputOption}
onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
onSubmit={props.onSubmitUserInput}
/>
) : null}
</Animated.View>
) : null}
</View>
</KeyboardStickyView>
</View>

<ThreadComposer
editorRef={composerEditorRef}
draftMessage={props.draftMessage}
draftAttachments={props.draftAttachments}
placeholder="Ask the repo agent, or run a command…"
contentMaxWidth={contentMaxWidth}
connectionState={props.connectionStateLabel}
connectionError={props.connectionError}
environmentLabel={props.environmentLabel}
threadSyncPhase={threadSyncPhase}
selectedThread={props.selectedThread}
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onPickDraftImages={props.onPickDraftImages}
onNativePasteImages={props.onNativePasteImages}
onRemoveDraftImage={props.onRemoveDraftImage}
onStopThread={props.onStopThread}
onSendMessage={handleSendMessage}
onReconnectEnvironment={props.onReconnectEnvironment}
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
/>
</View>
</KeyboardStickyView>
) : null}
</View>
);
Expand Down
Loading
Loading