Skip to content
Closed
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
4 changes: 4 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { installPermissionUpdateNotice } from "./features/connection/permissionUpdateNotice";
import { installComposerAttachmentUploads } from "./state/composer-attachment-uploads";
import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery";
import { installThreadOfflineSync } from "./state/thread-prefetch";
import { installThreadCacheProtection } from "./state/thread-cache-protection";
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
Expand Down Expand Up @@ -90,7 +92,9 @@ declare const module: { readonly hot?: FoundationHotModule } | undefined;

const appServiceDisposers = [
installPermissionUpdateNotice(),
installThreadCacheProtection(),
installThreadOutboxDelivery(),
installThreadOfflineSync(),
installComposerAttachmentUploads(),
];
disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/connection/environment-cache-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ function makeDatabase() {
values.set(id, payload);
schemaVersions.set(id, schemaVersion);
}),
listThreadCacheIndex: () => Effect.succeed([]),
enforceThreadCacheBudget: () => Effect.succeed({ evicted: 0, bytes: 0 }),
setThreadCacheProtection: () => Effect.succeed(undefined),
removeCache: (environmentId, kind, cacheKey) =>
Effect.sync(() => {
const id = cacheId(environmentId, kind, cacheKey);
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
makeStoredShellSnapshotEncoder,
} from "@t3tools/client-runtime/platform";
import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -147,7 +148,14 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
snapshot,
}).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
yield* database
.saveCache(environmentId, "thread", threadId, COMPACT_THREAD_CACHE_SCHEMA_VERSION, payload)
.saveCache(
environmentId,
"thread",
threadId,
COMPACT_THREAD_CACHE_SCHEMA_VERSION,
payload,
DateTime.toEpochMillis(snapshot.projection.thread.updatedAt),
)
.pipe(Effect.mapError(mapDatabaseError("save-thread")));
}),
removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) =>
Expand Down
33 changes: 18 additions & 15 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ import {
FLOATING_WORKING_CONTROL_COVERAGE,
FloatingWorkingControl,
} from "./floating-working-control";
import { connectionFloatingStatus, type FloatingWorkingStatus } from "./floating-working-status";
import type { FloatingWorkingStatus } from "./floating-working-status";
import { useThreadConnectionChip } from "./use-thread-connection-chip";
import {
derivePendingUserInputMaxHeight,
ESTIMATED_KEYBOARD_HEIGHT,
Expand Down Expand Up @@ -610,16 +611,15 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
);
const connectionChip = useThreadConnectionChip({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
connectionState: props.connectionStateLabel,
connectionError: props.connectionError,
onReconnect: props.onReconnectEnvironment,
queuedCount: props.queuedMessages.length,
});
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
connectionState: props.connectionStateLabel,
environmentLabel: props.environmentLabel,
onReconnect: props.onReconnectEnvironment,
});
if (connectionStatus !== null && !props.draftMode) {
return connectionStatus;
}
if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
return null;
}
Expand Down Expand Up @@ -651,7 +651,11 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
if (props.isCompacting && contentPresentationKind === "ready") {
return { kind: "compacting" };
}
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
if (
props.activeWorkStartedAt !== null &&
contentPresentationKind === "ready" &&
(props.connectionStateLabel === "connected" || props.threadSyncStatus !== "cached")
) {
return { kind: "working", startedAt: props.activeWorkStartedAt };
}
if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
Expand Down Expand Up @@ -721,7 +725,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
agentsSegment !== null ||
devicePreviews.length > 0 ||
browserTabs.tabs.length > 0 ||
props.connectionStateLabel !== "connected" ||
connectionChip !== null ||
props.queuedMessages.length > 0 ||
props.selectedThreadFeed.some(
(entry) => "acknowledged" in entry && entry.acknowledged === true,
Expand Down Expand Up @@ -1298,6 +1302,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
worktreeSetup={props.worktreeSetup}
setupWorkingStartedAt={props.setupWorkingStartedAt}
queuedMessages={props.queuedMessages}
connectionState={props.connectionStateLabel}
dispatchingMessageId={props.dispatchingMessageId}
// A native subagent has no composer to edit a pending message in;
// Cancel on the edit banner would discard it.
Expand Down Expand Up @@ -1374,9 +1379,6 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
composerWidthStyle,
]}
>
{/* 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={handleComposerOverlayLayout}
Expand All @@ -1386,6 +1388,7 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
<FloatingWorkingControl
colorScheme={isDarkMode ? "dark" : "light"}
status={floatingStatus}
connectionChip={connectionChip}
lift={floatingControlLift}
devicePreview={
devicePreviews.length > 0
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
type ScheduledTaskId,
type ScopedThreadRef,
} from "@t3tools/contracts";
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { parseThreadLinkHref, resolveThreadLinkRef } from "@t3tools/shared/threadLinks";
Expand Down Expand Up @@ -329,6 +330,7 @@ export interface ThreadFeedProps {
readonly worktreeSetup?: WorktreeSetupCardProps | null;
readonly setupWorkingStartedAt?: string | null;
readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
readonly connectionState?: EnvironmentConnectionPhase;
readonly dispatchingMessageId: MessageId | null;
readonly onEditPendingMessage: ((message: QueuedThreadMessage) => void) | null;
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -1240,6 +1242,7 @@ function renderFeedEntry(
| "onUseArtifactTemplate"
| "skills"
| "dispatchingMessageId"
| "connectionState"
| "onEditPendingMessage"
| "threadId"
| "workspaceRoot"
Expand Down Expand Up @@ -1700,7 +1703,13 @@ function renderFeedEntry(
</Text>
) : null}
<Text className="font-t3-medium text-xs tabular-nums text-foreground-secondary">
{entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel}
{entry.pendingMessage && !entry.acknowledged
? props.connectionState !== "connected"
? "Sends when connected"
: entry.pendingMessage.messageId === props.dispatchingMessageId
? "Sending…"
: "Queued"
: timestampLabel}
</Text>
{props.onEditPendingMessage !== null &&
entry.pendingMessage &&
Expand Down Expand Up @@ -2635,6 +2644,7 @@ export const ThreadFeed = memo(function ThreadFeed({
worktreeSetup: props.worktreeSetup,
setupWorkingStartedAt: props.setupWorkingStartedAt,
dispatchingMessageId: props.dispatchingMessageId,
connected: props.connectionState === "connected",
unsettledTurnId,
copiedRowId,
expandedWorkGroups,
Expand Down Expand Up @@ -3075,6 +3085,7 @@ export const ThreadFeed = memo(function ThreadFeed({
<ThreadMediaVisibility>
{renderFeedEntry(info, {
environmentId: props.environmentId,
connectionState: props.connectionState,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
dispatchingMessageId: props.dispatchingMessageId,
onEditPendingMessage: props.onEditPendingMessage,
onUseArtifactTemplate: props.onUseArtifactTemplate,
Expand Down
59 changes: 27 additions & 32 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ const CONTROL_SEPARATION = (CONTROL_ROW_GAP + CONTROL_HEIGHT) / 2;
const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System);
const LABEL_EXITING = FadeOut.duration(100).reduceMotion(ReduceMotion.System);

// Expo reapplies glass after layout or reattachment, restoring material UIKit can lose behind visible labels.
const UniwindGlassView = withUniwind(GlassView, {
style: { fromClassName: "className" },
});
Expand All @@ -57,7 +56,6 @@ const UniwindGlassContainer = withUniwind(GlassContainer, {
});
const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView);

// Segment widths feed back into labelWidth; ignore sub-point changes to avoid rounding-driven ellipsis loops.
function settleWidth(next: number) {
return (current: number) => (Math.abs(current - next) < 1 ? current : next);
}
Expand All @@ -68,6 +66,7 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTRO
export function FloatingWorkingControl(props: {
readonly colorScheme: "light" | "dark";
readonly status: FloatingWorkingStatus | null;
readonly connectionChip: { readonly label: string; readonly onPress: () => void } | null;
readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null;
readonly browserPreview: { readonly count: number; readonly onPress: () => void } | null;
readonly showScrollToEnd: boolean;
Expand All @@ -81,13 +80,15 @@ export function FloatingWorkingControl(props: {
const { width: windowWidth } = useWindowDimensions();
const [overlayWidth, setOverlayWidth] = useState(windowWidth);
const [queueWidth, setQueueWidth] = useState(0);
const [connectionWidth, setConnectionWidth] = useState(0);
const [actionWidth, setActionWidth] = useState(0);
const action =
props.status?.kind === "background" || props.status?.kind === "lifecycle"
? props.status.action
: null;
const [agentsWidth, setAgentsWidth] = useState(0);
const hasQueue = props.queuedCount > 0;
const hasConnectionChip = props.connectionChip !== null;
const hasPreview = props.devicePreview !== null || props.browserPreview !== null;
const [previewWidth, setPreviewWidth] = useState(0);
const agents = props.agents;
Expand All @@ -99,6 +100,7 @@ export function FloatingWorkingControl(props: {
32 -
(action !== null ? actionWidth : 0) -
(hasQueue ? queueWidth : 0) -
(hasConnectionChip ? connectionWidth : 0) -
(hasAgents ? agentsWidth : 0) -
(hasPreview ? previewWidth : 0),
);
Expand All @@ -119,7 +121,6 @@ export function FloatingWorkingControl(props: {
opacity: separationProgress.value,
}));

// Animate an in-flow sizer for native glass layout. Measure labels at fixed width to avoid repeated animation retargeting.
const capsuleWidth = useSharedValue<number | null>(null);
const measuredWidthRef = useRef<number | null>(null);
const handleLabelLayout = (event: LayoutChangeEvent) => {
Expand All @@ -132,7 +133,7 @@ export function FloatingWorkingControl(props: {
capsuleWidth.set(first ? width : withTiming(width, CONTROL_TIMING));
};
const hasStatus = props.status !== null;
const hasCapsule = hasStatus || hasQueue || hasAgents || hasPreview;
const hasCapsule = hasStatus || hasConnectionChip || hasQueue || hasAgents || hasPreview;
useEffect(() => {
if (!hasStatus) {
measuredWidthRef.current = null;
Expand All @@ -156,11 +157,14 @@ export function FloatingWorkingControl(props: {
hasQueue ||
(props.devicePreview !== null && props.browserPreview !== null);
const statusInteractive =
props.status?.kind === "connection" ||
props.status?.kind === "child-input" ||
props.status?.kind === "background";
props.status?.kind === "child-input" || props.status?.kind === "background";
const capsuleInteractive =
statusInteractive || action !== null || hasQueue || hasAgents || hasPreview;
statusInteractive ||
hasConnectionChip ||
action !== null ||
hasQueue ||
hasAgents ||
hasPreview;
const statusContent =
props.status !== null ? (
<View
Expand Down Expand Up @@ -188,6 +192,21 @@ export function FloatingWorkingControl(props: {

const capsuleContent = (
<View className="flex-row items-center">
{props.connectionChip !== null ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={props.connectionChip.label}
onPress={props.connectionChip.onPress}
onLayout={(event) => setConnectionWidth(settleWidth(event.nativeEvent.layout.width))}
className="h-11 shrink flex-row items-center px-3 active:opacity-70"
style={{ maxWidth: labelWidth + connectionWidth }}
>
<View className="mr-2 h-2 w-2 rounded-full bg-red-500" />
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
{props.connectionChip.label}
</Text>
</Pressable>
) : null}
{statusContent}
{action !== null ? (
<View
Expand Down Expand Up @@ -403,30 +422,6 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "connection") {
return (
<StatusLabelRow
key="connection"
accessibilityLabel={props.status.label}
accessibilityRole="button"
className="gap-2"
onLayout={props.onLayout}
onPress={props.status.onPress}
>
{props.status.tone === "reconnecting" ? (
<AppActivityIndicator size="small" colorClassName="accent-icon-muted" />
) : (
<View className="h-2 w-2 rounded-full bg-red-500" />
)}
<Text
className="max-w-[260px] shrink font-t3-medium text-xs text-foreground"
numberOfLines={1}
>
{props.status.label}
</Text>
</StatusLabelRow>
);
}
if (props.status.kind === "lifecycle") {
return (
<StatusLabelRow
Expand Down
Loading
Loading