diff --git a/packages/junior-dashboard/e2e/conversation-send.spec.ts b/packages/junior-dashboard/e2e/conversation-send.spec.ts index 26041c80c5..32574e4910 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,128 @@ 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"); + + // A draft with no room for the queued image keeps the message queued. + await composer.evaluate((element, data) => { + const dataTransfer = new DataTransfer(); + for (const name of ["one.png", "two.png", "three.png"]) { + dataTransfer.items.add( + new File([Uint8Array.from(atob(data), (c) => c.charCodeAt(0))], name, { + type: "image/png", + }), + ); + } + element.dispatchEvent( + new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }), + ); + }, mockChartPng.toString("base64")); + await expect(page.getByRole("img", { name: "three.png" })).toBeVisible(); + await pending + .getByRole("button", { name: "Edit queued message" }) + .first() + .click(); + await expect( + page.getByText("Remove some draft images to edit that message."), + ).toBeVisible(); + await expect(pending.getByText(queuedMessage.text)).toBeVisible(); + for (const name of ["one.png", "two.png", "three.png"]) { + await page.getByRole("button", { name: `Remove ${name}` }).click(); + } + + // 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/e2e/conversations-mobile.spec.ts b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts index 5392f886f9..5c1dd38710 100644 --- a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts +++ b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts @@ -297,10 +297,10 @@ test("opens and closes a conversation on mobile", async ({ const pending = page.getByLabel("Pending messages"); await expect(pending).toBeVisible(); // Mobile collapsed control owns the total count. - const expand = pending.getByRole("button", { name: "5 queued messages" }); + const expand = pending.getByRole("button", { name: "7 queued messages" }); await expect(expand).toBeVisible(); await expect(expand).toHaveAttribute("aria-expanded", "false"); - await expect(pending.getByText("5 queued messages")).toHaveCount(1); + await expect(pending.getByText("7 queued messages")).toHaveCount(1); await expect( pending.getByText("Also check the canary traffic from the last deploy."), ).toBeHidden(); diff --git a/packages/junior-dashboard/e2e/conversations.spec.ts b/packages/junior-dashboard/e2e/conversations.spec.ts index 362f44fec1..7d4a379d61 100644 --- a/packages/junior-dashboard/e2e/conversations.spec.ts +++ b/packages/junior-dashboard/e2e/conversations.spec.ts @@ -246,14 +246,14 @@ test("collapses long pending message stacks", async ({ page, dashboard }) => { }); await expect(expand).toBeVisible(); await expect(expand).toHaveAttribute("aria-expanded", "false"); - await expect(pending.getByText("Third queued message.")).toBeHidden(); + await expect(pending.getByText("Fifth queued message.")).toBeHidden(); await expand.click(); await expect( pending.getByRole("button", { name: "Show fewer queued messages" }), ).toHaveAttribute("aria-expanded", "true"); - await expect(pending.getByText("Third queued message.")).toBeVisible(); await expect(pending.getByText("Fifth queued message.")).toBeVisible(); + await expect(pending.getByText("Seventh queued message.")).toBeVisible(); await pending .getByRole("button", { name: "Show fewer queued messages" }) @@ -261,7 +261,7 @@ test("collapses long pending message stacks", async ({ page, dashboard }) => { await expect( pending.getByRole("button", { name: "3 more queued messages" }), ).toHaveAttribute("aria-expanded", "false"); - await expect(pending.getByText("Third queued message.")).toBeHidden(); + await expect(pending.getByText("Fifth queued message.")).toBeHidden(); }); test("scrolls long conversation and transcript panes independently", async ({ diff --git a/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx b/packages/junior-dashboard/src/client/components/ParticipantAvatarStack.tsx index 500c39c84f..8ea9f3f91c 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"]; }) { @@ -62,6 +63,8 @@ function ParticipantAvatar(props: { render={ event.stopPropagation()} tabIndex={0} diff --git a/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx b/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx index ec884f0ad0..a80793386d 100644 --- a/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx +++ b/packages/junior-dashboard/src/client/conversations/ComposerImages.tsx @@ -4,7 +4,25 @@ import { MAX_INPUT_IMAGE_BYTES, MAX_INPUT_IMAGES, type InputImage, + type MessageAttachment, } from "@sentry/junior/api/schema"; +import { + conversationAttachmentUrl, + inputImageSrc, +} from "./TranscriptAttachment"; + +/** Decoded size of base64 image data, as the server limit counts it. */ +function inputImageBytes(images: readonly InputImage[]): number { + return images.reduce((sum, image) => sum + (image.data.length * 3) / 4, 0); +} + +/** True when these images fit the message image count and size limits. */ +export function composerImagesFit(images: readonly InputImage[]): boolean { + return ( + images.length <= MAX_INPUT_IMAGES && + inputImageBytes(images) <= MAX_INPUT_IMAGE_BYTES + ); +} /** Read a bounded image selection for the message request and local previews. */ export async function readComposerImages( @@ -13,10 +31,7 @@ export async function readComposerImages( ): Promise { if (current.length + files.length > MAX_INPUT_IMAGES) throw new Error("Attach up to 3 images."); - const currentBytes = current.reduce( - (sum, image) => sum + (image.data.length * 3) / 4, - 0, - ); + const currentBytes = inputImageBytes(current); if ( currentBytes + files.reduce((sum, file) => sum + file.size, 0) > MAX_INPUT_IMAGE_BYTES @@ -51,6 +66,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[]; @@ -67,7 +104,7 @@ export function ComposerImages(props: { {image.filename} + } + /> + {props.tooltip} + + ); +} + function PendingMetaIcons(props: { - cancelDisabled: boolean; + actionsDisabled: boolean; cancelError: boolean; cancelPending: boolean; + editPending: boolean; message: ConversationMailboxMessage; onCancel?: () => void; + onEdit?: () => void; showSlack: boolean; }) { const delivery = pendingDeliveryMeta(props.message); @@ -96,55 +141,110 @@ function PendingMetaIcons(props: { strokeWidth={2.2} /> + {props.onEdit ? ( + + ) : null} {props.onCancel ? ( - - - {props.cancelPending ? ( - + ) : null} ); } +function PendingAuthorAvatar(props: { message: ConversationMailboxMessage }) { + // Pending rows can come from web or Slack. Redacted rows hide the author. + if (!props.message.actorIdentity) { + return ; + } + return ( + + ); +} + +/** Show small previews so the reader can tell which images a queued message has. */ +function PendingAttachmentThumbnails(props: { + conversationId?: string; + message: ConversationMailboxMessage; +}) { + const thumbnailClassName = "size-6 rounded object-cover"; + const triggerClassName = + "block size-6 flex-none overflow-hidden rounded border border-dashboard-border bg-dashboard-fill-faint transition-colors hover:border-dashboard-border-interactive focus-visible:outline-2 focus-visible:outline-dashboard-focus"; + const localImages = props.message.images ?? []; + const attachments = props.message.attachments ?? []; + if (localImages.length === 0 && attachments.length === 0) return null; + + return ( + + {localImages.map((image, index) => ( + + ))} + {attachments.map((attachment) => { + const isImage = INPUT_IMAGE_TYPES.some( + (type) => type === attachment.contentType, + ); + if (isImage && props.conversationId) { + return ( + + ); + } + return ( + + + ); + })} + + ); +} + function PendingRow(props: { - cancelDisabled: boolean; + actionsDisabled: boolean; cancelError: boolean; cancelPending: boolean; + canEdit: boolean; + conversationId?: string; + editPending: boolean; message: ConversationMailboxMessage; onCancel?(message: ConversationMailboxMessage): void; + onEdit?(message: ConversationMailboxMessage): void; onRetry?(message: ConversationMailboxMessage): void; }) { - const filenames = [ - ...(props.message.images ?? []), - ...(props.message.attachments ?? []), - ] - .map((attachment) => attachment.filename) - .join(", "); - const text = [props.message.text, filenames].filter(Boolean).join(" ยท "); + const text = props.message.text ?? ""; const redacted = Boolean(props.message.redacted); - // Pending rows can come from web or Slack. Avoid rebuilding a full - // transcript projection just to label the stack above the composer. - const roleLabel = actorLabel(props.message.actorIdentity) ?? "User"; const showSlack = props.message.source === "slack"; const canRetry = props.message.clientStatus === "failed" && @@ -152,36 +252,53 @@ function PendingRow(props: { Boolean(props.onRetry); return ( -
+
- {roleLabel} - + <> + + {redacted ? null : ( + + )} + {redacted ? ( +

+ [redacted] +

+ ) : text ? ( + // One line keeps the queue short. The title shows the full text. +

+ {text} +

+ ) : null} + } - leftClassName="text-sm leading-snug text-dashboard-text" + leftClassName="flex-1 text-sm leading-snug text-dashboard-text" right={ props.onCancel?.(props.message) : undefined } + onEdit={ + props.canEdit && props.onEdit + ? () => props.onEdit?.(props.message) + : undefined + } showSlack={showSlack} /> } /> - {redacted ? ( -

- [redacted] -

- ) : ( -

- {text} -

- )} {canRetry ? (

@@ -242,6 +359,12 @@ function ExpandQueuedMessagesButton(props: { ); } +function sameEmail(left: string | undefined, right: string | undefined) { + return Boolean( + left && right && left.trim().toLowerCase() === right.trim().toLowerCase(), + ); +} + /** Render accepted mailbox rows as a compact stack attached above the composer. */ export const PendingMailboxStack = memo(function PendingMailboxStack(props: { cancelError?: boolean; @@ -249,11 +372,19 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: { cancelTargetInboundMessageId?: string; /** Message ids already present in committed history. */ committedMessageIds?: readonly string[]; + /** Conversation that owns stored attachment previews. */ + conversationId?: string; + /** Inbound id of the row that an edit is taking out of the queue. */ + editTargetInboundMessageId?: string; messages: readonly ConversationMailboxMessage[]; onCancelMessage?: (message: ConversationMailboxMessage) => void; + /** Take the viewer's own queued web message back into the composer. */ + onEditMessage?: (message: ConversationMailboxMessage) => void; /** Fires after expand/collapse changes the stack height above the composer. */ onLayoutChange?: () => void; onRetry?(message: ConversationMailboxMessage): void; + /** Email of the signed-in reader. Only their own web rows can be edited. */ + viewerEmail?: string; }): ReactNode { const [expanded, setExpanded] = useState(false); const onLayoutChangeRef = useRef(props.onLayoutChange); @@ -283,6 +414,36 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: { const visibleRows = showCollapsed ? previewRows : rows; const hiddenCount = Math.max(0, rows.length - COLLAPSED_PENDING_ROW_COUNT); const toggleExpanded = () => setExpanded((value) => !value); + const actionsDisabled = + Boolean(props.cancelPending) || Boolean(props.editTargetInboundMessageId); + const renderRow = (message: ConversationMailboxMessage) => { + const isTarget = + props.cancelTargetInboundMessageId === message.inboundMessageId; + // Only accepted server rows can leave the queue. Local rows are still sending. + const accepted = message.clientStatus === undefined; + return ( + + ); + }; return (

{showCollapsed ? ( - // Desktop keeps a two-row preview; mobile collapses to the control only. -
- {previewRows.map((message) => ( - - ))} -
+ // Desktop keeps a short preview; mobile collapses to the control only. +
{previewRows.map(renderRow)}
) : ( - visibleRows.map((message) => ( - - )) + visibleRows.map(renderRow) )} {canCollapse ? ( type === props.attachment.contentType, diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index ff15db5c84..0fb1d93de2 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -1944,6 +1944,14 @@ export function readMockConversationPendingMessages( role: "user" as const, source: "web" as const, text: "Keep the reply in Junior. I will paste the dashboard link next.", + attachments: [ + { + id: "qa-chart-png", + filename: "queued-chart.png", + contentType: "image/png", + bytes: 18211, + }, + ], }, { actorIdentity: actor("dev@example.com", "Morgan Lee", "morgan"), @@ -1978,6 +1986,28 @@ export function readMockConversationPendingMessages( source: "web" as const, text: "Fifth queued message.", }, + { + actorIdentity: actor("dev@example.com", "Morgan Lee", "morgan"), + createdAt: iso(nowMs, -800), + delivery: "defer" as const, + inboundMessageId: `${conversationId}:pending-sixth`, + messageId: `${conversationId}:pending-sixth`, + receivedAt: iso(nowMs, -400), + role: "user" as const, + source: "web" as const, + text: "Sixth queued message.", + }, + { + actorIdentity: actor("dev@example.com", "Morgan Lee", "morgan"), + createdAt: iso(nowMs, -600), + delivery: "defer" as const, + inboundMessageId: `${conversationId}:pending-seventh`, + messageId: `${conversationId}:pending-seventh`, + receivedAt: iso(nowMs, -300), + role: "user" as const, + source: "web" as const, + text: "Seventh queued message.", + }, ] : []; diff --git a/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx b/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx index 4433078b90..5ce45ee151 100644 --- a/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx +++ b/packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx @@ -97,6 +97,38 @@ describe("PendingMailboxStack remove control", () => { expect(html).toContain('aria-label="Could not remove. Try again."'); }); + it("shows edit only on the viewer's own accepted web rows", () => { + const own = { email: "Reader@example.com" }; + const html = renderToStaticMarkup( + undefined} + viewerEmail="reader@example.com" + />, + ); + + expect(html.match(/aria-label="Edit queued message"/g)).toHaveLength(1); + }); + it("hides mailbox rows already present in committed history", () => { const html = renderToStaticMarkup( { }); it("uses the queue count on the mobile expand control", () => { - const messages = Array.from({ length: 5 }, (_, index) => + const messages = Array.from({ length: 7 }, (_, index) => message({ inboundMessageId: `accepted-${index + 1}`, messageId: `accepted-${index + 1}`, @@ -124,8 +156,8 @@ describe("PendingMailboxStack remove control", () => { />, ); - expect(html).toContain("5 queued messages"); + expect(html).toContain("7 queued messages"); expect(html).not.toContain("Show queued messages"); - expect(html.match(/5 queued messages/g)).toHaveLength(1); + expect(html.match(/7 queued messages/g)).toHaveLength(1); }); });