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: {
+ }
+ />
+ {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 ? (
-
- ) : (
-
- )}
-
- }
- />
- Remove queued message
-
+
) : 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);
});
});