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
98 changes: 82 additions & 16 deletions apps/mobile/src/features/threads/ThreadQueueControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
makeQueuedRunsAtom,
queuedRunEditBlockedReason,
} from "@t3tools/client-runtime/queued-run-edit";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
import { Image } from "expo-image";
Expand All @@ -27,6 +29,9 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useAssetUrl } from "../../state/assets";
import { useQuietThreadShell } from "../../state/entities";
import { useEnvironmentQuery } from "../../state/query";
import { serverEnvironment } from "../../state/server";
import { assetImageSource } from "../../lib/assetImageSource";
import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit";
import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
Expand Down Expand Up @@ -58,6 +63,62 @@ export function useThreadQueuedCount(target: QueueTarget | null) {
);
}

type QueuedRunMessage = NonNullable<
NonNullable<ReturnType<typeof useThreadQueueWorkflow>>["queuedRuns"][number]["message"]
>;

/** Who sent a queued message, as its sent form shows it in the thread. */
function QueuedRunSender(props: {
readonly environmentId: EnvironmentId;
readonly message: QueuedRunMessage;
}) {
const { attribution, scheduledTaskId } = resolveUserMessagePresentation(props.message);
const senderThreadId = attribution === "agent" ? props.message.senderThreadId : undefined;
const senderShell = useQuietThreadShell(
senderThreadId ? scopeThreadRef(props.environmentId, senderThreadId) : null,
);
const tasks = useEnvironmentQuery(
scheduledTaskId
? serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} })
: null,
);
const sender =
attribution === "automation"
? {
icon: "calendar.badge.clock" as const,
label:
tasks.data?.tasks.find((task) => task.id === scheduledTaskId)?.title ?? "Automation",
}
: attribution === "agent"
? {
icon: "bubble.left.and.bubble.right" as const,
label: senderShell?.title.trim() || "Agent",
}
: attribution === "t3code"
? { icon: null, label: "Sent by T3 Code" }
: null;
if (sender === null) return null;
return (
<View className="flex-row items-center gap-1">
{sender.icon ? (
<SymbolView
name={sender.icon}
size={11}
weight="medium"
tintColorClassName="accent-foreground-muted"
type="monochrome"
/>
) : null}
<Text
numberOfLines={1}
className="min-w-0 shrink font-t3-medium text-2xs text-foreground-muted"
>
{sender.label}
</Text>
</View>
);
}

