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
115 changes: 79 additions & 36 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ProviderInteractionMode,
RuntimeMode,
ServerConfig as T3ServerConfig,
ThreadTurnDeliveryMode,
} from "@t3tools/contracts";
import {
detectComposerTrigger,
Expand Down Expand Up @@ -114,7 +115,7 @@ export interface ThreadComposerProps {
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onSendMessage: (deliveryMode?: ThreadTurnDeliveryMode) => Promise<MessageId | null>;
readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void;
readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
Expand Down Expand Up @@ -332,7 +333,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
props.selectedThread.session?.status === "starting";

const sendLabel =
props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
showStopAction || props.connectionState !== "connected" || props.queueCount > 0
? "Queue"
: "Send";
const currentModelSelection = props.selectedThread.modelSelection;
const currentRuntimeMode = props.selectedThread.runtimeMode;
const connectionStatus = composerConnectionStatus({
Expand Down Expand Up @@ -536,31 +539,39 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
// ── Handle command selection ──────────────────────────────
const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;

const handleSend = useCallback(async () => {
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
if (inFlightThreadIdsRef.current.has(threadKey)) return;
inFlightThreadIdsRef.current.add(threadKey);
try {
await onSendMessage();
// Sending a prompt starts agent work: arm the lock-screen card while the
// app is foregrounded and the activity token can be registered. Armed
// after the send so its preference read and native Activity start don't
// contend with the queued-message feedback on the tap frame.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: props.environmentId,
threadTitle: props.selectedThread.title,
projectTitle: props.environmentLabel ?? "T3 Code",
});
} finally {
inFlightThreadIdsRef.current.delete(threadKey);
}
}, [
onSendMessage,
props.environmentId,
props.environmentLabel,
props.selectedThread.id,
props.selectedThread.title,
]);
const sendWithDeliveryMode = useCallback(
async (deliveryMode?: ThreadTurnDeliveryMode) => {
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
if (inFlightThreadIdsRef.current.has(threadKey)) return;
inFlightThreadIdsRef.current.add(threadKey);
try {
await onSendMessage(deliveryMode);
// Sending a prompt starts agent work: arm the lock-screen card while the
// app is foregrounded and the activity token can be registered. Armed
// after the send so native Activity setup does not contend with the
// queued-message feedback on the tap frame.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: props.environmentId,
threadTitle: props.selectedThread.title,
projectTitle: props.environmentLabel ?? "T3 Code",
});
} finally {
inFlightThreadIdsRef.current.delete(threadKey);
}
},
[
onSendMessage,
props.environmentId,
props.environmentLabel,
props.selectedThread.id,
props.selectedThread.title,
],
);
const handleSend = useCallback(async () => sendWithDeliveryMode(), [sendWithDeliveryMode]);
const handleSteer = useCallback(
async () => sendWithDeliveryMode("immediate"),
[sendWithDeliveryMode],
);
const handleCommandSelect = useCallback(
(item: ComposerCommandItem) => {
if (!composerTrigger) return;
Expand Down Expand Up @@ -838,7 +849,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
{!isExpanded ? (
<Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}>
{showStopAction ? (
<ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
<View className="flex-row gap-1.5">
<ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
{hasContent ? (
<ControlPill
accessibilityLabel="Queue message"
icon="arrow.up"
variant="primary"
onPress={handleSend}
/>
) : null}
</View>
) : (
<ControlPill
icon="arrow.up"
Expand Down Expand Up @@ -882,14 +903,36 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
/>
) : null}
</ComposerToolbarScroller>
<ComposerToolbarButton
accessibilityLabel={sendLabel}
icon="arrow.up"
variant="primary"
disabled={!canSend}
onPress={handleSend}
showChevron={false}
/>
{showStopAction ? (
<View className="flex-row gap-2">
<ComposerToolbarButton
accessibilityLabel="Steer active turn"
icon="arrow.up.right"
label="Steer"
disabled={!canSend}
onPress={handleSteer}
showChevron={false}
/>
<ComposerToolbarButton
accessibilityLabel="Queue message"
icon="arrow.up"
label="Queue"
variant="primary"
disabled={!canSend}
onPress={handleSend}
showChevron={false}
/>
</View>
) : (
<ComposerToolbarButton
accessibilityLabel={sendLabel}
icon="arrow.up"
variant="primary"
disabled={!canSend}
onPress={handleSend}
showChevron={false}
/>
)}
</ComposerToolbarRow>
) : null}
</ComposerSurface>
Expand Down
28 changes: 17 additions & 11 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
RuntimeMode,
ServerConfig as T3ServerConfig,
ThreadId,
ThreadTurnDeliveryMode,
UserInputQuestion,
} from "@t3tools/contracts";
import * as Haptics from "expo-haptics";
Expand Down Expand Up @@ -115,7 +116,8 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onSendMessage: (deliveryMode?: ThreadTurnDeliveryMode) => Promise<MessageId | null>;
readonly onCancelQueuedMessage: (messageId: MessageId) => void;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void;
Expand Down Expand Up @@ -515,17 +517,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
selectedThreadKey,
]);

