From e77493f880019a6a2c60d9476ecf4ee67def06f6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:32:21 +0000 Subject: [PATCH 1/6] feat(dashboard): Compact queued messages and let readers edit them Show the author avatar, image thumbnails, and one truncated line for each queued message. Add an edit action on the reader's own queued web messages that takes the message out of the queue and puts its text and images back in the composer. Co-Authored-By: adm --- .../e2e/conversation-send.spec.ts | 103 +++++- .../components/ParticipantAvatarStack.tsx | 3 +- .../client/conversations/ComposerImages.tsx | 24 ++ .../conversations/ConversationComposer.tsx | 60 +++- .../client/conversations/ConversationPage.tsx | 63 +++- .../conversations/PendingMailboxStack.tsx | 307 ++++++++++++------ .../conversations/TranscriptAttachment.tsx | 11 +- .../src/mock-reporting/fixtures.ts | 8 + .../tests/pending-mailbox-stack.test.tsx | 32 ++ 9 files changed, 499 insertions(+), 112 deletions(-) diff --git a/packages/junior-dashboard/e2e/conversation-send.spec.ts b/packages/junior-dashboard/e2e/conversation-send.spec.ts index 26041c80c5..23d70a94f8 100644 --- a/packages/junior-dashboard/e2e/conversation-send.spec.ts +++ b/packages/junior-dashboard/e2e/conversation-send.spec.ts @@ -220,9 +220,8 @@ test("starts and continues conversations from the dashboard", async ({ await page.getByRole("button", { name: "Send" }).click(); const pending = page.getByLabel("Pending messages"); await expect(pending.getByText("Continue in Junior")).toBeVisible(); - await expect( - pending.getByText("dev@example.com", { exact: true }), - ).toBeVisible(); + // The author avatar carries the name and email for hover and screen readers. + await expect(pending.getByLabel(/dev@example\.com/)).toBeVisible(); await expect(composer).toHaveValue(""); await expect.poll(() => continueRequests.length).toBe(1); await expect(details).toBeVisible(); @@ -672,3 +671,101 @@ test("stops the active Turn and returns queued text to the composer", async ({ active = false; await expect(stop).toBeHidden({ timeout: 15_000 }); }); + +test("edits a queued message back into the composer", async ({ + page, + dashboard, +}) => { + const conversationId = "slack:CQA123:1770000000.000100"; + const path = `**/api/conversations/${encodeURIComponent(conversationId)}`; + const createdAt = "2026-06-12T00:00:00.000Z"; + const queuedMessage: ConversationPendingMessage & { text: string } = { + actorIdentity: { email: "dev@example.com" }, + attachments: [ + { + bytes: 18211, + contentType: "image/png", + filename: "queued-chart.png", + id: "qa-chart-png", + }, + ], + createdAt, + receivedAt: createdAt, + delivery: "defer", + inboundMessageId: "queued-edit", + messageId: "queued-edit", + role: "user", + source: "web", + text: "Queued follow-up", + }; + // Junior reads this one before the edit lands, so the cancel finds nothing. + const readMessage = { + ...queuedMessage, + attachments: undefined, + inboundMessageId: "queued-read", + messageId: "queued-read", + text: "Already read", + }; + let queued: ConversationPendingMessage[] = [queuedMessage, readMessage]; + await page.route(`${path}/pending-messages`, async (route) => { + if (route.request().method() !== "DELETE") { + await route.fulfill({ + json: { conversationId, generatedAt: createdAt, messages: queued }, + }); + return; + } + const [id] = route.request().postDataJSON().inboundMessageIds as string[]; + const cancelled = id === queuedMessage.inboundMessageId ? [id] : []; + queued = queued.filter((message) => message.inboundMessageId !== id); + await route.fulfill({ + json: { + cancelledCount: cancelled.length, + cancelledInboundMessageIds: cancelled, + conversationId, + }, + }); + }); + await page.route(path, async (route) => { + const response = await route.fetch({ + headers: { ...route.request().headers(), "if-none-match": "" }, + }); + const detail = await response.json(); + await route.fulfill({ + response, + json: { ...detail, isParticipant: true, status: "active" }, + }); + }); + + await page.goto( + `${dashboard.baseURL}/conversations/${encodeURIComponent(conversationId)}`, + ); + const composer = page.getByLabel("Continue this conversation"); + const pending = page.getByLabel("Pending messages"); + await expect(pending.getByText(queuedMessage.text)).toBeVisible(); + await expect( + pending.getByRole("img", { name: "queued-chart.png" }), + ).toBeVisible(); + await screenshot(page, "conversation-queue-compact"); + + // Text goes ahead of the draft and the image comes back with it. + await composer.fill("Draft in progress"); + await pending + .getByRole("button", { name: "Edit queued message" }) + .first() + .click(); + await expect(composer).toHaveValue( + `${queuedMessage.text}\nDraft in progress`, + ); + await expect(page.getByRole("img", { name: "queued-chart.png" })).toHaveCount( + 1, + ); + await expect(pending.getByText(queuedMessage.text)).toBeHidden(); + + await pending.getByRole("button", { name: "Edit queued message" }).click(); + await expect( + page.getByText("Junior already read that message."), + ).toBeVisible(); + await expect(composer).toHaveValue( + `${queuedMessage.text}\nDraft in progress`, + ); +}); diff --git a/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx b/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx index 500c39c84f..3ec49f6c68 100644 --- a/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx +++ b/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx @@ -51,7 +51,8 @@ function ParticipantTooltipContent(props: { participant: ActorIdentity }) { ); } -function ParticipantAvatar(props: { +/** Show one actor avatar with their name and email on hover or focus. */ +export function ParticipantAvatar(props: { participant: ActorIdentity; size: ParticipantAvatarStackProps["size"]; }) { diff --git a/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx b/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx index ec884f0ad0..ffdb38cadb 100644 --- a/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx +++ b/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx @@ -4,7 +4,9 @@ import { MAX_INPUT_IMAGE_BYTES, MAX_INPUT_IMAGES, type InputImage, + type MessageAttachment, } from "@sentry/junior/api/schema"; +import { conversationAttachmentUrl } from "./TranscriptAttachment"; /** Read a bounded image selection for the message request and local previews. */ export async function readComposerImages( @@ -51,6 +53,28 @@ export async function readComposerImages( return [...current, ...images]; } +/** Read stored image attachments back into composer images for an edit. */ +export async function readStoredComposerImages( + conversationId: string, + attachments: readonly MessageAttachment[] | undefined, +): Promise { + const images = (attachments ?? []).filter((attachment) => + INPUT_IMAGE_TYPES.some((type) => type === attachment.contentType), + ); + const files = await Promise.all( + images.map(async (attachment) => { + const response = await fetch( + conversationAttachmentUrl(conversationId, attachment.id), + ); + if (!response.ok) throw new Error("Could not read the queued image."); + return new File([await response.blob()], attachment.filename, { + type: attachment.contentType, + }); + }), + ); + return await readComposerImages(files, []); +} + /** Show removable images before sending; bytes stay in memory until submit. */ export function ComposerImages(props: { images: InputImage[]; diff --git a/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx b/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx index 1e8479879c..2a956d05e5 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx @@ -1,14 +1,21 @@ -import { INPUT_IMAGE_TYPES, type InputImage } from "@sentry/junior/api/schema"; +import { + INPUT_IMAGE_TYPES, + MAX_INPUT_IMAGE_BYTES, + MAX_INPUT_IMAGES, + type InputImage, +} from "@sentry/junior/api/schema"; import { ComposerImages, readComposerImages } from "./ComposerImages"; import { memo, useEffect, useId, + useImperativeHandle, useRef, useState, type FormEvent, type KeyboardEvent, type ReactNode, + type Ref, } from "react"; import { ImagePlus, Send, Square } from "lucide-react"; @@ -56,6 +63,16 @@ export function conversationAttemptForSubmit( }; } +/** Imperative composer actions for the reply footer. */ +export type ConversationComposerHandle = { + /** Put queued text ahead of the draft and add its images when they fit. */ + restoreMessage(message: { images?: InputImage[]; text: string }): void; +}; + +function imageBytes(images: readonly InputImage[]): number { + return images.reduce((sum, image) => sum + (image.data.length * 3) / 4, 0); +} + type ConversationComposerProps = { disabled?: boolean; draftId: string; @@ -73,6 +90,8 @@ type ConversationComposerProps = { * the pending queue instead. */ restoreDraftOnError?: boolean; + /** Lets the parent put a message taken out of the queue back in the draft. */ + ref?: Ref; submitLabel: string; onFocus?: () => void; onSubmit( @@ -306,6 +325,16 @@ export const ConversationComposer = memo(function ConversationComposer( }; // Put queued text back ahead of anything the reader already typed. + const prependDraftText = (text: string) => { + const current = draftRef.current.text; + const next: ConversationDraft = { + ...draftRef.current, + text: current.trim() ? `${text}\n${current}` : text, + }; + replaceMessage(next); + storeDraft(storageKey, next); + }; + const stopTurn = async () => { if (!props.onStop || stoppingRef.current) return; stoppingRef.current = true; @@ -319,16 +348,31 @@ export const ConversationComposer = memo(function ConversationComposer( stoppingRef.current = false; } if (!restored) return; - const current = draftRef.current.text; - const next: ConversationDraft = { - ...draftRef.current, - text: current.trim() ? `${restored}\n${current}` : restored, - }; - replaceMessage(next); - storeDraft(storageKey, next); + prependDraftText(restored); textareaRef.current?.focus({ preventScroll: true }); }; + useImperativeHandle(props.ref, () => ({ + restoreMessage: (message) => { + prependDraftText(message.text); + const restoredImages = message.images ?? []; + if (restoredImages.length) { + const merged = [...restoredImages, ...imagesRef.current]; + if ( + merged.length > MAX_INPUT_IMAGES || + imageBytes(merged) > MAX_INPUT_IMAGE_BYTES + ) { + setImageError( + "The edited message images did not fit. Add them again.", + ); + } else { + replaceImages(merged); + } + } + textareaRef.current?.focus({ preventScroll: true }); + }, + })); + const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === "Enter" && diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index b12a610e8f..8ce7878bb7 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -32,7 +32,12 @@ import { CONVERSATION_EVENT_PARAM, parseConversationEventSeq, } from "./conversationRoutes"; -import { ConversationComposer } from "./ConversationComposer"; +import { + ConversationComposer, + type ConversationComposerHandle, +} from "./ConversationComposer"; +import { readStoredComposerImages } from "./ComposerImages"; +import { useDashboardCoreData } from "../api"; import { ConversationHeader } from "./ConversationHeader"; import { ConversationDetails } from "./ConversationDetails"; import { ConversationEventDetails } from "./ConversationEventDetails"; @@ -558,6 +563,9 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { const cancelPendingMessages = useCancelConversationPendingMessages( props.conversationId, ); + const [editTargetInboundMessageId, setEditTargetInboundMessageId] = + useState(); + const [editError, setEditError] = useState(); // Keep submit identity stable across mutation status flips so the memoized // composer does not re-render while the reader is still typing. const appendMessageRef = useRef(appendMessage); @@ -608,6 +616,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { cancelPendingMessagesRef.current.reset(); } if (!stopTurnRef.current.isPending) stopTurnRef.current.reset(); + setEditError(undefined); onPinRequestRef.current(); }, []); const cancellableMessageIds = props.pendingMessages @@ -633,6 +642,48 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { ), ); + // Edit uses its own cancel mutation so remove errors stay on remove. + const editCancel = useCancelConversationPendingMessages(props.conversationId); + const editCancelRef = useRef(editCancel); + editCancelRef.current = editCancel; + const composerRef = useRef(null); + const viewerEmail = useDashboardCoreData().data?.me.user.email; + const onEditMessage = useCallback( + async (message: ConversationMailboxMessage) => { + const receivedBefore = props.pendingGeneratedAtRef.current; + if (!receivedBefore || editCancelRef.current.isPending) return; + const inboundMessageId = message.inboundMessageId; + setEditTargetInboundMessageId(inboundMessageId); + setEditError(undefined); + try { + // Read images first. A failed read must leave the message queued. + const images = await readStoredComposerImages( + props.conversationId, + message.attachments, + ); + const result = await editCancelRef.current.mutateAsync({ + inboundMessageIds: [inboundMessageId], + receivedBefore, + }); + // Junior can read the message before the cancel lands. Then it is + // already in the Conversation and there is nothing to edit. + if (!result.cancelledInboundMessageIds.includes(inboundMessageId)) { + setEditError("Junior already read that message."); + return; + } + composerRef.current?.restoreMessage({ + images, + text: message.text ?? "", + }); + } catch { + setEditError("Could not edit the queued message. Try again."); + } finally { + setEditTargetInboundMessageId(undefined); + } + }, + [props.conversationId, props.pendingGeneratedAtRef], + ); + const onStop = useCallback(async () => { const result = await stopTurnRef.current.mutateAsync(); const restored = result.stoppedMessages.map((message) => message.text); @@ -662,17 +713,25 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { cancelPending={cancelPendingMessages.isPending} cancelTargetInboundMessageId={cancelTargetInboundMessageId} committedMessageIds={props.committedMessageIds} + conversationId={props.conversationId} + editTargetInboundMessageId={editTargetInboundMessageId} messages={props.pendingMessages} onCancelMessage={onCancelMessage} + onEditMessage={onEditMessage} onLayoutChange={onMailboxLayoutChange} onRetry={onRetry} + viewerEmail={viewerEmail} /> } > + + {props.pending ? ( +