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
4 changes: 4 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { usePendingThreadOrder } from "../../state/thread-order";
import { threadListEnvironmentsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
import { useMessageDraftCounts } from "../../state/messageDrafts";
import {
ThreadListV2GroupSectionHeader,
ThreadListV2PendingRow,
Expand Down Expand Up @@ -298,6 +299,7 @@ export function HomeScreen(props: HomeScreenProps) {
const selectedThreadKey = primaryColumn?.selectedThreadKey ?? null;
const fullSwipeWidth = primaryColumn && columnMetrics ? columnMetrics.width - 20 : undefined;
const queuedThreadKeys = useQueuedThreadKeys();
const draftCounts = useMessageDraftCounts();
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const homeScrollGesture = useNativeGesture();
const insets = useSafeAreaInsets();
Expand Down Expand Up @@ -641,6 +643,7 @@ export function HomeScreen(props: HomeScreenProps) {
snoozeLabelNow: `${nowMinute}:00.000Z`,
snoozeEnvironmentIds,
queuedThreadKeys,
draftCounts,
moveAvailability: threadMoveAvailability,
shelfPreferencesLoading: !shelfPreferencesLoaded,
}))();
Expand Down Expand Up @@ -714,6 +717,7 @@ export function HomeScreen(props: HomeScreenProps) {
thread={thread}
variant={item.item.variant}
hasQueuedMessages={item.hasQueuedMessages}
draftCount={item.draftCount}
snoozed={item.item.snoozed}
pinned={item.item.pinned}
hidden={item.item.hidden}
Expand Down
104 changes: 78 additions & 26 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { Menu } from "../../components/ui/Menu";
import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch";
import {
findComposerSendMenuAction,
resolveComposerSendMenu,
type ComposerSendMenuAction,
type ComposerSendMenuItem,
} from "@t3tools/client-runtime/state/send-menu";
import { composerSendMenuActions } from "./composerSendMenu";
import type { FollowUpBehavior } from "../../lib/followUpBehavior";
import {
resolveComposerSendPresentation,
Expand Down Expand Up @@ -178,6 +185,15 @@ export interface ThreadComposerProps {
readonly existingAttachments: ReadonlyArray<ChatAttachment>;
readonly saving: boolean;
readonly onRemoveExistingAttachment: (attachmentId: string) => void;
readonly onCancel: () => void;
} | null;
/** Absent where the server has no drafts or this connection cannot save them. */
readonly messageDrafts?: {
readonly drafts: ReadonlyArray<{ readonly id: string; readonly text: string }>;
readonly busy: boolean;
readonly onSaveDraft: () => void;
readonly onMoveQueuedEditToDrafts: () => void;
readonly onOpenDraft: (draftId: string) => void;
} | null;
readonly followUpBehavior: FollowUpBehavior;
readonly canSteerActiveTurn: boolean;
Expand Down Expand Up @@ -249,47 +265,36 @@ const FOLLOW_UP_ACTION_LABEL = {
restart: "Restart turn",
} as const;

const FOLLOW_UP_ACTION_SUBTITLE = {
queue: "Run after the current turn",
steer: "Interrupt what the agent is doing",
restart: "Start the turn over with this message",
} as const;

function SendActionButton(props: {
readonly accessibilityLabel: string;
readonly presentation: ComposerSendPresentation;
readonly disabled: boolean;
readonly onSend: (followUp?: ActiveTurnComposerAction) => void;
readonly menuItems: ReadonlyArray<ComposerSendMenuItem>;
readonly onSend: () => void;
readonly onMenuAction: (action: ComposerSendMenuAction) => void;
}) {
const { presentation } = props;
const { presentation, menuItems } = props;
const button = (
<ComposerActionButton
accessibilityLabel={props.accessibilityLabel}
icon={presentation.icon}
variant="primary"
disabled={props.disabled}
onPress={() => props.onSend()}
onPress={props.onSend}
/>
);
if (!presentation.offersFollowUpChoice || presentation.action === null || props.disabled) {
return button;
}
const actions = [presentation.action, presentation.alternate].filter(
(action): action is ActiveTurnComposerAction => action !== null,
);
if (menuItems.length === 0) return button;
const menu = composerSendMenuActions(menuItems, Platform.OS === "ios" ? "ios" : "android");
return (
<Menu
accessibilityLabel="Choose how to send this message"
shouldOpenOnLongPress
actions={actions.map((action) => ({
id: action,
title: FOLLOW_UP_ACTION_LABEL[action],
subtitle: FOLLOW_UP_ACTION_SUBTITLE[action],
state: action === presentation.action ? ("on" as const) : ("off" as const),
}))}
onPressAction={({ nativeEvent }) =>
props.onSend(nativeEvent.event as ActiveTurnComposerAction)
}
{...(menu.title ? { title: menu.title } : {})}
actions={menu.actions}
onPressAction={({ nativeEvent }) => {
const action = findComposerSendMenuAction(menuItems, nativeEvent.event);
if (action) props.onMenuAction(action);
}}
>
{button}
</Menu>
Expand Down Expand Up @@ -621,6 +626,49 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
}
onEditorFocusChange?.(false);
};
const messageDrafts = props.messageDrafts ?? null;
const sendMenuItems =
hasContent && (!canSend || messageDrafts?.busy === true)
? []
: resolveComposerSendMenu({
hasContent,
running: props.activeThreadBusy,
canSteer: props.canSteerActiveTurn,
activeTurnDefault: props.followUpBehavior,
// Mobile has no way to start a sibling thread from the thread composer.
canSendInNewThread: false,
canSaveDraft: messageDrafts !== null && !messageDrafts.busy,
isEditingQueuedMessage: queuedEdit !== null,
drafts: messageDrafts?.busy === false ? messageDrafts.drafts : [],
});
const handleSendMenuAction = (action: ComposerSendMenuAction) => {
switch (action.type) {
case "send":
case "update-queued":
void handleSend();
return;
case "queue":
void handleSend(props.activeThreadBusy && props.canSteerActiveTurn ? "queue" : undefined);
return;
case "steer":
void handleSend("steer");
return;
case "save-draft":
messageDrafts?.onSaveDraft();
return;
case "move-to-drafts":
messageDrafts?.onMoveQueuedEditToDrafts();
return;
case "cancel-edit":
queuedEdit?.onCancel();
return;
case "open-draft":
messageDrafts?.onOpenDraft(action.draftId);
return;
case "send-in-new-thread":
return;
}
};
const handleSend = async (followUp?: ActiveTurnComposerAction) => {
if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return;
if (usageLimitsOffered && isUsageLimitsCommand(draftMessage) && draftAttachments.length === 0) {
Expand Down Expand Up @@ -1070,7 +1118,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
accessibilityLabel={sendBlockedReason ?? sendLabel}
presentation={sendPresentation}
disabled={!canSend}
onSend={handleSend}
menuItems={sendMenuItems}
onSend={() => void handleSend()}
onMenuAction={handleSendMenuAction}
/>
)}
</View>
Expand Down Expand Up @@ -1179,7 +1229,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
accessibilityLabel={sendBlockedReason ?? sendLabel}
presentation={sendPresentation}
disabled={!canSend}
onSend={handleSend}
menuItems={sendMenuItems}
onSend={() => void handleSend()}
onMenuAction={handleSendMenuAction}
/>
) : null}
</View>
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { LegendListRef } from "@legendapp/list/react-native";
import { HeaderHeightContext } from "@react-navigation/elements";
import { NativeLayoutObserver } from "../../native/NativeLayoutObserver";
import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions";
import { MessageDraftId } from "@t3tools/contracts";
import type {
EnvironmentId,
MessageId,
Expand Down Expand Up @@ -153,6 +154,9 @@ import { ComposerPopoverHost } from "./ComposerPopoverHost";
import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
import { useThreadTurnSubagents } from "./ThreadAgentsSheet";
import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit";
import { ThreadDraftsTray } from "./ThreadDraftsTray";
import { defaultMessageDraftSendMode } from "./messageDraftPresentation";
import { useThreadMessageDrafts } from "./useThreadMessageDrafts";
import { useThreadQueuedCount } from "./ThreadQueueControl";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
Expand Down Expand Up @@ -479,6 +483,13 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
const composerEditorRef = useRef<ComposerEditorHandle>(null);
const isProviderSubagent = isProviderNativeSubagentThread(props.selectedThread.source);
const editingRunId = props.queuedRunEdit?.runId ?? null;
const messageDrafts = useThreadMessageDrafts({
environmentId: props.environmentId,
threadId: props.selectedThread.id,
serverConfig: props.serverConfig,
onOpened: () => composerEditorRef.current?.focus(),
});
const draftsEnabled = messageDrafts.canEdit && props.canOperateThread && !isProviderSubagent;
useEffect(() => {
if (editingRunId === null) return;
const frame = requestAnimationFrame(() => composerEditorRef.current?.focus());
Expand Down Expand Up @@ -1268,6 +1279,21 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
/>
</Animated.View>
) : null}
{draftsEnabled && props.queuedRunEdit === null ? (
<ThreadDraftsTray
drafts={messageDrafts.drafts}
disabled={messageDrafts.busy}
sendMode={defaultMessageDraftSendMode({
running: props.activeThreadBusy,
canSteer: props.canSteerActiveTurn,
followUpBehavior: props.followUpBehavior,
})}
canSteer={props.activeThreadBusy && props.canSteerActiveTurn}
onOpen={(draft) => void messageDrafts.open(draft.id)}
onSend={(draft, mode) => void messageDrafts.send(draft.id, mode)}
onDelete={(draft) => void messageDrafts.remove(draft.id)}
/>
) : null}
<UsageLimitRecoveryCard
key={props.selectedThread.latestRun?.runId}
thread={props.selectedThread}
Expand Down Expand Up @@ -1466,7 +1492,23 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
existingAttachments: props.queuedRunEdit.existingAttachments,
saving: props.isSavingQueuedEdit,
onRemoveExistingAttachment: props.onRemoveQueuedEditAttachment,
onCancel: () => {
voiceInputSession.cancel(props.composerDraftKey);
props.onCancelQueuedRunEdit();
},
}
}
messageDrafts={
draftsEnabled
? {
drafts: messageDrafts.drafts,
busy: messageDrafts.busy,
onSaveDraft: () => void messageDrafts.saveComposer(),
onMoveQueuedEditToDrafts: () => void messageDrafts.moveQueuedEdit(),
onOpenDraft: (draftId) =>
void messageDrafts.open(MessageDraftId.make(draftId)),
}
: null
}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
Expand Down
Loading