Skip to content

Commit 6306e29

Browse files
authored
feat(mobile): reorder and edit queued messages in place (port #3242) (#3466)
1 parent dc71243 commit 6306e29

7 files changed

Lines changed: 579 additions & 36 deletions

File tree

apps/mobile/src/app/task/[id].tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "@/features/tasks/hooks/useMessagingMode";
4141
import { taskKeys } from "@/features/tasks/hooks/useTasks";
4242
import {
43+
type MoveDirection,
4344
type QueuedMessage,
4445
useMessageQueueStore,
4546
} from "@/features/tasks/stores/messageQueueStore";
@@ -109,6 +110,7 @@ export default function TaskDetailScreen() {
109110
getSessionForTask,
110111
setFocusedTaskId,
111112
steerQueuedMessage,
113+
flushQueuedMessagesIfIdle,
112114
stopRun,
113115
} = useTaskSessionStore();
114116

@@ -174,6 +176,9 @@ export default function TaskDetailScreen() {
174176

175177
const messagingMode = useMessagingMode(taskId);
176178
const queuedCount = useQueuedCount(taskId);
179+
const editingQueuedId = useMessageQueueStore((s) =>
180+
taskId ? s.editingByTaskId[taskId] : undefined,
181+
);
177182
const toggleMessagingMode = useToggleMessagingMode(taskId);
178183
const analytics = useAnalytics();
179184

@@ -359,6 +364,18 @@ export default function TaskDetailScreen() {
359364
if (!taskId) return;
360365
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
361366

367+
// Saving an in-place edit: overwrite the queued message and release the
368+
// drain hold. If the turn already ended while editing, flush now — the
369+
// turn-end drain won't fire again on its own.
370+
const queue = useMessageQueueStore.getState();
371+
const editingId = queue.editingByTaskId[taskId];
372+
if (editingId) {
373+
queue.update(taskId, editingId, { content: text, attachments });
374+
queue.clearEditing(taskId);
375+
flushQueuedMessagesIfIdle(taskId);
376+
return;
377+
}
378+
362379
if (session?.terminalStatus) {
363380
handleSendAfterTerminal(text, attachments);
364381
return;
@@ -398,6 +415,7 @@ export default function TaskDetailScreen() {
398415
messagingMode,
399416
handleSendAfterTerminal,
400417
trackPromptSent,
418+
flushQueuedMessagesIfIdle,
401419
],
402420
);
403421

@@ -422,10 +440,13 @@ export default function TaskDetailScreen() {
422440
[taskId, steerQueuedMessage, trackPromptSent],
423441
);
424442

425-
const handleReturnQueuedToComposer = useCallback(
443+
// Pull a queued message into the composer for an in-place edit. It stays in
444+
// the queue at its position (marked as the edit target); the next send saves
445+
// it back rather than sending a new prompt.
446+
const handleEditQueued = useCallback(
426447
(message: QueuedMessage) => {
427448
if (!taskId) return;
428-
useMessageQueueStore.getState().remove(taskId, message.id);
449+
useMessageQueueStore.getState().setEditing(taskId, message.id);
429450
setRestoredDraft({
430451
text: message.content,
431452
attachments: message.attachments,
@@ -434,10 +455,29 @@ export default function TaskDetailScreen() {
434455
[taskId],
435456
);
436457

458+
const handleCancelEdit = useCallback(() => {
459+
if (!taskId) return;
460+
useMessageQueueStore.getState().clearEditing(taskId);
461+
setRestoredDraft({ text: "", attachments: [] });
462+
flushQueuedMessagesIfIdle(taskId);
463+
}, [taskId, flushQueuedMessagesIfIdle]);
464+
465+
const handleMoveQueued = useCallback(
466+
(message: QueuedMessage, direction: MoveDirection) => {
467+
if (!taskId) return;
468+
Haptics.selectionAsync();
469+
useMessageQueueStore.getState().move(taskId, message.id, direction);
470+
},
471+
[taskId],
472+
);
473+
437474
const handleDiscardQueued = useCallback(
438475
(message: QueuedMessage) => {
439476
if (!taskId) return;
477+
const wasEditing =
478+
useMessageQueueStore.getState().editingByTaskId[taskId] === message.id;
440479
useMessageQueueStore.getState().remove(taskId, message.id);
480+
if (wasEditing) setRestoredDraft({ text: "", attachments: [] });
441481
},
442482
[taskId],
443483
);
@@ -718,13 +758,16 @@ export default function TaskDetailScreen() {
718758
!session?.terminalStatus
719759
}
720760
onSteer={handleSteerQueued}
721-
onReturnToComposer={handleReturnQueuedToComposer}
761+
onEdit={handleEditQueued}
722762
onDiscard={handleDiscardQueued}
763+
onMove={handleMoveQueued}
723764
/>
724765
) : null}
725766
<TaskChatComposer
726767
onSend={handleSendPrompt}
727768
restoredDraft={restoredDraft}
769+
editing={!!editingQueuedId}
770+
onCancelEdit={handleCancelEdit}
728771
onStop={handleStop}
729772
isUserTurn={!(session?.isPromptPending ?? true)}
730773
placeholder={

apps/mobile/src/features/tasks/composer/QueuedMessagesDock.tsx

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Text } from "@components/text";
22
import {
3+
CaretDown,
4+
CaretUp,
35
Lightning,
46
PaperclipIcon,
57
PencilSimple,
@@ -11,6 +13,7 @@ import { Pressable, View } from "react-native";
1113
import { SheetContainer } from "@/components/SheetContainer";
1214
import { useThemeColors } from "@/lib/theme";
1315
import {
16+
type MoveDirection,
1417
type QueuedMessage,
1518
useMessageQueueStore,
1619
} from "../stores/messageQueueStore";
@@ -19,8 +22,9 @@ interface QueuedMessagesDockProps {
1922
taskId: string;
2023
canSteer: boolean;
2124
onSteer: (message: QueuedMessage) => void;
22-
onReturnToComposer: (message: QueuedMessage) => void;
25+
onEdit: (message: QueuedMessage) => void;
2326
onDiscard: (message: QueuedMessage) => void;
27+
onMove: (message: QueuedMessage, direction: MoveDirection) => void;
2428
}
2529

2630
function previewText(message: QueuedMessage): string {
@@ -33,11 +37,13 @@ export function QueuedMessagesDock({
3337
taskId,
3438
canSteer,
3539
onSteer,
36-
onReturnToComposer,
40+
onEdit,
3741
onDiscard,
42+
onMove,
3843
}: QueuedMessagesDockProps) {
3944
const themeColors = useThemeColors();
4045
const queued = useMessageQueueStore((s) => s.queuesByTaskId[taskId]);
46+
const editingId = useMessageQueueStore((s) => s.editingByTaskId[taskId]);
4147
const [activeId, setActiveId] = useState<string | null>(null);
4248

4349
if (!queued || queued.length === 0) return null;
@@ -46,24 +52,58 @@ export function QueuedMessagesDock({
4652
return (
4753
<>
4854
<View className="gap-1 px-3 pb-2">
49-
{queued.map((message) => (
50-
<Pressable
51-
key={message.id}
52-
onPress={() => setActiveId(message.id)}
53-
accessibilityRole="button"
54-
accessibilityLabel="Queued message actions"
55-
className="flex-row items-center gap-2 rounded-xl border border-gray-6 bg-card px-3 py-2 active:opacity-70"
56-
>
57-
<Stack size={14} color={themeColors.gray[10]} />
58-
<Text numberOfLines={1} className="flex-1 text-[13px] text-gray-11">
59-
{previewText(message)}
60-
</Text>
61-
{message.attachments.length > 0 ? (
62-
<PaperclipIcon size={13} color={themeColors.gray[9]} />
63-
) : null}
64-
<Text className="text-[11px] text-gray-9">Queued</Text>
65-
</Pressable>
66-
))}
55+
{queued.map((message, index) => {
56+
const isEditing = message.id === editingId;
57+
return (
58+
<View
59+
key={message.id}
60+
className={`flex-row items-center gap-2 rounded-xl border px-3 py-2 ${
61+
isEditing
62+
? "border-accent-7 bg-accent-2"
63+
: "border-gray-6 bg-card"
64+
}`}
65+
>
66+
<Pressable
67+
onPress={() => setActiveId(message.id)}
68+
accessibilityRole="button"
69+
accessibilityLabel="Queued message actions"
70+
className="min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70"
71+
>
72+
<Stack size={14} color={themeColors.gray[10]} />
73+
<Text
74+
numberOfLines={1}
75+
className="flex-1 text-[13px] text-gray-11"
76+
>
77+
{previewText(message)}
78+
</Text>
79+
{message.attachments.length > 0 ? (
80+
<PaperclipIcon size={13} color={themeColors.gray[9]} />
81+
) : null}
82+
<Text
83+
className={`text-[11px] ${
84+
isEditing ? "text-accent-11" : "text-gray-9"
85+
}`}
86+
>
87+
{isEditing ? "Editing" : "Queued"}
88+
</Text>
89+
</Pressable>
90+
<View className="flex-row items-center">
91+
<ReorderButton
92+
icon={<CaretUp size={16} color={themeColors.gray[11]} />}
93+
label="Move up"
94+
disabled={index === 0}
95+
onPress={() => onMove(message, "up")}
96+
/>
97+
<ReorderButton
98+
icon={<CaretDown size={16} color={themeColors.gray[11]} />}
99+
label="Move down"
100+
disabled={index === queued.length - 1}
101+
onPress={() => onMove(message, "down")}
102+
/>
103+
</View>
104+
</View>
105+
);
106+
})}
67107
</View>
68108

69109
<SheetContainer open={active !== null} onClose={() => setActiveId(null)}>
@@ -93,10 +133,10 @@ export function QueuedMessagesDock({
93133
) : null}
94134
<ActionRow
95135
icon={<PencilSimple size={18} color={themeColors.gray[11]} />}
96-
label="Edit in composer"
97-
description="Pull it back into the composer to revise"
136+
label="Edit in place"
137+
description="Revise it in the composer; it keeps its place in the queue"
98138
onPress={() => {
99-
onReturnToComposer(active);
139+
onEdit(active);
100140
setActiveId(null);
101141
}}
102142
/>
@@ -116,6 +156,34 @@ export function QueuedMessagesDock({
116156
);
117157
}
118158

159+
function ReorderButton({
160+
icon,
161+
label,
162+
disabled,
163+
onPress,
164+
}: {
165+
icon: ReactNode;
166+
label: string;
167+
disabled: boolean;
168+
onPress: () => void;
169+
}) {
170+
return (
171+
<Pressable
172+
onPress={onPress}
173+
disabled={disabled}
174+
hitSlop={6}
175+
accessibilityRole="button"
176+
accessibilityLabel={label}
177+
accessibilityState={{ disabled }}
178+
className={`h-8 w-8 items-center justify-center rounded-lg active:bg-gray-3 ${
179+
disabled ? "opacity-30" : ""
180+
}`}
181+
>
182+
{icon}
183+
</Pressable>
184+
);
185+
}
186+
119187
function ActionRow({
120188
icon,
121189
label,

apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Text } from "@components/text";
12
import * as Haptics from "expo-haptics";
23
import {
34
ArrowUp,
@@ -81,6 +82,9 @@ interface TaskChatComposerProps {
8182
onToggleMessagingMode: () => void;
8283
/** A queued message pulled back for editing; pass a fresh object to restore. */
8384
restoredDraft?: { text: string; attachments: PendingAttachment[] };
85+
/** True while editing a queued message in place; the next send saves it. */
86+
editing?: boolean;
87+
onCancelEdit?: () => void;
8488
}
8589

8690
function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode {
@@ -167,6 +171,8 @@ export function TaskChatComposer({
167171
queuedCount,
168172
onToggleMessagingMode,
169173
restoredDraft,
174+
editing = false,
175+
onCancelEdit,
170176
}: TaskChatComposerProps) {
171177
const themeColors = useThemeColors();
172178
const [message, setMessage] = useState(() => initialMessage ?? "");
@@ -266,6 +272,25 @@ export function TaskChatComposer({
266272
<View className="relative">
267273
<PulsingBorder active={isUserTurn} color={themeColors.accent[9]} />
268274
<View className="overflow-hidden rounded-2xl border border-gray-6 bg-card">
275+
{editing ? (
276+
<View className="flex-row items-center gap-2 border-gray-6 border-b bg-accent-2 px-3 py-2">
277+
<PencilIcon size={14} color={themeColors.accent[11]} />
278+
<Text className="flex-1 text-[12px] text-accent-11">
279+
Editing queued message
280+
</Text>
281+
<Pressable
282+
hitSlop={8}
283+
onPress={onCancelEdit}
284+
accessibilityRole="button"
285+
accessibilityLabel="Cancel editing"
286+
className="active:opacity-60"
287+
>
288+
<Text className="font-medium text-[12px] text-gray-11">
289+
Cancel
290+
</Text>
291+
</Pressable>
292+
</View>
293+
) : null}
269294
<AttachmentsBar
270295
attachments={attachments}
271296
onRemove={removeAttachment}

0 commit comments

Comments
 (0)