Skip to content
Open
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
62 changes: 61 additions & 1 deletion apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ import {
modelSupportsReasoning,
type ReasoningEffort,
} from "@/features/tasks/composer/options";
import { QueuedMessagesDock } from "@/features/tasks/composer/QueuedMessagesDock";
import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer";
import {
useMessagingMode,
useQueuedCount,
useToggleMessagingMode,
} from "@/features/tasks/hooks/useMessagingMode";
import { taskKeys } from "@/features/tasks/hooks/useTasks";
import { useMessageQueueStore } from "@/features/tasks/stores/messageQueueStore";
import {
type QueuedMessage,
useMessageQueueStore,
} from "@/features/tasks/stores/messageQueueStore";
import {
pendingTaskPromptStoreApi,
usePendingTaskPrompt,
Expand Down Expand Up @@ -96,6 +100,7 @@ export default function TaskDetailScreen() {
setConfigOption,
getSessionForTask,
setFocusedTaskId,
steerQueuedMessage,
} = useTaskSessionStore();

useEffect(() => {
Expand Down Expand Up @@ -386,6 +391,47 @@ export default function TaskDetailScreen() {
],
);

const [restoredDraft, setRestoredDraft] = useState<{
text: string;
attachments: PendingAttachment[];
}>();

const handleSteerQueued = useCallback(
(message: QueuedMessage) => {
if (!taskId) return;
steerQueuedMessage(taskId, message.id)
.then(() => trackPromptSent(message.content, true))
.catch((err) => {
log.error("Failed to steer queued message", err);
Alert.alert(
"Couldn't steer",
"This message is still queued. Please try again.",
);
});
},
[taskId, steerQueuedMessage, trackPromptSent],
);

const handleReturnQueuedToComposer = useCallback(
(message: QueuedMessage) => {
if (!taskId) return;
useMessageQueueStore.getState().remove(taskId, message.id);
setRestoredDraft({
text: message.content,
attachments: message.attachments,
});
},
[taskId],
);

const handleDiscardQueued = useCallback(
(message: QueuedMessage) => {
if (!taskId) return;
useMessageQueueStore.getState().remove(taskId, message.id);
},
[taskId],
);

const handleModeChange = useCallback(
(value: ExecutionMode) => {
if (!taskId) return;
Expand Down Expand Up @@ -619,8 +665,22 @@ export default function TaskDetailScreen() {
last message can never sit behind the input. Stays visible on
terminal runs so the user can send a follow-up that resumes. */}
<Animated.View style={inputContainerStyle}>
{taskId ? (
<QueuedMessagesDock
taskId={taskId}
canSteer={
!!session?.isPromptPending &&
!session?.isCompacting &&
!session?.terminalStatus
}
onSteer={handleSteerQueued}
onReturnToComposer={handleReturnQueuedToComposer}
onDiscard={handleDiscardQueued}
/>
) : null}
<TaskChatComposer
onSend={handleSendPrompt}
restoredDraft={restoredDraft}
onStop={handleStop}
isUserTurn={!(session?.isPromptPending ?? true)}
placeholder={
Expand Down
156 changes: 156 additions & 0 deletions apps/mobile/src/features/tasks/composer/QueuedMessagesDock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Text } from "@components/text";
import {
Lightning,
PaperclipIcon,
PencilSimple,
Stack,
Trash,
} from "phosphor-react-native";
import { type ReactNode, useState } from "react";
import { Pressable, View } from "react-native";
import { SheetContainer } from "@/components/SheetContainer";
import { useThemeColors } from "@/lib/theme";
import {
type QueuedMessage,
useMessageQueueStore,
} from "../stores/messageQueueStore";

interface QueuedMessagesDockProps {
taskId: string;
canSteer: boolean;
onSteer: (message: QueuedMessage) => void;
onReturnToComposer: (message: QueuedMessage) => void;
onDiscard: (message: QueuedMessage) => void;
}

function previewText(message: QueuedMessage): string {
if (message.content.trim().length > 0) return message.content;
const count = message.attachments.length;
return count === 1 ? "1 attachment" : `${count} attachments`;
}

export function QueuedMessagesDock({
taskId,
canSteer,
onSteer,
onReturnToComposer,
onDiscard,
}: QueuedMessagesDockProps) {
const themeColors = useThemeColors();
const queued = useMessageQueueStore((s) => s.queuesByTaskId[taskId]);
const [activeId, setActiveId] = useState<string | null>(null);

if (!queued || queued.length === 0) return null;
const active = queued.find((m) => m.id === activeId) ?? null;

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>
))}
</View>

<SheetContainer open={active !== null} onClose={() => setActiveId(null)}>
{active ? (
<>
<View className="px-4 pt-2 pb-3">
<Text numberOfLines={3} className="text-[14px] text-gray-12">
{previewText(active)}
</Text>
</View>
{canSteer ? (
<ActionRow
icon={
<Lightning
size={18}
color={themeColors.accent[11]}
weight="fill"
/>
}
label="Steer now"
description="Interrupt the current turn and send this now"
onPress={() => {
onSteer(active);
setActiveId(null);
}}
/>
) : null}
<ActionRow
icon={<PencilSimple size={18} color={themeColors.gray[11]} />}
label="Edit in composer"
description="Pull it back into the composer to revise"
onPress={() => {
onReturnToComposer(active);
setActiveId(null);
}}
/>
<ActionRow
icon={<Trash size={18} color={themeColors.status.error} />}
label="Discard"
destructive
onPress={() => {
onDiscard(active);
setActiveId(null);
}}
/>
</>
) : null}
</SheetContainer>
</>
);
}