const handleSendMessage = useCallback(async () => {
const targetThreadKey = selectedThreadKey;
const messageId = await props.onSendMessage();
if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
return messageId;
}
const handleSendMessage = useCallback(
async (deliveryMode?: ThreadTurnDeliveryMode) => {
const targetThreadKey = selectedThreadKey;
const messageId = await props.onSendMessage(deliveryMode);
if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
return messageId;
}

setAnchorMessageId(messageId);
composerEditorRef.current?.blur();
return messageId;
}, [props.onSendMessage, selectedThreadKey]);
setAnchorMessageId(messageId);
composerEditorRef.current?.blur();
return messageId;
},
[props.onSendMessage, selectedThreadKey],
);

const collapseComposer = useCallback(() => {
composerEditorRef.current?.blur();
Expand Down Expand Up @@ -605,6 +610,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onEndFollowEnabledChange={setEndFollowEnabled}
skills={selectedProviderSkills}
loadEarlier={props.loadEarlier ?? null}
onCancelQueuedMessage={props.onCancelQueuedMessage}
/>
</View>
) : (
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export interface ThreadFeedProps {
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
readonly onCancelQueuedMessage: (messageId: MessageId) => void;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
/** Non-null when older turns exist beyond the loaded window. */
readonly loadEarlier?: {
Expand Down Expand Up @@ -805,6 +806,7 @@ function renderFeedEntry(
readonly onToggleTurnFold: (turnId: TurnId) => void;
readonly onPressImage: (uri: string, headers?: Record<string, string>) => void;
readonly onMarkdownLinkPress: (href: string) => void;
readonly onCancelQueuedMessage: (messageId: MessageId) => void;
readonly iconSubtleColor: string | import("react-native").ColorValue;
readonly userBubbleColor: string | import("react-native").ColorValue;
readonly markdownStyles: MarkdownStyleSets;
Expand Down Expand Up @@ -918,6 +920,21 @@ function renderFeedEntry(
})}
</View>
<View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
{message.deliveryState === "queued" ? (
<>
<Text className="font-t3-medium text-xs text-foreground-muted">Queued</Text>
<Pressable
accessibilityLabel="Cancel queued message"
accessibilityRole="button"
hitSlop={6}
onPress={() => props.onCancelQueuedMessage(message.id)}
>
<Text className="font-t3-semibold text-xs text-red-600 dark:text-red-400">
Cancel
</Text>
</Pressable>
</>
) : null}
<Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400">
{timestampLabel}
</Text>
Expand Down Expand Up @@ -1804,6 +1821,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleTurnFold,
onPressImage,
onMarkdownLinkPress,
onCancelQueuedMessage: props.onCancelQueuedMessage,
iconSubtleColor,
userBubbleColor,
markdownStyles,
Expand All @@ -1825,6 +1843,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
userBubbleMaxWidth,
onCopyWorkRow,
onMarkdownLinkPress,
props.onCancelQueuedMessage,
onPressImage,
onToggleTurnFold,
onToggleWorkGroup,
Expand Down
23 changes: 22 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "@react-navigation/native";
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 { EnvironmentId, ThreadId, type MessageId, type ProjectScript } from "@t3tools/contracts";
import {
requestOlderThreadTurns,
threadHasOlderTurns,
Expand Down Expand Up @@ -214,6 +214,10 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const cancelQueuedTurn = useAtomCommand(
threadEnvironment.cancelQueuedTurn,
"queued message cancellation",
);
const navigation = useNavigation();
const params = props.route.params;
const environmentIdRaw = firstRouteParam(params.environmentId);
Expand Down Expand Up @@ -497,6 +501,22 @@ function ThreadRouteContent(
});
}, [interruptThreadTurn, selectedThread]);

const handleCancelQueuedMessage = useCallback(
(messageId: MessageId) => {
if (!selectedThread) {
return;
}
void cancelQueuedTurn({
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
messageId,
},
});
},
[cancelQueuedTurn, selectedThread],
);

