diff --git a/packages/junior-dashboard/e2e/conversation-send.spec.ts b/packages/junior-dashboard/e2e/conversation-send.spec.ts index 856739c0d6..d594fe47a9 100644 --- a/packages/junior-dashboard/e2e/conversation-send.spec.ts +++ b/packages/junior-dashboard/e2e/conversation-send.spec.ts @@ -3,6 +3,7 @@ import type { ConversationPendingMessage, InputImage, } from "@sentry/junior/api/schema"; +import type { Route } from "@playwright/test"; import { expect, test } from "./test"; import { screenshot } from "./screenshot"; import { mockChartPng } from "../src/mock-reporting/chart-png"; @@ -219,6 +220,7 @@ test("starts and continues conversations from the dashboard", async ({ await screenshot(page, "conversation-image-reply"); await page.getByRole("button", { name: "Send" }).click(); const pending = page.getByLabel("Pending messages"); + // This history expired, so the transcript cannot show the send. It queues. await expect(pending.getByText("Continue in Junior")).toBeVisible(); // The author avatar carries the name and email for hover and screen readers. await expect(pending.getByLabel(/dev@example\.com/)).toBeVisible(); @@ -406,14 +408,65 @@ test("hands web and external messages from queue to history without gaps", async ); const composer = page.getByLabel("Continue this conversation"); const pending = page.getByLabel("Pending messages"); + const transcript = page.getByLabel("Conversation transcript"); const thinking = page .getByRole("status") .filter({ hasText: "Junior is thinking" }); await composer.fill(web.text); + await composer.evaluate((element, data) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File( + [Uint8Array.from(atob(data), (c) => c.charCodeAt(0))], + "handoff.png", + { type: "image/png" }, + ), + ); + element.dispatchEvent( + new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }), + ); + }, mockChartPng.toString("base64")); + await expect(page.getByRole("img", { name: "handoff.png" })).toBeVisible(); await page.getByRole("button", { name: "Send", exact: true }).click(); - await expect(pending.getByText(web.text)).toBeVisible(); + // Junior is idle and the queue is empty, so the send goes straight to the + // transcript and stays in place until history has it. Its local image shows + // before the server stores it. + await expect(transcript.getByText(web.text)).toBeVisible(); + await expect( + transcript.getByRole("img", { name: "handoff.png" }), + ).toBeVisible(); + await expect(pending).toBeHidden(); await expect(thinking).toBeHidden(); + // History does not have the first send yet, so its Turn can start first. + // A send now waits in the queue instead of the transcript tail. + const waiting = { ...web, text: "Wait behind the first send" }; + const acceptWaiting = async (route: Route) => { + const body = route.request().postDataJSON(); + waiting.messageId = await webMessageId({ + conversationId, + idempotencyKey: body.idempotencyKey, + }); + waiting.inboundMessageId = waiting.messageId; + await route.fulfill({ + json: { + conversationId, + messageId: waiting.messageId, + status: "accepted", + }, + }); + }; + await page.route(`${path}/messages`, acceptWaiting); + await composer.fill(waiting.text); + await page.getByRole("button", { name: "Send", exact: true }).click(); + await expect(pending.getByText(waiting.text)).toBeVisible(); + await expect(transcript.getByText(waiting.text)).toBeHidden(); + await page.unroute(`${path}/messages`, acceptWaiting); + // History takes the waiting send so the rest of the handoff starts clean. + committed = [waiting]; + await expect(pending).toBeHidden(); + await expect(transcript.getByText(web.text)).toBeVisible(); + // Watch every DOM change, not just the final state: the message must never // disappear or appear twice while accept and history requests are held. await page.evaluate((text) => { @@ -446,36 +499,36 @@ test("hands web and external messages from queue to history without gaps", async const readsBeforeAccept = detailReads; releaseAccept(); await expect.poll(() => detailReads).toBeGreaterThan(readsBeforeAccept); - await expect(pending.getByText(web.text)).toBeVisible(); - await expect(pending.getByLabel("Queued", { exact: true })).toBeVisible(); + await expect(transcript.getByText(web.text)).toBeVisible(); + await expect(pending).toBeHidden(); await expect(thinking).toBeHidden(); releaseHistory(); historyHeld = undefined; + // The mailbox copy does not pull the message back into the queue. queued = [web]; - await expect( - pending.getByRole("button", { name: "Delete queued message" }), - ).toBeVisible(); + const readsBeforeQueued = pendingReads; + await expect.poll(() => pendingReads).toBeGreaterThan(readsBeforeQueued + 1); + await expect(transcript.getByText(web.text)).toBeVisible(); + await expect(pending).toBeHidden(); await expect(thinking).toBeHidden(); // The mailbox can empty before the slower history read returns. Publish both // together, and do not hide the web message in the meantime. const readsBeforeCommit = pendingReads; queued = []; - committed = [web]; + committed = [waiting, web]; started = true; historyHeld = new Promise((resolve) => { releaseHistory = resolve; }); await expect.poll(() => pendingReads).toBeGreaterThan(readsBeforeCommit); - await expect(pending.getByText(web.text)).toBeVisible(); + await expect(transcript.getByText(web.text)).toBeVisible(); await expect(thinking).toBeHidden(); releaseHistory(); historyHeld = undefined; - await expect(pending).toBeHidden(); - await expect( - page.getByLabel("Conversation transcript").getByText(web.text), - ).toBeVisible(); await expect(thinking).toBeVisible(); + await expect(transcript.getByText(web.text)).toBeVisible(); + await expect(pending).toBeHidden(); expect( await page.evaluate(() => { @@ -521,7 +574,7 @@ test("hands web and external messages from queue to history without gaps", async pending.getByRole("button", { name: "Delete queued message" }), ).toBeVisible(); await expect(page.getByText(second.text, { exact: true })).toHaveCount(1); - committed = [web, second]; + committed = [waiting, web, second]; await expect(pending).toBeHidden(); await expect(page.getByText(second.text, { exact: true })).toHaveCount(1); releaseSecondAccept(); @@ -532,7 +585,7 @@ test("hands web and external messages from queue to history without gaps", async await expect(pending.getByText(external.text)).toBeVisible(); await expect(pending.getByLabel("Slack", { exact: true })).toBeVisible(); await expect(thinking).toBeVisible(); - committed = [web, second, external]; + committed = [waiting, web, second, external]; // Even a stale mailbox containing the consumed input must not duplicate it. await expect( page.getByLabel("Conversation transcript").getByText(external.text), diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 32e3cd8670..07b2a0e335 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -441,6 +441,7 @@ export function ConversationPage(props: { loadingPreviousPage={detail.isLoadingPreviousPage} onLoadPreviousPage={detail.loadPreviousPage} onSelectedEventSeqChange={setSelectedEventSeq} + pendingMessages={detail.inlineMessages} pinRequestVersion={pinRequestVersion} responding={ !detail.error && conversationIsResponding(transcript) @@ -473,6 +474,15 @@ export function ConversationPage(props: { // every 2s; a prop would bust footer memo while the reader types. pendingGeneratedAtRef={pendingGeneratedAtRef} pendingMessages={detail.pendingMessages} + // Without live history, an inline send would never leave the + // transcript tail. A started Turn reads a new send only later. + // Keep those sends in the queue. An earlier inline send that + // history does not have yet can start that Turn first. + sendsInline={ + detail.data.eventHistory.status === "available" && + !conversationIsResponding(detail.data) && + detail.inlineMessages.length === 0 + } stoppable={live} /> ) : undefined @@ -555,6 +565,11 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { pendingAuthorization?: ConversationPendingMessagesReport["authorization"]; pendingGeneratedAtRef: { current: string | undefined }; pendingMessages: readonly ConversationMailboxMessage[]; + /** + * True when history is live and no Turn has started, so the next Turn will + * read a new send at once. + */ + sendsInline: boolean; /** True while the conversation has a Turn that a stop can end. */ stoppable: boolean; }) { @@ -593,10 +608,21 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { if (!window.matchMedia("(max-width: 767px)").matches) return; onPinRequestRef.current(); }, [pendingMessageVersion]); + // No Turn runs and nothing waits in the queue, so the next send would + // leave the queue almost at once. Show it in the transcript instead. A + // conversation can be active before its Turn starts; that still counts. + const committedIds = new Set(props.committedMessageIds); + const sendInlineRef = useRef(false); + sendInlineRef.current = + props.sendsInline && + !props.pendingMessages.some( + (message) => !committedIds.has(message.messageId), + ); const onSubmit = useCallback( async (message: string, idempotencyKey: string, images?: InputImage[]) => { await appendMessageRef.current.mutateAsync({ idempotencyKey, + inline: sendInlineRef.current, message, images, }); diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index bd26654b78..29a480fa2a 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -1,5 +1,6 @@ import { memo, useMemo, useRef, type ReactNode } from "react"; import { History } from "lucide-react"; +import type { ConversationMailboxMessage } from "./conversationOutbox"; import { EmptyTelemetry } from "../components/EmptyTelemetry"; import { @@ -81,13 +82,20 @@ export const ConversationTranscriptView = memo( conversation: ConversationTranscript; }) => void; conversation: ConversationTranscript; + /** Sends that history does not have yet. History wins on message id. */ + pendingMessages?: readonly ConversationMailboxMessage[]; responding?: boolean; }) { // Cards can change while their Message events stay unchanged. const { events, annotations } = props.conversation; const messages = useMemo( - () => transcriptMessagesFromEvents(events, annotations), - [events, annotations], + () => + transcriptMessagesFromEvents( + events, + annotations, + props.pendingMessages, + ), + [events, annotations, props.pendingMessages], ); return ( diff --git a/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx b/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx index b5f341331d..a8fb346e66 100644 --- a/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx +++ b/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx @@ -12,6 +12,7 @@ import { Clock3, FileText, Pencil, + SkipForward, Trash2, Zap, type LucideIcon, @@ -47,7 +48,10 @@ const COLLAPSED_PENDING_ROW_COUNT = 4; const STEER_ICON = Zap; function pendingDeliveryMeta( - message: Pick, + message: Pick< + ConversationMailboxMessage, + "clientStatus" | "delivery" | "source" + >, ): { icon: LucideIcon | typeof Spinner; label: string; tooltip: string } { if (message.clientStatus === "failed") { return { @@ -59,6 +63,15 @@ function pendingDeliveryMeta( if (message.clientStatus === "sending") { return { icon: Spinner, label: "Sending", tooltip: "Sending" }; } + // Only a dashboard steer turns a web message into an interrupt. Other + // sources, such as a Slack mention, arrive as interrupts. + if (message.delivery === "interrupt" && message.source !== "web") { + return { + icon: SkipForward, + label: "Next step", + tooltip: "Junior reads it at its next step", + }; + } if (message.delivery === "interrupt") { return { icon: STEER_ICON, diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptAttachment.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptAttachment.tsx index f3a444dcc2..c5dab695b5 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptAttachment.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptAttachment.tsx @@ -45,6 +45,28 @@ export function TranscriptAttachments(props: { ); } +/** Show the images of a send before the server stores them. */ +export function TranscriptLocalImages(props: { images: InputImage[] }) { + return ( +
+ {props.images.map((image, index) => ( + + ))} +
+ ); +} + +const TRANSCRIPT_IMAGE_CLASS = + "max-h-48 w-auto max-w-full h-auto rounded-lg object-contain"; +const TRANSCRIPT_IMAGE_TRIGGER_CLASS = + "block min-w-0 max-w-full flex-none rounded-lg border border-dashboard-border bg-dashboard-fill-faint transition-colors hover:border-dashboard-border-interactive hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:outline-dashboard-focus"; + /** Render one stored user or assistant attachment using the private read route. */ function TranscriptAttachment(props: { attachment: MessageAttachment; @@ -66,10 +88,10 @@ function TranscriptAttachment(props: { ); } diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx index 3f216a8837..990241c501 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptMessageView.tsx @@ -1,5 +1,8 @@ import { ForkConversationButton } from "./ForkConversationButton"; -import { TranscriptAttachments } from "./TranscriptAttachment"; +import { + TranscriptAttachments, + TranscriptLocalImages, +} from "./TranscriptAttachment"; import { ObjectCard } from "./ObjectCard"; import { AutomationCard } from "../components/AutomationCard"; import { memo, type ReactNode } from "react"; @@ -69,6 +72,9 @@ export const TranscriptMessageView = memo( conversationId={props.conversation.conversationId} /> ) : null} + {props.message.images?.length ? ( + + ) : null} {props.message.cards?.map((card) => { switch (card.kind) { case "object": diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx index 3c28918dca..9d72cb009f 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { ConversationMailboxMessage } from "./conversationOutbox"; import { ArrowDownToLine } from "lucide-react"; import type { @@ -37,6 +38,8 @@ export function Transcript(props: { pinRequestVersion?: number; onLoadPreviousPage?: () => void; onSelectedEventSeqChange?(seq: number | undefined): void; + /** The reader's own sends to show at the end until history has them. */ + pendingMessages?: readonly ConversationMailboxMessage[]; responding?: boolean; search?: string; onSearchChange?(value: string): void; @@ -139,6 +142,7 @@ export function Transcript(props: { diff --git a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts index 97abebc3b0..a23f51f7f0 100644 --- a/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts +++ b/packages/junior-dashboard/src/client/conversations/conversationOutbox.ts @@ -9,6 +9,11 @@ export type ConversationOutboxMessage = { actorIdentity?: ActorIdentity; createdAt: string; idempotencyKey: string; + /** + * Sent while Junior was idle and the queue was empty. The transcript shows + * the row in place, so it does not pass through the queue first. + */ + inline?: boolean; message: string; images?: InputImage[]; messageId: string; @@ -31,6 +36,7 @@ export function conversationOutboxQueryKey(conversationId: string | undefined) { export function conversationOutboxMessageForSubmit(input: { actorIdentity?: ActorIdentity; idempotencyKey: string; + inline?: boolean; message: string; images?: InputImage[]; messageId: string; @@ -41,6 +47,7 @@ export function conversationOutboxMessageForSubmit(input: { actorIdentity: input.actorIdentity, createdAt, idempotencyKey: input.idempotencyKey, + ...(input.inline ? { inline: true } : undefined), message: input.message, ...(input.images?.length ? { images: input.images } : undefined), messageId: input.messageId, @@ -153,6 +160,45 @@ function sameActorIdentity( ); } +/** True while an outbox row belongs in the transcript instead of the queue. */ +export function isInlineOutboxMessage( + message: ConversationOutboxMessage, +): boolean { + // A failed send moves to the queue, which owns the retry action. + return Boolean(message.inline) && message.status !== "failed"; +} + +/** + * Split pending rows between the queue and the transcript. + * + * Inline rows prefer the server copy once the mailbox has it, so attachments + * and identity match what the queue would show. + */ +export function splitInlineMailboxMessages( + merged: readonly ConversationMailboxMessage[], + server: readonly ConversationPendingMessage[] | undefined, + outbox: readonly ConversationOutboxMessage[], +): { + inline: ConversationMailboxMessage[]; + queued: ConversationMailboxMessage[]; +} { + const inlineRows = outbox.filter(isInlineOutboxMessage); + if (inlineRows.length === 0) { + return { inline: [], queued: merged as ConversationMailboxMessage[] }; + } + const inlineIds = new Set(inlineRows.map((message) => message.messageId)); + const serverById = new Map( + (server ?? []).map((message) => [message.messageId, message]), + ); + return { + inline: inlineRows.map( + (message) => + serverById.get(message.messageId) ?? mailboxMessageFromOutbox(message), + ), + queued: merged.filter((message) => !inlineIds.has(message.messageId)), + }; +} + /** Append or replace one outbox row by idempotency key. */ export function upsertConversationOutboxMessage( current: readonly ConversationOutboxMessage[] | undefined, diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 53228bef10..9ab4e2338e 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -2,6 +2,7 @@ import { resolveMessageCards } from "@sentry/junior/api/schema"; import type { ConversationPendingMessage, ConversationReportEvent, + InputImage, } from "@sentry/junior/api/schema"; import type { @@ -10,6 +11,11 @@ import type { TranscriptViewPart, } from "../types"; +/** A mailbox row, or a local send that can still carry its own images. */ +type PendingTranscriptInput = ConversationPendingMessage & { + images?: InputImage[]; +}; + type ToolCall = | Extract< ConversationReportEvent["data"], @@ -67,7 +73,7 @@ function historyMessageIds( /** Project one accepted mailbox row into a pending transcript message. */ export function pendingTranscriptMessage( - message: ConversationPendingMessage, + message: PendingTranscriptInput, index: number, ): TranscriptViewMessage { return { @@ -77,6 +83,7 @@ export function pendingTranscriptMessage( ? [{ type: "text", redacted: true }] : [{ type: "text", text: message.text ?? "" }], ...(message.attachments ? { attachments: message.attachments } : undefined), + ...(message.images?.length ? { images: message.images } : undefined), pending: true, role: "user", source: message.source, @@ -97,7 +104,7 @@ export function pendingTranscriptMessage( */ export function mergePendingTranscriptMessages( messages: TranscriptViewMessage[], - pending: readonly ConversationPendingMessage[] | undefined, + pending: readonly PendingTranscriptInput[] | undefined, ): TranscriptViewMessage[] { if (!pending?.length) return messages; const extras = unresolvedPendingTranscriptMessages(messages, pending); @@ -112,7 +119,7 @@ export function mergePendingTranscriptMessages( */ export function unresolvedPendingTranscriptMessages( messages: readonly TranscriptViewMessage[], - pending: readonly ConversationPendingMessage[] | undefined, + pending: readonly PendingTranscriptInput[] | undefined, ): TranscriptViewMessage[] { if (!pending?.length) return []; const committedIds = historyMessageIds(messages); @@ -124,7 +131,7 @@ export function unresolvedPendingTranscriptMessages( /** Reduce the ordered reporting event API into dashboard-only transcript rows. */ export function conversationTranscriptMessages( conversation: ConversationTranscript, - pendingMessages?: readonly ConversationPendingMessage[], + pendingMessages?: readonly PendingTranscriptInput[], ): TranscriptViewMessage[] { return transcriptMessagesFromEvents( conversation.events, @@ -137,7 +144,7 @@ export function conversationTranscriptMessages( export function transcriptMessagesFromEvents( events: ConversationReportEvent[], annotations: ConversationTranscript["annotations"], - pendingMessages?: readonly ConversationPendingMessage[], + pendingMessages?: readonly PendingTranscriptInput[], ): TranscriptViewMessage[] { const replacedToolIds = specialToolIds(events); const tools = new Map< diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index 80d151e9e2..ddf47067b9 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -43,8 +43,11 @@ import { conversationOutboxMessageForSubmit, conversationOutboxQueryKey, failConversationOutboxMessage, + isInlineOutboxMessage, mergeConversationMailboxMessages, acceptConversationOutboxMessage, + reuseConversationMailboxMessages, + splitInlineMailboxMessages, upsertConversationOutboxMessage, type ConversationMailboxMessage, type ConversationOutboxMessage, @@ -131,6 +134,11 @@ export function usePendingArchiveConversationUpdates() { }); } +// Right after a send, Junior usually reads the message within a few seconds. +// Poll fast for that window only, then fall back to the normal live interval. +const FAST_POLL_INTERVAL_MS = 500; +const FAST_POLL_WINDOW_MS = 6_000; + type ConversationSnapshot = ConversationDetailReport & { mailbox?: ConversationPendingMessagesReport; detailEtag?: string; @@ -150,7 +158,11 @@ async function readConversationSnapshot( /** Refresh history and mailbox as one visible snapshot, including idle input. */ export function conversationDetailQueryOptions( conversationId: string | undefined, - options?: { awaitingMessage?: boolean }, + options?: { + awaitingMessage?: boolean; + /** Poll fast until this time so a new send reaches history quickly. */ + fastPollUntilMs?: number; + }, ) { return queryOptions({ enabled: Boolean(conversationId), @@ -172,12 +184,14 @@ export function conversationDetailQueryOptions( ); }, refetchInterval: (query) => - options?.awaitingMessage || - query.state.data?.status === "active" || - Boolean(query.state.data?.mailbox?.messages.length) || - Boolean(query.state.data?.mailbox?.authorization) - ? 2_000 - : 10_000, + options?.fastPollUntilMs && Date.now() < options.fastPollUntilMs + ? FAST_POLL_INTERVAL_MS + : options?.awaitingMessage || + query.state.data?.status === "active" || + Boolean(query.state.data?.mailbox?.messages.length) || + Boolean(query.state.data?.mailbox?.authorization) + ? 2_000 + : 10_000, retry: false, }); } @@ -214,6 +228,8 @@ export function useCreateConversation() { ...conversationOutboxMessageForSubmit({ ...args, actorIdentity: outboxActorIdentity(queryClient), + // A new Conversation has no Turn or queue yet. + inline: true, messageId: accepted.messageId, }), status: "accepted", @@ -262,6 +278,8 @@ export function useForkConversation(sourceConversationId: string) { idempotencyKey: args.idempotencyKey, message: args.message, actorIdentity: outboxActorIdentity(queryClient), + // A new fork has no Turn or queue yet. + inline: true, messageId: accepted.messageId, }), status: "accepted", @@ -279,15 +297,20 @@ export function useAppendConversationMessage(conversationId: string) { const queryClient = useQueryClient(); const outboxQueryKey = conversationOutboxQueryKey(conversationId); return useMutation({ - mutationFn: (args: { + mutationFn: ({ + inline: _inline, + ...body + }: { idempotencyKey: string; + /** Show the send in the transcript instead of the queue. */ + inline?: boolean; message: string; images?: InputImage[]; }) => post( acceptedConversationMessageSchema, `/api/conversations/${encodeURIComponent(conversationId)}/messages`, - args, + body, ), onMutate: async (args) => { // Use the durable id from the first render. No server read is needed, @@ -674,11 +697,20 @@ export function useConversationData(conversationId: string | undefined) { staleTime: Infinity, gcTime: Infinity, }); - const awaitingMessage = outbox.data.some( + const awaitingRows = outbox.data.filter( (message) => message.status !== "failed", ); + const latestSendAtMs = Math.max( + 0, + ...awaitingRows.map((message) => Date.parse(message.createdAt)), + ); const detail = useQuery( - conversationDetailQueryOptions(conversationId, { awaitingMessage }), + conversationDetailQueryOptions(conversationId, { + awaitingMessage: awaitingRows.length > 0, + fastPollUntilMs: latestSendAtMs + ? latestSendAtMs + FAST_POLL_WINDOW_MS + : undefined, + }), ); // Defer the whole server snapshot, not history alone. Queue removal and the // matching transcript must paint together while composer input stays urgent. @@ -694,17 +726,23 @@ export function useConversationData(conversationId: string | undefined) { // contains its real Message id. History and mailbox arrive in one render. useEffect(() => { if (!mailbox || !detailData) return; - const observedIds = new Set( + const queuedIds = new Set( mailbox.messages.map((message) => message.messageId), ); + const historyIds = new Set(); for (const event of detailData.events) { - if (event.data.type === "message") observedIds.add(event.data.messageId); + if (event.data.type === "message") historyIds.add(event.data.messageId); } queryClient.setQueryData( conversationOutboxQueryKey(conversationId), (current) => { + // An inline row stays until history has it. The mailbox copy alone + // would put it back in the queue. const next = current?.filter( - (message) => !observedIds.has(message.messageId), + (message) => + !historyIds.has(message.messageId) && + (isInlineOutboxMessage(message) || + !queuedIds.has(message.messageId)), ); return next?.length === current?.length ? current : next; }, @@ -774,23 +812,37 @@ export function useConversationData(conversationId: string | undefined) { const pendingMessagesRef = useRef( undefined, ); - const pendingMessages = useMemo(() => { + const inlineMessagesRef = useRef( + undefined, + ); + const { inlineMessages, pendingMessages } = useMemo(() => { // Hide stopped rows from both sources, or the outbox copy of a stopped // send would look unseen and come back. const stopped = stoppedMessageIds.data; - const next = mergeConversationMailboxMessages( - stopped.length - ? mailbox?.messages.filter( - (message) => !stopped.includes(message.inboundMessageId), - ) - : mailbox?.messages, - stopped.length - ? outbox.data.filter((message) => !stopped.includes(message.messageId)) - : outbox.data, + const server = stopped.length + ? mailbox?.messages.filter( + (message) => !stopped.includes(message.inboundMessageId), + ) + : mailbox?.messages; + const local = stopped.length + ? outbox.data.filter((message) => !stopped.includes(message.messageId)) + : outbox.data; + const split = splitInlineMailboxMessages( + mergeConversationMailboxMessages(server, local), + server, + local, + ); + const queued = reuseConversationMailboxMessages( pendingMessagesRef.current, + split.queued, + ); + const inline = reuseConversationMailboxMessages( + inlineMessagesRef.current, + split.inline, ); - pendingMessagesRef.current = next; - return next; + pendingMessagesRef.current = queued; + inlineMessagesRef.current = inline; + return { inlineMessages: inline, pendingMessages: queued }; }, [outbox.data, mailbox?.messages, stoppedMessageIds.data]); const invalidHistoryCursor = isInvalidCursorError(history.error); const shouldRefreshDetail = Boolean( @@ -844,6 +896,8 @@ export function useConversationData(conversationId: string | undefined) { isLoadingPreviousPage, pendingAuthorization: mailbox?.authorization, pendingGeneratedAt: mailbox?.generatedAt, + /** The reader's own sends that the transcript shows before history has them. */ + inlineMessages, pendingMessages, loadCompleteTranscript: () => { if (!conversationId || !detailData) { diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 0317f80d75..ce5f414ffa 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -1,6 +1,7 @@ import type { ObjectType } from "@sentry/junior-plugin-api"; import type { BundledLanguage } from "shiki/bundle/web"; import type { + InputImage, MessageAttachment, MessageCard, PluginOperationalReportFeed, @@ -151,6 +152,8 @@ export type TranscriptViewMessage = { failureReason?: ConversationTurnFailureReason; sentryEventUrl?: string; attachments?: MessageAttachment[]; + /** Local images of a pending send that the server has not stored yet. */ + images?: InputImage[]; parts: TranscriptViewPart[]; role: "assistant" | "system" | "tool" | "user"; source?: "slack" | "web"; diff --git a/packages/junior-dashboard/tests/conversation-outbox.test.ts b/packages/junior-dashboard/tests/conversation-outbox.test.ts index 1a63591360..8b5a37a6ec 100644 --- a/packages/junior-dashboard/tests/conversation-outbox.test.ts +++ b/packages/junior-dashboard/tests/conversation-outbox.test.ts @@ -5,6 +5,7 @@ import { failConversationOutboxMessage, mailboxMessageFromOutbox, mergeConversationMailboxMessages, + splitInlineMailboxMessages, upsertConversationOutboxMessage, } from "../src/client/conversations/conversationOutbox"; @@ -156,4 +157,37 @@ describe("conversation outbox", () => { expect(second).not.toBe(first); expect(second[0]?.actorIdentity?.fullName).toBe("Morgan Lee"); }); + + it("shows inline sends in the transcript until a failure moves them to the queue", () => { + const inline = conversationOutboxMessageForSubmit({ + idempotencyKey: "attempt-1", + inline: true, + messageId: "accepted-1", + message: "Continue in Junior", + now: "2026-01-01T00:00:00.000Z", + }); + const server = [ + { + ...mailboxMessageFromOutbox(inline), + clientStatus: undefined, + idempotencyKey: undefined, + actorIdentity: { fullName: "Morgan Lee" }, + }, + ]; + // The server copy wins, but it stays out of the queue. + expect( + splitInlineMailboxMessages( + mergeConversationMailboxMessages(server, [inline]), + server, + [inline], + ), + ).toEqual({ inline: server, queued: [] }); + + const failed = failConversationOutboxMessage([inline], "attempt-1"); + const merged = mergeConversationMailboxMessages([], failed); + expect(splitInlineMailboxMessages(merged, [], failed)).toEqual({ + inline: [], + queued: merged, + }); + }); }); diff --git a/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx b/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx index 1b2f0d77ed..cb73f788d4 100644 --- a/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx +++ b/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx @@ -117,6 +117,7 @@ describe("PendingMailboxStack remove control", () => { message({ actorIdentity: own, inboundMessageId: "slack-1", + delivery: "interrupt", messageId: "slack-1", source: "slack", }), @@ -136,6 +137,9 @@ describe("PendingMailboxStack remove control", () => { expect(html.match(/aria-label="Edit queued message"/g)).toHaveLength(2); // A steered row is already an interrupt, so it cannot be steered again. expect(html.match(/aria-label="Steer queued message"/g)).toHaveLength(1); + // A Slack mention arrives as an interrupt. No one steered it. + expect(html.match(/aria-label="Steered"/g)).toHaveLength(1); + expect(html.match(/aria-label="Next step"/g)).toHaveLength(1); }); it("shows edit only on the viewer's own accepted web rows", () => {