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
16 changes: 1 addition & 15 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ 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;
Expand Down Expand Up @@ -314,10 +313,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
props.selectedThread.session?.status === "running" ||
props.selectedThread.session?.status === "starting";

const sendLabel =
props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0
? "Queue"
: "Send";
const sendLabel = "Send";
const currentModelSelection = props.selectedThread.modelSelection;
const currentRuntimeMode = props.selectedThread.runtimeMode;
const currentInteractionMode = props.selectedThread.interactionMode ?? "default";
Expand Down Expand Up @@ -991,16 +987,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</ComposerToolbarRow>
</Animated.View>
) : null}

{/* Queue count */}
{props.queueCount > 0 ? (
<Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(120)}>
<Text className="pt-2 text-xs text-foreground-muted">
{props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
automatically.
</Text>
</Animated.View>
) : null}
</Animated.View>

<ImageViewing
Expand Down
21 changes: 10 additions & 11 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ export interface ThreadDetailScreenProps {
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 @@ -80,6 +79,11 @@ export interface ThreadDetailScreenProps {
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onSteerQueuedMessage: (messageId: MessageId) => Promise<void>;
readonly onRemoveQueuedMessage: (
messageId: MessageId,
source: "local" | "server",
) => Promise<void>;
readonly onStartNewThread: () => void;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
Expand Down Expand Up @@ -247,10 +251,11 @@ 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 @@ -289,14 +294,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
});
});
return () => cancelAnimationFrame(frame);
}, [
anchorMessageId,
freeze,
contentPresentationKind,
selectedThreadFeed,
scrollMessageToEnd,
selectedThreadKey,
]);
}, [anchorMessageId, freeze, selectedThreadFeed, scrollMessageToEnd, selectedThreadKey]);