export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
const { layout } = useAdaptiveWorkspaceLayout();
const target = route.params;
Expand Down Expand Up @@ -173,7 +234,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
No messages waiting in this queue.
</Text>
) : null}
{queuedRuns.map(({ run, text, attachments, context }, index) => {
{queuedRuns.map(({ run, text, attachments, context, message }, index) => {
const controls = resolveThreadQueueRowControls({
busy: busyRunId !== null,
canPromoteToSteer: workflow?.canPromoteToSteer ?? false,
Expand Down Expand Up @@ -240,21 +301,26 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
environmentId={target.environmentId}
attachments={attachments}
/>
<Text
className={
controls.isEditing
? "min-w-0 flex-1 text-sm text-foreground-muted"
: "min-w-0 flex-1 text-sm text-foreground"
}
numberOfLines={1}
>
{context?.guest === undefined ? null : (
<Text className="font-t3-medium text-adaptive-fuchsia-600-300">
{`${context.guest.name} `}
</Text>
)}
{title}
</Text>
<View className="min-w-0 flex-1 gap-0.5">
{message ? (
<QueuedRunSender environmentId={target.environmentId} message={message} />
) : null}
<Text
className={
controls.isEditing
? "text-sm text-foreground-muted"
: "text-sm text-foreground"
}
numberOfLines={1}
>
{context?.guest === undefined ? null : (
<Text className="font-t3-medium text-adaptive-fuchsia-600-300">
{`${context.guest.name} `}
</Text>
)}
{title}
</Text>
</View>
{controls.isEditing ? (
<Text className="shrink-0 text-2xs uppercase tracking-wide text-primary">
Editing
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2907,6 +2907,8 @@ export default function ChatView(props: ChatViewProps) {
})}
onEditQueuedRun={queuedRunEdit.beginEdit}
onCancelEdit={queuedRunEdit.cancelEdit}
timestampFormat={timestampFormat}
onOpenThread={onOpenRelatedThreadStable}
/>
</>
) : null;
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import * as DateTime from "effect/DateTime";
import type { ComponentProps } from "react";
import { expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
Expand All @@ -17,6 +18,15 @@ vi.mock(import("@t3tools/client-runtime/state/thread-workflows"), async (importO
vi.mock(import("../../state/entities"), async (importOriginal) => ({
...(await importOriginal()),
useThreadProjection: (() => state.projection) as never,
useQuietThreadShell: ((target: unknown) =>
target === null ? null : { title: "Release lead" }) as never,
}));

vi.mock(import("../../state/query"), async (importOriginal) => ({
...(await importOriginal()),
useEnvironmentQuery: (() => ({
data: { tasks: [{ id: "task:webhook", title: "PR dispatcher" }] },
})) as never,
}));

vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({
Expand Down Expand Up @@ -46,6 +56,8 @@ const baseProps = {
editBlockedReason: null,
onEditQueuedRun: () => undefined,
onCancelEdit: () => undefined,
timestampFormat: "locale",
onOpenThread: () => undefined,
} satisfies ComponentProps<typeof QueuedRunsControl>;

async function renderQueue(props: Partial<ComponentProps<typeof QueuedRunsControl>> = {}) {
Expand Down Expand Up @@ -97,3 +109,66 @@ it("previews assistant quotes as readable text and preserves the citation when e
expect.objectContaining({ text: quoteAndText }),
);
});

it("shows who sent a queued message and folds automation and agent prompts", async () => {
setup();
const message = (id: string, fields: Record<string, unknown>) => ({
id,
role: "user",
createdAt: DateTime.makeUnsafe("2026-10-27T12:00:00.000Z"),
...fields,
});
state.projection = { projection: { messages: [] } };
state.workflow = {
activeRun: { id: "run:active" },
canPromoteToSteer: true,
queuedRuns: [
[
"run:automation",
"GitHub labeled a pull request.\nFollow the runbook.",
{
createdBy: "agent",
scheduledTaskId: "task:webhook",
},
],
[
"run:agent",
"Review finished.",
{
createdBy: "agent",
creationSource: "provider",
senderThreadId: "thread:lead",
},
],
[
"run:server",
"Continue where you left off.",
{
createdBy: "agent",
creationSource: "server",
},
],
].map(([runId, text, fields]) => ({
run: { id: runId, userMessageId: `${runId}:message` },
text,
attachments: [],
message: message(`${runId}:message`, { text, ...(fields as object) }),
})),
};
const onOpenThread = vi.fn();
await using _screen = await renderQueue({ onOpenThread });

const automation = page.getByRole("button", { name: /PR dispatcher/ });
await expect.element(automation).toHaveAttribute("aria-expanded", "false");
await expect.element(page.getByText(/Follow the runbook/)).not.toBeInTheDocument();
await automation.click();
await expect.element(page.getByText(/Follow the runbook/)).toBeVisible();
await expect.element(page.getByRole("link", { name: "Open automation" })).toBeInTheDocument();

await expect.element(page.getByRole("button", { name: /Release lead/ })).toBeVisible();
await page.getByRole("button", { name: "Open thread" }).click({ force: true });
expect(onOpenThread).toHaveBeenCalledExactlyOnceWith("thread:lead");

await expect.element(page.getByText("Sent by T3 Code")).toBeVisible();
await expect.element(page.getByText("Continue where you left off.")).toBeVisible();
});
Loading
Loading