Skip to content
Closed
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
45 changes: 42 additions & 3 deletions apps/mobile/src/features/threads/ThreadQueueControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -130,8 +131,10 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
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<RunId | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
const busyRef = useRef(false);
const queuedRuns = workflow?.queuedRuns ?? [];

Expand All @@ -149,6 +152,19 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
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]!;
Expand Down Expand Up @@ -205,6 +221,11 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
contentContainerClassName="px-5 pb-6"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }}
>
{moveError ? (
<Text accessibilityRole="alert" className="py-3 text-sm text-destructive">
{moveError}
</Text>
) : null}
{workflow?.isHeld && queuedRuns.length > 0 ? (
<View className="gap-2 py-3">
<Text className="text-sm text-foreground-muted">Queue paused</Text>
Expand Down Expand Up @@ -258,6 +279,22 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
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
? [
{
Expand Down Expand Up @@ -292,8 +329,10 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
? "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"
>
<QueueAttachmentThumbnails
Expand Down
63 changes: 58 additions & 5 deletions apps/web/src/components/chat/QueuedRunsControl.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
import {
deriveThreadQueueWorkflowState,
queuedRunMoveTarget,
} from "@t3tools/client-runtime/state/thread-workflows";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import {
isAttachmentOnlyBootstrapPrompt,
Expand All @@ -18,6 +21,7 @@ import {
ArrowUpIcon,
CalendarClockIcon,
ChevronDownIcon,
ChevronUpIcon,
Clock3Icon,
MessagesSquareIcon,
PencilIcon,
Expand Down Expand Up @@ -99,10 +103,12 @@ export function QueuedRunsControl({
const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);
const resume = useAtomCommand(threadEnvironment.resumeThreadQueue);
const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun);
const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun);
const [showAll, setShowAll] = useState(false);
const queueListId = useId();
const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
const steerInFlightRef = useRef(false);
const [moveError, setMoveError] = useState<string | null>(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);
Expand Down Expand Up @@ -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({
Expand All @@ -187,7 +193,7 @@ export function QueuedRunsControl({
input: { threadId: props.threadId, queuedRunId, targetRunId: activeRun.id },
});
}
steerInFlightRef.current = false;
actionInFlightRef.current = false;
setBusyRunId(null);
};

Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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) => (
<Tooltip key={direction}>
<TooltipTrigger
render={
<Button
size="icon-xs"
variant="ghost-muted"
aria-label={`Move queued message ${direction}`}
disabled={
busyRunId !== null ||
queuedRunMoveTarget(queued, item.runId!, direction) === null
}
onClick={() => void move(item.runId!, direction)}
/>
}
>
{direction === "up" ? <ChevronUpIcon /> : <ChevronDownIcon />}
</TooltipTrigger>
<TooltipPopup>{`Move ${direction} in queue`}</TooltipPopup>
</Tooltip>
))
: null}
{canSendRun(item.freshContext) && !isEditing ? (
<>
<Tooltip>
Expand Down Expand Up @@ -376,6 +424,11 @@ export function QueuedRunsControl({
);
})}
</ol>
{moveError ? (
<p role="alert" className="self-end text-xs text-destructive">
{moveError}
</p>
) : null}
{collapsible ? (
<div className="self-end pe-1 text-xs pointer-coarse:[&_[data-slot=inline-button]]:relative pointer-coarse:[&_[data-slot=inline-button]]:after:absolute pointer-coarse:[&_[data-slot=inline-button]]:after:-inset-x-1.5 pointer-coarse:[&_[data-slot=inline-button]]:after:-inset-y-3.5">
<InlineButton
Expand Down
4 changes: 3 additions & 1 deletion docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ See [images and videos](#images-and-videos-in-messages) for previewing and savin
On web and desktop, choose **Settings → Chat → Follow-up behavior** to queue
new messages for a later turn or steer the running turn immediately. The setting
applies to this client; already queued messages keep their place. Queued messages
are saved on the server and can be edited or removed before they send.
are saved on the server and can be edited, reordered, or removed before they send.
Use **Move up** or **Move down** to change their order without sending them. On
mobile, these actions are in each queued message's menu.
When Enter sends, `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux uses
the opposite action: it steers when your default is Queue and queues when your
default is Steer.
Expand Down
17 changes: 17 additions & 0 deletions packages/client-runtime/src/operations/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ export interface CancelQueuedRunInput extends ThreadCommandInput {
readonly runId: RunId;
}

export interface ReorderQueuedRunInput extends ThreadCommandInput {
readonly runId: RunId;
readonly beforeRunId: RunId | null;
}

export interface RetryWorkspacePreparationInput extends ThreadCommandInput {
readonly runId: RunId;
}
Expand Down Expand Up @@ -1061,6 +1066,18 @@ export const cancelQueuedRun = Effect.fn("EnvironmentCommands.cancelQueuedRun")(
});
});

export const reorderQueuedRun = Effect.fn("EnvironmentCommands.reorderQueuedRun")(function* (
input: ReorderQueuedRunInput,
) {
return yield* dispatch({
type: "queued-run.reorder",
commandId: yield* allocateCommandId(input),
threadId: input.threadId,
runId: input.runId,
beforeRunId: input.beforeRunId,
});
});

export const retryWorkspacePreparation = Effect.fn("EnvironmentCommands.retryWorkspacePreparation")(
function* (input: RetryWorkspacePreparationInput) {
return yield* dispatch({
Expand Down
7 changes: 7 additions & 0 deletions packages/client-runtime/src/state/threadCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
markThreadUnread,
mergeThreadBack,
promoteQueuedRun,
reorderQueuedRun,
resumeThreadQueue,
retryWorkspacePreparation,
linkThreadPullRequest,
Expand Down Expand Up @@ -345,6 +346,12 @@ export function createThreadEnvironmentAtoms<R, E>(
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,
Expand Down
16 changes: 15 additions & 1 deletion packages/client-runtime/src/state/threadWorkflows.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {
Expand Down
16 changes: 16 additions & 0 deletions packages/client-runtime/src/state/threadWorkflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
isCompactionCommandMessage,
type OrchestrationV2ProjectedTurnItem,
type OrchestrationV2ProviderCapabilities,
type RunId,
} from "@t3tools/contracts";
import { copySorted } from "@t3tools/shared/Array";

Expand Down Expand Up @@ -41,6 +42,21 @@ export interface ThreadQueueWorkflowState {
readonly canSendNow: boolean;
}

export function queuedRunMoveTarget(
queuedRuns: ReadonlyArray<{ readonly run: Pick<Run, "id"> }>,
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<Projection, "runs">): Run | null {
return projection.runs.findLast((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? null;
}
Expand Down
Loading