Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e77493f
feat(dashboard): Compact queued messages and let readers edit them
sentry-junior[bot] Oct 30, 2026
5ac13b5
feat(dashboard): Steer a queued message into the running Turn
sentry-junior[bot] Oct 30, 2026
ab745a0
feat(dashboard): Use a delete action for queued messages
sentry-junior[bot] Oct 30, 2026
282d60c
fix(dashboard): Keep edited queued text when the composer is gone
sentry-junior[bot] Oct 30, 2026
5db48ac
Merge branch 'feat/dashboard-compact-queue' into feat/dashboard-steer…
sentry-junior[bot] Oct 30, 2026
6818a04
fix(slack): Leave steered web messages out of Slack turns
sentry-junior[bot] Oct 30, 2026
98bfbf6
feat(dashboard): Show idle sends in the transcript and poll fast afte…
sentry-junior[bot] Oct 30, 2026
f6de856
fix(task-execution): Resume a yielded Turn before steered web messages
sentry-junior[bot] Oct 30, 2026
0ea4484
fix(dashboard): Keep queued images safe during edit
sentry-junior[bot] Oct 30, 2026
853c1c7
Merge branch 'feat/dashboard-compact-queue' into feat/dashboard-steer…
sentry-junior[bot] Oct 30, 2026
cce0978
Merge branch 'feat/dashboard-steer-queued' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
564929d
feat(dashboard): Show more queued messages before the stack collapses
sentry-junior[bot] Oct 30, 2026
d0d3e35
Merge branch 'feat/dashboard-compact-queue' into feat/dashboard-steer…
sentry-junior[bot] Oct 30, 2026
614ab95
Merge branch 'feat/dashboard-steer-queued' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
8b8bbe4
fix(dashboard): Keep queued images when the draft fills during edit
sentry-junior[bot] Oct 30, 2026
4b30bf1
Merge branch 'feat/dashboard-compact-queue' into feat/dashboard-steer…
sentry-junior[bot] Oct 30, 2026
639306f
fix(dashboard): Cancel in-flight polls before a steer updates the cache
sentry-junior[bot] Oct 30, 2026
927ecca
Merge branch 'feat/dashboard-steer-queued' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
d268946
fix(dashboard): Send inline before a Turn starts and show local images
sentry-junior[bot] Oct 30, 2026
7ba2675
ref(dashboard): Share composer image size and preview helpers
sentry-junior[bot] Oct 30, 2026
1770899
Merge branch 'feat/dashboard-compact-queue' into feat/dashboard-steer…
sentry-junior[bot] Oct 30, 2026
f7e3707
ref(steering): Reuse the Turn author for steered web messages
sentry-junior[bot] Oct 30, 2026
c5e250f
Merge branch 'feat/dashboard-steer-queued' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
9d0e92f
fix(dashboard): Queue a send while an earlier inline send waits
sentry-junior[bot] Oct 30, 2026
d0d205d
ref(task-execution): Move attempt input selection out of the worker
sentry-junior[bot] Oct 30, 2026
2f0ee44
Merge branch 'feat/dashboard-steer-queued' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
514c8c7
Merge remote-tracking branch 'origin/main' into feat/dashboard-steer-…
sentry-junior[bot] Oct 30, 2026
e881b41
feat(dashboard): Clarify queued message actions and their tooltips
sentry-junior[bot] Oct 30, 2026
ed0b840
Merge branch 'feat/dashboard-steer-queued' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
3e43f74
fix(dashboard): Label only steered web rows as Steered
sentry-junior[bot] Oct 30, 2026
242c7ad
Merge branch 'feat/dashboard-steer-queued' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
81bc33b
Merge remote-tracking branch 'origin/main' into feat/dashboard-inline…
sentry-junior[bot] Oct 30, 2026
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
81 changes: 67 additions & 14 deletions packages/junior-dashboard/e2e/conversation-send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<void>((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(() => {
Expand Down Expand Up @@ -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();
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}) {
Expand Down Expand Up @@ -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),
);
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
const onSubmit = useCallback(
async (message: string, idempotencyKey: string, images?: InputImage[]) => {
await appendMessageRef.current.mutateAsync({
idempotencyKey,
inline: sendInlineRef.current,
message,
images,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
),
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
[events, annotations, props.pendingMessages],
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Clock3,
FileText,
Pencil,
SkipForward,
Trash2,
Zap,
type LucideIcon,
Expand Down Expand Up @@ -47,7 +48,10 @@ const COLLAPSED_PENDING_ROW_COUNT = 4;
const STEER_ICON = Zap;

function pendingDeliveryMeta(
message: Pick<ConversationMailboxMessage, "clientStatus" | "delivery">,
message: Pick<
ConversationMailboxMessage,
"clientStatus" | "delivery" | "source"
>,
): { icon: LucideIcon | typeof Spinner; label: string; tooltip: string } {
if (message.clientStatus === "failed") {
return {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex min-w-0 flex-wrap items-start gap-2">
{props.images.map((image, index) => (
<ImageAttachment
filename={image.filename}
imageClassName={TRANSCRIPT_IMAGE_CLASS}
key={`${image.filename}:${index}`}
src={inputImageSrc(image)}
triggerClassName={TRANSCRIPT_IMAGE_TRIGGER_CLASS}
/>
))}
</div>
);
}

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;
Expand All @@ -66,10 +88,10 @@ function TranscriptAttachment(props: {
<ImageAttachment
context={meta}
filename={props.attachment.filename}
imageClassName="max-h-48 w-auto max-w-full h-auto rounded-lg object-contain"
imageClassName={TRANSCRIPT_IMAGE_CLASS}
loading="lazy"
src={href}
triggerClassName="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"
triggerClassName={TRANSCRIPT_IMAGE_TRIGGER_CLASS}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -69,6 +72,9 @@ export const TranscriptMessageView = memo(
conversationId={props.conversation.conversationId}
/>
) : null}
{props.message.images?.length ? (
<TranscriptLocalImages images={props.message.images} />
) : null}
{props.message.cards?.map((card) => {
switch (card.kind) {
case "object":
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import type { ConversationMailboxMessage } from "./conversationOutbox";
import { ArrowDownToLine } from "lucide-react";

import type {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -139,6 +142,7 @@ export function Transcript(props: {
<ConversationTranscriptView
onOpenSubagentTranscript={props.onOpenSubagentTranscript}
conversation={props.transcript}
pendingMessages={props.pendingMessages}
responding={props.responding ?? props.live ?? false}
/>
</TurnContextPanelProvider>
Expand Down
Loading
Loading