From 7ffb6015cd21f00b81bf955641198445662ed6a5 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 6 Nov 2026 23:29:16 +0530 Subject: [PATCH] feat(chat): reorder queued prompts before they run --- .../features/threads/ThreadQueueControl.tsx | 45 ++++++++++++- .../src/components/chat/QueuedRunsControl.tsx | 63 +++++++++++++++++-- docs/user/composer.md | 4 +- .../client-runtime/src/operations/commands.ts | 17 +++++ .../src/state/threadCommands.ts | 7 +++ .../src/state/threadWorkflows.test.ts | 16 ++++- .../src/state/threadWorkflows.ts | 16 +++++ 7 files changed, 158 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index e2c6f33f22079..1ef661fc0c7e8 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -5,6 +5,7 @@ import { queuedRunEditBlockedReason, } from "@t3tools/client-runtime/queued-run-edit"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { queuedRunMoveTarget } from "@t3tools/client-runtime/state/thread-workflows"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; @@ -47,7 +48,7 @@ const EMPTY_QUEUED_COUNT_ATOM = Atom.make(0).pipe( ); type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; -type QueueAction = "steer" | "edit" | "remove"; +type QueueAction = "steer" | "edit" | "remove" | "up" | "down"; function useThreadQueueWorkflow(target: QueueTarget) { return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target)); @@ -130,8 +131,10 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message"); const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "remove queued message"); const resume = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue"); + const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun, "reorder queued message"); const [resuming, setResuming] = useState(false); const [busyRunId, setBusyRunId] = useState(null); + const [moveError, setMoveError] = useState(null); const busyRef = useRef(false); const queuedRuns = workflow?.queuedRuns ?? []; @@ -149,6 +152,19 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { if (busyRef.current) return; const index = queuedRuns.findIndex(({ run }) => run.id === runId); if (index < 0) return; + if (action === "up" || action === "down") { + const move = queuedRunMoveTarget(queuedRuns, runId, action); + if (move === null || editing?.runId === runId) return; + busyRef.current = true; + setBusyRunId(runId); + setMoveError(null); + void Haptics.selectionAsync(); + const result = await reorder({ ...target, input: { threadId: target.threadId, ...move } }); + if (result._tag === "Failure") setMoveError("Could not reorder queued messages. Try again."); + busyRef.current = false; + setBusyRunId(null); + return; + } if (action === "edit") { if (editBlockedReason !== null) return; const entry = queuedRuns[index]!; @@ -205,6 +221,11 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { contentContainerClassName="px-5 pb-6" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }} > + {moveError ? ( + + {moveError} + + ) : null} {workflow?.isHeld && queuedRuns.length > 0 ? ( Queue paused @@ -258,6 +279,22 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { shouldOpenOnLongPress style={{ flex: 1 }} actions={[ + ...(queuedRuns.length > 1 + ? (["up", "down"] as const).map((direction) => ({ + id: direction, + title: `Move ${direction}`, + attributes: { + disabled: + busyRunId !== null || + controls.isEditing || + queuedRunMoveTarget(queuedRuns, run.id, direction) === null, + }, + image: + Platform.OS === "ios" + ? `chevron.${direction}` + : `keyboard_arrow_${direction}`, + })) + : []), ...(workflow?.canPromoteToSteer || workflow?.canSendNow ? [ { @@ -292,8 +329,10 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { ? "Opens this message in the composer for editing" : (editBlockedReason ?? undefined) } - disabled={!controls.canEdit} - onPress={() => void act(run.id, "edit")} + disabled={busyRunId !== null || controls.isEditing} + onPress={() => { + if (controls.canEdit) void act(run.id, "edit"); + }} className="min-h-14 flex-row items-center gap-2.5 py-2.5 active:opacity-70" > (null); - const steerInFlightRef = useRef(false); + const [moveError, setMoveError] = useState(null); + const actionInFlightRef = useRef(false); // Run-only acknowledgements can resurrect pending rows after cancellation or startup. const acknowledgedMessageIds = new Set((projection?.messages ?? []).map((message) => message.id)); const queuedImageAttachmentResources = queuedImageResources(projection); @@ -173,8 +179,8 @@ export function QueuedRunsControl({ const freshContext = queued.some( ({ run }) => run.id === queuedRunId && run.freshContext === true, ); - if (!canSendRun(freshContext) || steerInFlightRef.current) return; - steerInFlightRef.current = true; + if (!canSendRun(freshContext) || actionInFlightRef.current) return; + actionInFlightRef.current = true; setBusyRunId(queuedRunId); if (workflow?.canSendNow) { await resume({ @@ -187,7 +193,7 @@ export function QueuedRunsControl({ input: { threadId: props.threadId, queuedRunId, targetRunId: activeRun.id }, }); } - steerInFlightRef.current = false; + actionInFlightRef.current = false; setBusyRunId(null); }; @@ -226,11 +232,30 @@ export function QueuedRunsControl({ if (items.length === 0) return null; const remove = async (runId: RunId) => { + if (actionInFlightRef.current) return; + actionInFlightRef.current = true; setBusyRunId(runId); await cancel({ environmentId: props.environmentId, input: { threadId: props.threadId, runId }, }); + actionInFlightRef.current = false; + setBusyRunId(null); + }; + + const move = async (runId: RunId, direction: "up" | "down") => { + const target = queuedRunMoveTarget(queued, runId, direction); + if (target === null || actionInFlightRef.current) return; + actionInFlightRef.current = true; + setBusyRunId(runId); + setShowAll(true); + setMoveError(null); + const result = await reorder({ + environmentId: props.environmentId, + input: { threadId: props.threadId, ...target }, + }); + if (result._tag === "Failure") setMoveError("Could not reorder queued messages. Try again."); + actionInFlightRef.current = false; setBusyRunId(null); }; @@ -275,6 +300,29 @@ export function QueuedRunsControl({ timestampFormat={props.timestampFormat} onOpenThread={props.onOpenThread} > + {item.runId !== null && queued.length > 1 && !isEditing + ? (["up", "down"] as const).map((direction) => ( + + void move(item.runId!, direction)} + /> + } + > + {direction === "up" ? : } + + {`Move ${direction} in queue`} + + )) + : null} {canSendRun(item.freshContext) && !isEditing ? ( <> @@ -376,6 +424,11 @@ export function QueuedRunsControl({ ); })} + {moveError ? ( +

+ {moveError} +

+ ) : null} {collapsible ? (
( scheduler, concurrency, }), + reorderQueuedRun: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-queued-run", + execute: reorderQueuedRun, + scheduler, + concurrency, + }), retryWorkspacePreparation: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:retry-workspace-preparation", execute: retryWorkspacePreparation, diff --git a/packages/client-runtime/src/state/threadWorkflows.test.ts b/packages/client-runtime/src/state/threadWorkflows.test.ts index 51f4d5a9e46ca..cc5baf1949fb1 100644 --- a/packages/client-runtime/src/state/threadWorkflows.test.ts +++ b/packages/client-runtime/src/state/threadWorkflows.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import { RunId, type OrchestrationV2ThreadProjection } from "@t3tools/contracts"; import { canForkProjectedAssistantItem, deriveThreadQueueWorkflowState, + queuedRunMoveTarget, resolveLatestMergeBackRun, threadSupportsProviderHandoff, } from "./threadWorkflows.ts"; @@ -30,6 +31,19 @@ const capabilities = (input?: { }) as never; describe("thread workflows", () => { + it.each([ + ["first", "up", null], + ["first", "down", { runId: "first", beforeRunId: "last" }], + ["middle", "up", { runId: "middle", beforeRunId: "first" }], + ["middle", "down", { runId: "middle", beforeRunId: null }], + ["last", "up", { runId: "last", beforeRunId: "middle" }], + ["last", "down", null], + ["missing", "up", null], + ] as const)("moves %s %s within the displayed queue", (runId, direction, expected) => { + const queued = ["first", "middle", "last"].map((id) => ({ run: { id: RunId.make(id) } })); + expect(queuedRunMoveTarget(queued, RunId.make(runId), direction)).toEqual(expected); + }); + it("allows a completed thread to switch providers after its session detaches", () => { const projection = { thread: { diff --git a/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts index 9e13b669a1da5..fbd859ba98c10 100644 --- a/packages/client-runtime/src/state/threadWorkflows.ts +++ b/packages/client-runtime/src/state/threadWorkflows.ts @@ -3,6 +3,7 @@ import { isCompactionCommandMessage, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2ProviderCapabilities, + type RunId, } from "@t3tools/contracts"; import { copySorted } from "@t3tools/shared/Array"; @@ -41,6 +42,21 @@ export interface ThreadQueueWorkflowState { readonly canSendNow: boolean; } +export function queuedRunMoveTarget( + queuedRuns: ReadonlyArray<{ readonly run: Pick }>, + runId: RunId, + direction: "up" | "down", +) { + const index = queuedRuns.findIndex(({ run }) => run.id === runId); + if (index < 0 || (direction === "up" ? index === 0 : index === queuedRuns.length - 1)) { + return null; + } + return { + runId, + beforeRunId: queuedRuns[index + (direction === "up" ? -1 : 2)]?.run.id ?? null, + }; +} + export function resolveActiveThreadRun(projection: Pick): Run | null { return projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? null; }