const handleOpenTerminal = useCallback(
(nextTerminalId?: string | null) => {
terminalDebugLog("terminal-menu:open-existing", {
Expand Down Expand Up @@ -799,6 +819,7 @@ function ThreadRouteContent(
serverConfig={serverConfig}
onStopThread={handleStopThread}
onSendMessage={composer.onSendMessage}
onCancelQueuedMessage={handleCancelQueuedMessage}
onReconnectEnvironment={handleReconnectEnvironment}
onUpdateThreadModelSelection={composer.onUpdateModelSelection}
onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode}
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/projectThreadStartTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ProjectId,
type ProviderInteractionMode,
type RuntimeMode,
type ThreadTurnDeliveryMode,
} from "@t3tools/contracts";

import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages";
Expand All @@ -32,6 +33,7 @@ export interface ProjectThreadStartTurnSpec {
readonly modelSelection: ModelSelection;
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
readonly deliveryMode?: ThreadTurnDeliveryMode;
readonly workspaceMode: "local" | "worktree";
readonly branch: string | null;
readonly worktreePath: string | null;
Expand Down Expand Up @@ -61,6 +63,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
titleSeed: title,
runtimeMode: spec.runtimeMode,
interactionMode: spec.interactionMode,
...(spec.deliveryMode !== undefined ? { deliveryMode: spec.deliveryMode } : {}),
bootstrap: {
createThread: {
projectId: spec.projectId,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] {
return "message";
}
if (entry.activityKind === "runtime.warning") return "warning";
if (entry.activityKind === "provider.turn.steer.rejected") return "warning";
if (entry.requestKind === "command") return "command";
if (entry.requestKind === "file-read") return "eye";
if (entry.requestKind === "file-change") return "edit";
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ import {
ProjectId,
ProviderInteractionMode,
RuntimeMode,
ThreadTurnDeliveryMode,
ThreadId,
type ModelSelection as ModelSelectionType,
type ProjectId as ProjectIdType,
type ProviderInteractionMode as ProviderInteractionModeType,
type RuntimeMode as RuntimeModeType,
type ThreadTurnDeliveryMode as ThreadTurnDeliveryModeType,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";

import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
import { scopedThreadKey } from "../lib/scopedEntities";

const THREAD_OUTBOX_SCHEMA_VERSION = 3;
const THREAD_OUTBOX_SCHEMA_VERSION = 4;
const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;

const QueuedThreadCreationSchema = Schema.Struct({
Expand All @@ -37,7 +39,7 @@ const QueuedThreadCreationSchema = Schema.Struct({
});

export const QueuedThreadMessageSchema = Schema.Struct({
schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),
schemaVersion: Schema.Literals([1, 2, 3, THREAD_OUTBOX_SCHEMA_VERSION]),
environmentId: EnvironmentId,
threadId: ThreadId,
messageId: MessageId,
Expand All @@ -47,6 +49,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
modelSelection: Schema.optional(ModelSelection),
runtimeMode: Schema.optional(RuntimeMode),
interactionMode: Schema.optional(ProviderInteractionMode),
deliveryMode: Schema.optional(ThreadTurnDeliveryMode),
// Present when the queued item creates a brand-new thread (pending task)
// instead of appending a turn to an existing one.
creation: Schema.optional(QueuedThreadCreationSchema),
Expand Down Expand Up @@ -76,6 +79,7 @@ export interface QueuedThreadMessage {
readonly modelSelection?: ModelSelectionType;
readonly runtimeMode?: RuntimeModeType;
readonly interactionMode?: ProviderInteractionModeType;
readonly deliveryMode?: ThreadTurnDeliveryModeType;
readonly creation?: QueuedThreadCreation;
readonly createdAt: string;
}
Expand Down
Loading
Loading