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
49 changes: 46 additions & 3 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "@/features/tasks/hooks/useMessagingMode";
import { taskKeys } from "@/features/tasks/hooks/useTasks";
import {
type MoveDirection,
type QueuedMessage,
useMessageQueueStore,
} from "@/features/tasks/stores/messageQueueStore";
Expand Down Expand Up @@ -109,6 +110,7 @@ export default function TaskDetailScreen() {
getSessionForTask,
setFocusedTaskId,
steerQueuedMessage,
flushQueuedMessagesIfIdle,
stopRun,
} = useTaskSessionStore();

Expand Down Expand Up @@ -174,6 +176,9 @@ export default function TaskDetailScreen() {

const messagingMode = useMessagingMode(taskId);
const queuedCount = useQueuedCount(taskId);
const editingQueuedId = useMessageQueueStore((s) =>
taskId ? s.editingByTaskId[taskId] : undefined,
);
const toggleMessagingMode = useToggleMessagingMode(taskId);
const analytics = useAnalytics();

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

// Saving an in-place edit: overwrite the queued message and release the
// drain hold. If the turn already ended while editing, flush now — the
// turn-end drain won't fire again on its own.
const queue = useMessageQueueStore.getState();
const editingId = queue.editingByTaskId[taskId];
if (editingId) {
queue.update(taskId, editingId, { content: text, attachments });
queue.clearEditing(taskId);
flushQueuedMessagesIfIdle(taskId);
return;
}

if (session?.terminalStatus) {
handleSendAfterTerminal(text, attachments);
return;
Expand Down Expand Up @@ -398,6 +415,7 @@ export default function TaskDetailScreen() {
messagingMode,
handleSendAfterTerminal,
trackPromptSent,
flushQueuedMessagesIfIdle,
],
);

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

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

const handleCancelEdit = useCallback(() => {
if (!taskId) return;
useMessageQueueStore.getState().clearEditing(taskId);
setRestoredDraft({ text: "", attachments: [] });
flushQueuedMessagesIfIdle(taskId);
}, [taskId, flushQueuedMessagesIfIdle]);

const handleMoveQueued = useCallback(
(message: QueuedMessage, direction: MoveDirection) => {
if (!taskId) return;
Haptics.selectionAsync();
useMessageQueueStore.getState().move(taskId, message.id, direction);
},
[taskId],
);

const handleDiscardQueued = useCallback(
(message: QueuedMessage) => {
if (!taskId) return;
const wasEditing =
useMessageQueueStore.getState().editingByTaskId[taskId] === message.id;
useMessageQueueStore.getState().remove(taskId, message.id);
if (wasEditing) setRestoredDraft({ text: "", attachments: [] });
},
[taskId],
);
Expand Down Expand Up @@ -718,13 +758,16 @@ export default function TaskDetailScreen() {
!session?.terminalStatus
}
onSteer={handleSteerQueued}
onReturnToComposer={handleReturnQueuedToComposer}
onEdit={handleEditQueued}
onDiscard={handleDiscardQueued}
onMove={handleMoveQueued}
/>
) : null}
<TaskChatComposer
onSend={handleSendPrompt}
restoredDraft={restoredDraft}
editing={!!editingQueuedId}
onCancelEdit={handleCancelEdit}
onStop={handleStop}
isUserTurn={!(session?.isPromptPending ?? true)}
placeholder={
Expand Down
114 changes: 91 additions & 23 deletions apps/mobile/src/features/tasks/composer/QueuedMessagesDock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Text } from "@components/text";
import {
CaretDown,
CaretUp,
Lightning,
PaperclipIcon,
PencilSimple,
Expand All @@ -11,6 +13,7 @@ import { Pressable, View } from "react-native";
import { SheetContainer } from "@/components/SheetContainer";
import { useThemeColors } from "@/lib/theme";
import {
type MoveDirection,
type QueuedMessage,
useMessageQueueStore,
} from "../stores/messageQueueStore";
Expand All @@ -19,8 +22,9 @@ interface QueuedMessagesDockProps {
taskId: string;
canSteer: boolean;
onSteer: (message: QueuedMessage) => void;
onReturnToComposer: (message: QueuedMessage) => void;
onEdit: (message: QueuedMessage) => void;
onDiscard: (message: QueuedMessage) => void;
onMove: (message: QueuedMessage, direction: MoveDirection) => void;
}

function previewText(message: QueuedMessage): string {
Expand All @@ -33,11 +37,13 @@ export function QueuedMessagesDock({
taskId,
canSteer,
onSteer,
onReturnToComposer,
onEdit,
onDiscard,
onMove,
}: QueuedMessagesDockProps) {
const themeColors = useThemeColors();
const queued = useMessageQueueStore((s) => s.queuesByTaskId[taskId]);
const editingId = useMessageQueueStore((s) => s.editingByTaskId[taskId]);
const [activeId, setActiveId] = useState<string | null>(null);

if (!queued || queued.length === 0) return null;
Expand All @@ -46,24 +52,58 @@ export function QueuedMessagesDock({
return (
<>
<View className="gap-1 px-3 pb-2">
{queued.map((message) => (
<Pressable
key={message.id}
onPress={() => setActiveId(message.id)}
accessibilityRole="button"
accessibilityLabel="Queued message actions"
className="flex-row items-center gap-2 rounded-xl border border-gray-6 bg-card px-3 py-2 active:opacity-70"
>
<Stack size={14} color={themeColors.gray[10]} />
<Text numberOfLines={1} className="flex-1 text-[13px] text-gray-11">
{previewText(message)}
</Text>
{message.attachments.length > 0 ? (
<PaperclipIcon size={13} color={themeColors.gray[9]} />
) : null}
<Text className="text-[11px] text-gray-9">Queued</Text>
</Pressable>
))}
{queued.map((message, index) => {
const isEditing = message.id === editingId;
return (
<View
key={message.id}
className={`flex-row items-center gap-2 rounded-xl border px-3 py-2 ${
isEditing
? "border-accent-7 bg-accent-2"
: "border-gray-6 bg-card"
}`}
>
<Pressable
onPress={() => setActiveId(message.id)}
accessibilityRole="button"
accessibilityLabel="Queued message actions"
className="min-w-0 flex-1 flex-row items-center gap-2 active:opacity-70"
>
<Stack size={14} color={themeColors.gray[10]} />
<Text
numberOfLines={1}
className="flex-1 text-[13px] text-gray-11"
>
{previewText(message)}
</Text>
{message.attachments.length > 0 ? (
<PaperclipIcon size={13} color={themeColors.gray[9]} />
) : null}
<Text
className={`text-[11px] ${
isEditing ? "text-accent-11" : "text-gray-9"
}`}
>
{isEditing ? "Editing" : "Queued"}
</Text>
</Pressable>
<View className="flex-row items-center">
<ReorderButton
icon={<CaretUp size={16} color={themeColors.gray[11]} />}
label="Move up"
disabled={index === 0}
onPress={() => onMove(message, "up")}
/>
<ReorderButton
icon={<CaretDown size={16} color={themeColors.gray[11]} />}
label="Move down"
disabled={index === queued.length - 1}
onPress={() => onMove(message, "down")}
/>
</View>
</View>
);
})}
</View>

<SheetContainer open={active !== null} onClose={() => setActiveId(null)}>
Expand Down Expand Up @@ -93,10 +133,10 @@ export function QueuedMessagesDock({
) : null}
<ActionRow
icon={<PencilSimple size={18} color={themeColors.gray[11]} />}
label="Edit in composer"
description="Pull it back into the composer to revise"
label="Edit in place"
description="Revise it in the composer; it keeps its place in the queue"
onPress={() => {
onReturnToComposer(active);
onEdit(active);
setActiveId(null);
}}
/>
Expand All @@ -116,6 +156,34 @@ export function QueuedMessagesDock({
);
}

function ReorderButton({
icon,
label,
disabled,
onPress,
}: {
icon: ReactNode;
label: string;
disabled: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
hitSlop={6}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled }}
className={`h-8 w-8 items-center justify-center rounded-lg active:bg-gray-3 ${
disabled ? "opacity-30" : ""
}`}
>
{icon}
</Pressable>
);
}

function ActionRow({
icon,
label,
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Text } from "@components/text";
import * as Haptics from "expo-haptics";
import {
ArrowUp,
Expand Down Expand Up @@ -81,6 +82,9 @@ interface TaskChatComposerProps {
onToggleMessagingMode: () => void;
/** A queued message pulled back for editing; pass a fresh object to restore. */
restoredDraft?: { text: string; attachments: PendingAttachment[] };
/** True while editing a queued message in place; the next send saves it. */
editing?: boolean;
onCancelEdit?: () => void;
}

function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode {
Expand Down Expand Up @@ -167,6 +171,8 @@ export function TaskChatComposer({
queuedCount,
onToggleMessagingMode,
restoredDraft,
editing = false,
onCancelEdit,
}: TaskChatComposerProps) {
const themeColors = useThemeColors();
const [message, setMessage] = useState(() => initialMessage ?? "");
Expand Down Expand Up @@ -266,6 +272,25 @@ export function TaskChatComposer({
<View className="relative">
<PulsingBorder active={isUserTurn} color={themeColors.accent[9]} />
<View className="overflow-hidden rounded-2xl border border-gray-6 bg-card">
{editing ? (
<View className="flex-row items-center gap-2 border-gray-6 border-b bg-accent-2 px-3 py-2">
<PencilIcon size={14} color={themeColors.accent[11]} />
<Text className="flex-1 text-[12px] text-accent-11">
Editing queued message
</Text>
<Pressable
hitSlop={8}
onPress={onCancelEdit}
accessibilityRole="button"
accessibilityLabel="Cancel editing"
className="active:opacity-60"
>
<Text className="font-medium text-[12px] text-gray-11">
Cancel
</Text>
</Pressable>
</View>
) : null}
<AttachmentsBar
attachments={attachments}
onRemove={removeAttachment}
Expand Down
Loading
Loading