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
130 changes: 127 additions & 3 deletions packages/junior-dashboard/e2e/conversation-send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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`,
);
});
4 changes: 2 additions & 2 deletions packages/junior-dashboard/e2e/conversations-mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions packages/junior-dashboard/e2e/conversations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,22 +246,22 @@ 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" })
.click();
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 ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
}) {
Expand All @@ -62,6 +63,8 @@ function ParticipantAvatar(props: {
render={
<span
aria-label={label}
// A name on a generic span is often skipped by screen readers.
role="img"
className="relative -ml-1 inline-flex rounded-full first:ml-0 pointer-events-none focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus [@media(hover:hover)_and_(pointer:fine)]:pointer-events-auto"
onKeyDown={(event) => event.stopPropagation()}
tabIndex={0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -13,10 +31,7 @@ export async function readComposerImages(
): Promise<InputImage[]> {
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
Expand Down Expand Up @@ -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<InputImage[]> {
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[];
Expand All @@ -67,7 +104,7 @@ export function ComposerImages(props: {
<img
alt={image.filename}
className="h-20 w-24 rounded-md object-contain"
src={`data:${image.contentType};base64,${image.data}`}
src={inputImageSrc(image)}
/>
<button
aria-label={`Remove ${image.filename}`}
Expand Down
Loading
Loading