function ActionRow({
icon,
label,
description,
destructive = false,
onPress,
}: {
icon: ReactNode;
label: string;
description?: string;
destructive?: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={label}
className="flex-row items-center gap-3 px-4 py-3 active:bg-gray-2"
>
<View className="h-5 w-5 shrink-0 items-center justify-center">
{icon}
</View>
<View className="min-w-0 flex-1">
<Text
className={`font-medium text-[15px] ${
destructive ? "text-status-error" : "text-gray-12"
}`}
>
{label}
</Text>
{description ? (
<Text className="mt-0.5 text-[12px] text-gray-10">{description}</Text>
) : null}
</View>
</Pressable>
);
}
9 changes: 9 additions & 0 deletions apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ interface TaskChatComposerProps {
messagingMode: MessagingMode;
queuedCount: number;
onToggleMessagingMode: () => void;
/** A queued message pulled back for editing; pass a fresh object to restore. */
restoredDraft?: { text: string; attachments: PendingAttachment[] };
}

function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode {
Expand Down Expand Up @@ -164,6 +166,7 @@ export function TaskChatComposer({
messagingMode,
queuedCount,
onToggleMessagingMode,
restoredDraft,
}: TaskChatComposerProps) {
const themeColors = useThemeColors();
const [message, setMessage] = useState(() => initialMessage ?? "");
Expand All @@ -175,6 +178,12 @@ export function TaskChatComposer({
setMessage(initialMessage);
}, [initialMessage]);

useEffect(() => {
if (!restoredDraft) return;
setMessage(restoredDraft.text);
setAttachments(restoredDraft.attachments);
}, [restoredDraft]);

const appendTranscript = useCallback((transcript: string) => {
setMessage((prev) => (prev ? `${prev} ${transcript}` : transcript));
}, []);
Expand Down
29 changes: 28 additions & 1 deletion apps/mobile/src/features/tasks/stores/messageQueueStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,41 @@ describe("messageQueueStore", () => {

expect(getQueue("t1").map((m) => m.content)).toEqual(["a", "b", "c"]);
});

it.each([
{
name: "removes exactly the targeted message",
contents: ["a", "b", "c"],
removeIndex: 1,
expected: ["a", "c"],
},
{
name: "clears the entry once the last message is removed",
contents: ["only"],
removeIndex: 0,
expected: [],
},
])("$name", ({ contents, removeIndex, expected }) => {
const { enqueue, remove, getQueue } = useMessageQueueStore.getState();
for (const content of contents) enqueue("t1", content, []);
remove("t1", getQueue("t1")[removeIndex].id);
expect(getQueue("t1").map((m) => m.content)).toEqual(expected);
});

it("ignores removal of an unknown id", () => {
const { enqueue, remove, getQueue } = useMessageQueueStore.getState();
enqueue("t1", "a", []);
remove("t1", "nope");
expect(getQueue("t1").map((m) => m.content)).toEqual(["a"]);
});
});

describe("combineQueuedMessages", () => {
function msg(
content: string,
attachments: PendingAttachment[],
): QueuedMessage {
return { content, attachments };
return { id: content, content, attachments };
}

it("joins text in order with a blank line and concatenates attachments", () => {
Expand Down
25 changes: 24 additions & 1 deletion apps/mobile/src/features/tasks/stores/messageQueueStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import { create } from "zustand";
import type { PendingAttachment } from "../composer/attachments/types";

export interface QueuedMessage {
id: string;
content: string;
attachments: PendingAttachment[];
}

const EMPTY: QueuedMessage[] = [];

let queueIdCounter = 0;
function nextQueueId(): string {
queueIdCounter += 1;
return `queue-${queueIdCounter}`;
}

interface MessageQueueState {
queuesByTaskId: Record<string, QueuedMessage[]>;
enqueue: (
Expand All @@ -19,6 +26,8 @@ interface MessageQueueState {
drain: (taskId: string) => QueuedMessage[];
/** Restore messages at the head of the queue, e.g. after a failed flush. */
prepend: (taskId: string, messages: QueuedMessage[]) => void;
/** Drop a single queued message by id. */
remove: (taskId: string, messageId: string) => void;
getQueue: (taskId: string) => QueuedMessage[];
}

Expand All @@ -30,7 +39,7 @@ export const useMessageQueueStore = create<MessageQueueState>((set, get) => ({
...state.queuesByTaskId,
[taskId]: [
...(state.queuesByTaskId[taskId] ?? []),
{ content, attachments },
{ id: nextQueueId(), content, attachments },
],
},
})),
Expand All @@ -50,6 +59,20 @@ export const useMessageQueueStore = create<MessageQueueState>((set, get) => ({
[taskId]: [...messages, ...(state.queuesByTaskId[taskId] ?? [])],
},
})),
remove: (taskId, messageId) =>
set((state) => {
const queue = state.queuesByTaskId[taskId];
if (!queue) return state;
const next = queue.filter((m) => m.id !== messageId);
if (next.length === queue.length) return state;
if (next.length === 0) {
const { [taskId]: _emptied, ...rest } = state.queuesByTaskId;
return { queuesByTaskId: rest };
}
return {
queuesByTaskId: { ...state.queuesByTaskId, [taskId]: next },
};
}),
getQueue: (taskId) => get().queuesByTaskId[taskId] ?? EMPTY,
}));

Expand Down
Loading
Loading