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
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"expo-updates": "~56.0.19",
"expo-web-browser": "~56.0.5",
"expo-widgets": "~56.0.19",
"pako": "^2.1.0",
"punycode": "^2.3.1",
"react": "19.2.3",
"react-dom": "19.2.3",
Expand All @@ -116,6 +117,7 @@
"devDependencies": {
"@effect/vitest": "catalog:",
"@pierre/trees": "1.0.0-beta.4",
"@types/pako": "^2.0.3",
"@types/react": "~19.2.0",
"babel-preset-expo": "~56.0.0",
"tailwindcss": "^4.0.0",
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
OrchestrationThreadDetailSnapshot,
ServerConfig,
VcsListRefsResult,
WindowedOrchestrationThread,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -17,7 +18,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;
// Bumped 2 -> 3: the stored snapshot is now either a complete legacy detail
// snapshot or a bounded thread-sync-v2 window. Version-2 records fail decode
// and are evicted by loadDecodedCache, so upgrades take one clean resync.
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;

Expand All @@ -30,7 +34,7 @@ const StoredThreadSnapshot = Schema.Struct({
schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
environmentId: Schema.String,
threadId: Schema.String,
snapshot: OrchestrationThreadDetailSnapshot,
snapshot: Schema.Union([OrchestrationThreadDetailSnapshot, WindowedOrchestrationThread]),
});
const StoredServerConfig = Schema.Struct({
schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION),
Expand Down Expand Up @@ -150,7 +154,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
}),
),
saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) {
const threadId = snapshot.thread.id;
const threadId = "head" in snapshot ? snapshot.head.id : snapshot.thread.id;
const payload = yield* encodeStoredThreadSnapshot({
schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
environmentId,
Expand Down
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 @@ -72,6 +72,9 @@ export interface ThreadDetailScreenProps {
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly onLoadOlder: () => Promise<void>;
readonly hasOlder: boolean;
readonly loadingOlder: boolean;
readonly onOpenConnectionEditor: () => void;
readonly onChangeDraftMessage: (value: string) => void;
readonly onPickDraftImages: () => Promise<void>;
Expand Down Expand Up @@ -414,6 +417,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
layoutVariant={layoutVariant}
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
onLoadOlder={props.onLoadOlder}
hasOlder={props.hasOlder}
loadingOlder={props.loadingOlder}
skills={selectedProviderSkills}
/>
</View>
Expand Down
92 changes: 83 additions & 9 deletions apps/mobile/src/features/threads/ThreadFeed.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;

The payloadAsset filter in ThreadWorkLog silently drops media-bearing rows: activities with status === "neutral" and toolLike === true are removed before rendering, so any attachment on an in-progress or informational tool activity is never shown and the user has no way to open it. Consider preserving rows that carry a payloadAsset regardless of their status, or excluding only toolLike && status === "neutral" rows when they have no asset.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around line 139:

The `payloadAsset` filter in `ThreadWorkLog` silently drops media-bearing rows: activities with `status === "neutral"` and `toolLike === true` are removed before rendering, so any attachment on an in-progress or informational tool activity is never shown and the user has no way to open it. Consider preserving rows that carry a `payloadAsset` regardless of their status, or excluding only `toolLike && status === "neutral"` rows when they have no asset.

Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export interface ThreadFeedProps {
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly onLoadOlder: () => Promise<void>;
readonly hasOlder: boolean;
readonly loadingOlder: boolean;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
}

Expand All @@ -146,10 +149,41 @@ function MessageAttachmentImage(props: {
readonly className: string;
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
}) {
const uri = useAssetUrl(props.environmentId, {
_tag: "attachment",
attachmentId: props.attachmentId,
});
const [requested, setRequested] = useState(false);
const uri = useAssetUrl(
props.environmentId,
requested
? {
_tag: "attachment",
attachmentId: props.attachmentId,
}
: null,
);

// A resolution that never arrives (offline, URL-signing failure) must not
// strand the row on a spinner: fall back to the tappable placeholder so the
// user can retry.
useEffect(() => {
if (!requested || uri !== null) return;
const timeout = setTimeout(() => setRequested(false), 10_000);
return () => clearTimeout(timeout);
}, [requested, uri]);

if (!requested) {
return (
<TouchableOpacity
accessibilityRole="imagebutton"
accessibilityLabel="Load attached media"
activeOpacity={0.7}
onPress={() => setRequested(true)}
>
<View className={`${props.className} items-center justify-center gap-1.5`}>
<SymbolView name="photo" size={20} tintColor="#8a8a8a" type="monochrome" />
<Text className="font-t3-medium text-xs text-foreground-muted">Tap to load media</Text>
</View>
</TouchableOpacity>
);
}

if (uri === null) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return (
Expand Down Expand Up @@ -878,6 +912,7 @@ function renderFeedEntry(

return (
<ThreadWorkLog
environmentId={props.environmentId}
activities={entry.activities}
copiedRowId={props.copiedRowId}
expandedRows={props.expandedWorkRows}
Expand Down Expand Up @@ -1071,7 +1106,10 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
showsHorizontalScrollIndicator={false}
bounces={false}
className="border-t"
style={{ backgroundColor: props.colors.codeBackground, borderColor: props.colors.border }}
style={{
backgroundColor: props.colors.codeBackground,
borderColor: props.colors.border,
}}
contentContainerStyle={{ padding: 10 }}
>
<NativeText
Expand Down Expand Up @@ -1273,15 +1311,39 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
},
[props.onHeaderMaterialVisibilityChange],
);
const lastScrollOffsetRef = useRef<number | null>(null);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
// anchorTopInset, not topContentInset: under automatic insets the list
// rests at contentOffset.y = -headerHeight (the inset lives only in
// UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
// header height back or the material toggles a full header too late.
reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
const offset = event.nativeEvent.contentOffset.y;
reportHeaderMaterialVisibility(offset + anchorTopInset > 6);
// Thread-sync v2 pagination: nearing the top of the loaded window pulls
// the next page of older history (single-flight; loadingOlder gates it).
// Gated on UPWARD movement so a list shorter than the viewport — which
// rests inside the threshold — does not page eagerly on open or on
// unrelated scroll events.
const previousOffset = lastScrollOffsetRef.current;
lastScrollOffsetRef.current = offset;
const scrolledUpward = previousOffset !== null && offset < previousOffset;
if (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
scrolledUpward &&
props.hasOlder &&
!props.loadingOlder &&
offset + anchorTopInset < 180
) {
void props.onLoadOlder();
}
},
[reportHeaderMaterialVisibility, anchorTopInset],
[
props.hasOlder,
props.loadingOlder,
props.onLoadOlder,
reportHeaderMaterialVisibility,
anchorTopInset,
],
);
const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
Expand Down Expand Up @@ -1577,7 +1639,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// own layout math) collapses the scroll view's adjustedContentInset
// top to 0, leaving the iOS 26/27 scroll-edge effect no region to
// render into — which is why the header blur was missing on threads.
scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 },
scrollIndicatorInsets: {
top: 0,
left: 0,
right: 0,
bottom: 0,
},
}
: { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
Expand Down Expand Up @@ -1648,7 +1715,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onScroll={handleScroll}
scrollEventThrottle={16}
ListHeaderComponent={
usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
<View>
{usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
{props.loadingOlder ? (
<View className="items-center py-3">
<ActivityIndicator size="small" />
</View>
) : 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 @@ -699,6 +699,9 @@ function ThreadRouteContent(
draftAttachments={composer.draftAttachments}
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
onLoadOlder={selectedThreadDetailState.loadOlder ?? (() => Promise.resolve())}
hasOlder={selectedThreadDetailState.hasOlder ?? false}
loadingOlder={selectedThreadDetailState.loadingOlder ?? false}
activeThreadBusy={composer.activeThreadBusy}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
Expand Down
61 changes: 59 additions & 2 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,58 @@
import * as Haptics from "expo-haptics";
import { SymbolView, type SFSymbol } from "expo-symbols";
import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";
import { useEffect, useState } from "react";
import type { ActivityPayloadAssetReference, EnvironmentId } from "@t3tools/contracts";
import {
LayoutAnimation,
Linking,
Pressable,
ScrollView,
useColorScheme,
View,
} from "react-native";

import { AppText as Text } from "../../components/AppText";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
import Animated, { FadeIn } from "react-native-reanimated";
import { useAssetUrl } from "../../state/assets";

function ActivityPayloadAssetLink(props: {
readonly environmentId: EnvironmentId;
readonly asset: ActivityPayloadAssetReference;
}) {
const [requested, setRequested] = useState(false);
const uri = useAssetUrl(props.environmentId, requested ? props.asset.resource : null);
useEffect(() => {
if (uri === null) return;
void Linking.openURL(uri)
.catch(() => {})
.finally(() => setRequested(false));
}, [uri]);
// A lookup that never resolves (offline, signing failure) must not leave the
// link stuck on "Loading media…" — reset so the user can retry.
useEffect(() => {
if (!requested || uri !== null) return;
const timeout = setTimeout(() => setRequested(false), 10_000);
return () => clearTimeout(timeout);
}, [requested, uri]);

return (
<Pressable
accessibilityRole="link"
accessibilityLabel="Open activity media"
onPress={(event) => {
event.stopPropagation();
setRequested(true);
}}
className="rounded-md px-1.5 py-1"
>
<Text className="font-t3-medium text-3xs text-blue-600 dark:text-blue-400">
{requested ? "Loading media…" : "Open media"}
</Text>
</Pressable>
);
}

const WORK_LOG_LAYOUT_ANIMATION = {
duration: 180,
Expand Down Expand Up @@ -78,6 +125,7 @@ function isFreshRow(createdAt: string): boolean {
}

export function ThreadWorkLog(props: {
readonly environmentId: EnvironmentId;
readonly activities: ReadonlyArray<ThreadFeedActivity>;
readonly copiedRowId: string | null;
readonly expandedRows: Readonly<Record<string, boolean>>;
Expand All @@ -89,7 +137,10 @@ export function ThreadWorkLog(props: {
const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
const rows = props.activities
.filter((activity) => !(activity.toolLike && activity.status === "neutral"))
.map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) }));
.map((activity) => ({
...activity,
detail: compactActivityDetail(activity.detail),
}));

if (rows.length === 0) {
return null;
Expand Down Expand Up @@ -165,6 +216,12 @@ export function ThreadWorkLog(props: {
</Text>

<View className="shrink-0 flex-row items-center gap-px">
{row.payloadAsset ? (
<ActivityPayloadAssetLink
environmentId={props.environmentId}
asset={row.payloadAsset}
/>
) : null}
{props.copiedRowId === row.id ? (
<Text className="pr-1 font-t3-medium text-3xs text-emerald-600 dark:text-emerald-400">
Copied
Expand Down
15 changes: 14 additions & 1 deletion apps/mobile/src/lib/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Socket from "effect/unstable/socket/Socket";
import * as pako from "pako";

import { type CompressionCodec, RpcCompressionCodec } from "@t3tools/contracts";
import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";

import { cryptoLayer } from "../features/cloud/dpop";
Expand All @@ -16,17 +18,28 @@ function configuredRelayUrl(): string {

const httpClientLayer = remoteHttpClientLayer(fetch);

// RN gzip codec for the /ws RPC (Fix 1). pako runs synchronously on the JS
// thread; fine for one-shot thread sync.
const pakoCodec: CompressionCodec = {
compressSync: (b) => pako.gzip(b),
decompressSync: (b) => pako.inflate(b),
threshold: 1024,
};
Comment thread
cursor[bot] marked this conversation as resolved.
const compressionCodecLayer = Layer.succeed(RpcCompressionCodec, pakoCodec);

type RuntimeLayerSource =
| ReturnType<typeof managedRelayClientLayer>
| typeof Socket.layerWebSocketConstructorGlobal
| typeof compressionCodecLayer
| typeof cryptoLayer
| typeof httpClientLayer
| typeof Persistence.layer
| typeof tracingLayer;

const runtimeLayer = Layer.merge(
const runtimeLayer = Layer.mergeAll(
managedRelayClientLayer(configuredRelayUrl()),
Socket.layerWebSocketConstructorGlobal,
compressionCodecLayer,
).pipe(
Layer.provideMerge(cryptoLayer),
Layer.provideMerge(httpClientLayer),
Expand Down
Loading
Loading