const handleSendMessage = useCallback(async () => {
const targetThreadKey = selectedThreadKey;
Expand Down Expand Up @@ -382,6 +380,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
hasMoreOlder={props.hasMoreOlderActivities}
loadingOlder={props.loadingOlderActivities}
onLoadOlder={props.onLoadOlderActivities}
onSteerQueuedMessage={props.onSteerQueuedMessage}
onRemoveQueuedMessage={props.onRemoveQueuedMessage}
/>
</View>
) : (
Expand Down Expand Up @@ -439,7 +439,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
threadSyncPhase={threadSyncPhase}
selectedThread={props.selectedThread}
serverConfig={props.serverConfig}
queueCount={props.selectedThreadQueueCount}
activeThreadBusy={props.activeThreadBusy}
environmentId={props.environmentId}
projectCwd={props.projectWorkspaceRoot}
Expand Down
76 changes: 74 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Haptics from "expo-haptics";
import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
import { type LegendListRef } from "@legendapp/list/react-native";
import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
import { MessageId, type EnvironmentId, type ThreadId, type TurnId } from "@t3tools/contracts";
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import { SymbolView } from "../../components/AppSymbol";
Expand Down Expand Up @@ -86,6 +86,7 @@ import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCo
import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
import {
deriveQueuedMessageControls,
deriveThreadFeedPresentation,
type ThreadFeedEntry,
type ThreadFeedLatestTurn,
Expand Down Expand Up @@ -146,6 +147,11 @@ export interface ThreadFeedProps {
readonly hasMoreOlder?: boolean;
readonly loadingOlder?: boolean;
readonly onLoadOlder?: () => void;
readonly onSteerQueuedMessage: (messageId: MessageId) => Promise<void>;
readonly onRemoveQueuedMessage: (
messageId: MessageId,
source: "local" | "server",
) => Promise<void>;
}

function MessageAttachmentImage(props: {
Expand Down Expand Up @@ -801,7 +807,10 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe

function renderFeedEntry(
info: { item: ThreadFeedEntry; index: number },
props: Pick<ThreadFeedProps, "environmentId" | "skills"> & {
props: Pick<
ThreadFeedProps,
"environmentId" | "skills" | "onSteerQueuedMessage" | "onRemoveQueuedMessage"
> & {
readonly copiedRowId: string | null;
readonly expandedWorkRows: Record<string, boolean>;
readonly terminalAssistantMessageIds: ReadonlySet<string>;
Expand Down Expand Up @@ -867,6 +876,10 @@ function renderFeedEntry(
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
const attachments = message.attachments ?? [];
const previewAttachments = entry.previewAttachments ?? [];
const deliveryState = entry.deliveryState;
const queueSource = entry.queueSource;
const queueControls = deriveQueuedMessageControls(deliveryState, queueSource);
const hasReviewCommentContext = message.text.includes("<review_comment");
const assistantTurnStillInProgress =
message.role === "assistant" &&
Expand All @@ -884,6 +897,7 @@ function renderFeedEntry(
<Animated.View
className="mb-5 items-end"
{...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
style={deliveryState ? { opacity: 0.58 } : undefined}
>
<View
className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
Expand Down Expand Up @@ -913,8 +927,62 @@ function renderFeedEntry(
/>
);
})}
{previewAttachments.map((attachment) => (
<Image
key={attachment.id}
source={{ uri: attachment.previewUri }}
className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
resizeMode="cover"
/>
))}
</View>
<View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
{deliveryState === "sending" ? (
<>
<ActivityIndicator size="small" color={iconSubtleColor} />
<NativeText className="font-t3-medium text-xs text-foreground-muted">
Sending
</NativeText>
</>
) : deliveryState === "waiting" ? (
<NativeText className="font-t3-medium text-xs text-foreground-muted">
Waiting for connection
</NativeText>
) : deliveryState === "queued" ? (
<NativeText className="font-t3-medium text-xs text-foreground-muted">
Queued on server
</NativeText>
) : null}
{queueControls.canSteer ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Send queued message now"
hitSlop={8}
onPress={() => void props.onSteerQueuedMessage(MessageId.make(message.id))}
className="min-h-8 justify-center rounded-full px-2"
>
<NativeText className="font-t3-semibold text-xs text-accent">Send now</NativeText>
</Pressable>
) : null}
{queueControls.canRemove && queueSource ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={
queueSource === "server"
? "Remove queued message"
: "Discard offline queued message"
}
hitSlop={8}
onPress={() =>
void props.onRemoveQueuedMessage(MessageId.make(message.id), queueSource)
}
className="min-h-8 justify-center rounded-full px-2"
>
<NativeText className="font-t3-semibold text-xs text-destructive">
{queueSource === "local" ? "Discard" : "Remove"}
</NativeText>
</Pressable>
) : null}
<Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400">
{timestampLabel}
</Text>
Expand Down Expand Up @@ -1722,6 +1790,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleTurnFold,
onPressImage,
onMarkdownLinkPress,
onSteerQueuedMessage: props.onSteerQueuedMessage,
onRemoveQueuedMessage: props.onRemoveQueuedMessage,
iconSubtleColor,
userBubbleColor,
markdownStyles,
Expand All @@ -1743,6 +1813,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
userBubbleMaxWidth,
onCopyWorkRow,
onMarkdownLinkPress,
props.onSteerQueuedMessage,
props.onRemoveQueuedMessage,
onPressImage,
onToggleTurnFold,
onToggleWorkGroup,
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,6 @@ function ThreadRouteContent(
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
selectedThreadQueueCount={composer.selectedThreadQueueCount}
layoutVariant={layout.variant}
usesAutomaticContentInsets={usesNativeHeaderGlass}
onOpenConnectionEditor={handleOpenConnectionEditor}
Expand All @@ -804,6 +803,8 @@ function ThreadRouteContent(
serverConfig={serverConfig}
onStopThread={handleStopThread}
onSendMessage={composer.onSendMessage}
onSteerQueuedMessage={composer.onSteerQueuedMessage}
onRemoveQueuedMessage={composer.onRemoveQueuedMessage}
onStartNewThread={handleStartNewThread}
onReconnectEnvironment={handleReconnectEnvironment}
onUpdateThreadModelSelection={composer.onUpdateModelSelection}
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,35 @@ import {

import {
buildThreadFeed,
deriveQueuedMessageControls,
deriveThreadFeedPresentation,
type ThreadFeedActivity,
type ThreadFeedEntry,
} from "./threadActivity";

describe("deriveQueuedMessageControls", () => {
it("allows steering or removing server-queued messages", () => {
expect(deriveQueuedMessageControls("queued", "server")).toEqual({
canSteer: true,
canRemove: true,
});
});

it("allows discarding an offline local-outbox message", () => {
expect(deriveQueuedMessageControls("waiting", "local")).toEqual({
canSteer: false,
canRemove: true,
});
});

it("does not claim an in-flight local send can still be cancelled", () => {
expect(deriveQueuedMessageControls("sending", "local")).toEqual({
canSteer: false,
canRemove: false,
});
});
});

function makeActivity(
input: Partial<OrchestrationThreadActivity> &
Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { deriveResolvedUserInputTranscripts } from "@t3tools/shared/userInputTra
import * as Arr from "effect/Array";
import * as Order from "effect/Order";

import type { DraftComposerImageAttachment } from "./composerImages";

export interface PendingApproval {
readonly requestId: ApprovalRequestId;
readonly requestKind: "command" | "file-read" | "file-change";
Expand Down Expand Up @@ -94,6 +96,9 @@ type RawThreadFeedEntry =
readonly id: string;
readonly createdAt: string;
readonly message: OrchestrationThread["messages"][number];
readonly deliveryState?: "waiting" | "sending" | "queued";
readonly queueSource?: "local" | "server";
readonly previewAttachments?: ReadonlyArray<DraftComposerImageAttachment>;
}
| {
readonly type: "activity";
Expand Down Expand Up @@ -136,6 +141,18 @@ export type ThreadFeedEntry =
readonly expanded: boolean;
};

export function deriveQueuedMessageControls(
deliveryState: "waiting" | "sending" | "queued" | undefined,
queueSource: "local" | "server" | undefined,
): { readonly canSteer: boolean; readonly canRemove: boolean } {
return {
canSteer: deliveryState === "queued" && queueSource === "server",
canRemove:
(deliveryState === "queued" && queueSource === "server") ||
(deliveryState === "waiting" && queueSource === "local"),
};
}

export type ThreadFeedLatestTurn = Pick<
OrchestrationLatestTurn,
"turnId" | "state" | "startedAt" | "completedAt"
Expand Down
22 changes: 16 additions & 6 deletions apps/mobile/src/state/thread-outbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,24 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
return loadPromise;
};

const enqueue = (message: QueuedThreadMessage): Promise<void> =>
serialize(async () => {
const enqueue = (message: QueuedThreadMessage): Promise<void> => {
// Paint the optimistic bubble immediately. Disk durability trails the
// in-memory queue so send UX never waits on filesystem latency.
setMessages([
...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
message,
]);
return serialize(async () => {
// Dropped while the write was queued (e.g. user discarded / delivered).
if (!currentMessages().some((candidate) => candidate.messageId === message.messageId)) {
return;
}
try {
await options.storage.write(message);
} catch (cause) {
setMessages(
currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
);
throw new ThreadOutboxManagerError({
operation: "enqueue",
environmentId: message.environmentId,
Expand All @@ -101,11 +114,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
cause,
});
}
setMessages([
...currentMessages().filter((candidate) => candidate.messageId !== message.messageId),
message,
]);
});
};

// Rewrites an already-queued message. A no-op when the message has been
// removed in the meantime (e.g. deleted or delivered), so a trailing editor
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ export function resolveThreadOutboxDeliveryAction(input: {
readonly threadExists: boolean;
readonly shellStatus: EnvironmentShellStatus;
readonly environmentConnected: boolean;
readonly threadBusy: boolean;
}): ThreadOutboxDeliveryAction {
if (input.isCreation) {
// A pending task creates its thread on delivery. If the thread already
Expand All @@ -169,7 +168,10 @@ export function resolveThreadOutboxDeliveryAction(input: {
if (!input.threadExists) {
return input.shellStatus === "live" ? "remove" : "wait";
}
return input.environmentConnected && !input.threadBusy ? "send" : "wait";
// Once connected, hand ownership to the server immediately. The server
// persists follow-ups that arrive during an active turn; this local outbox
// is only the offline/transport safety boundary.
return input.environmentConnected ? "send" : "wait";
}

/**
Expand Down
Loading
Loading