Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
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
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
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
d0d205d
ref(task-execution): Move attempt input selection out of the worker
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
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
40 changes: 33 additions & 7 deletions packages/junior-dashboard/e2e/conversation-send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ test("hands web and external messages from queue to history without gaps", async
historyHeld = undefined;
queued = [web];
await expect(
pending.getByRole("button", { name: "Remove queued message" }),
pending.getByRole("button", { name: "Delete queued message" }),
).toBeVisible();
await expect(thinking).toBeHidden();

Expand Down Expand Up @@ -518,7 +518,7 @@ test("hands web and external messages from queue to history without gaps", async
await composer.fill(second.text);
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(
pending.getByRole("button", { name: "Remove queued message" }),
pending.getByRole("button", { name: "Delete queued message" }),
).toBeVisible();
await expect(page.getByText(second.text, { exact: true })).toHaveCount(1);
committed = [web, second];
Expand Down Expand Up @@ -554,11 +554,11 @@ test("hands web and external messages from queue to history without gaps", async
timeout: 15_000,
});
await expect(thinking).toBeHidden();
await pending.getByRole("button", { name: "Remove queued message" }).click();
await expect(pending.getByText("Could not remove. Try again.")).toBeVisible();
await pending.getByRole("button", { name: "Delete queued message" }).click();
await expect(pending.getByText("Could not delete. Try again.")).toBeVisible();
failCancel = false;
await pending
.getByRole("button", { name: "Could not remove. Try again." })
.getByRole("button", { name: "Could not delete. Try again." })
.click();
await expect(pending).toBeHidden();
});
Expand Down Expand Up @@ -698,15 +698,29 @@ test("edits a queued message back into the composer", async ({
source: "web",
text: "Queued follow-up",
};
// Junior reads this one before the edit lands, so the cancel finds nothing.
const readMessage = {
// The reader steers this one. Junior reads it before the edit lands, so the
// cancel finds nothing.
const readMessage: ConversationPendingMessage & { text: string } = {
...queuedMessage,
attachments: undefined,
inboundMessageId: "queued-read",
messageId: "queued-read",
text: "Already read",
};
let queued: ConversationPendingMessage[] = [queuedMessage, readMessage];
const steerRequests: string[][] = [];
await page.route(`${path}/pending-messages/steer`, async (route) => {
const ids = route.request().postDataJSON().inboundMessageIds as string[];
steerRequests.push(ids);
queued = queued.map((message) =>
ids.includes(message.inboundMessageId)
? { ...message, delivery: "interrupt" }
: message,
);
await route.fulfill({
json: { conversationId, steeredInboundMessageIds: ids },
});
});
await page.route(`${path}/pending-messages`, async (route) => {
if (route.request().method() !== "DELETE") {
await route.fulfill({
Expand Down Expand Up @@ -747,6 +761,18 @@ test("edits a queued message back into the composer", async ({
).toBeVisible();
await screenshot(page, "conversation-queue-compact");

const readRow = pending.getByRole("article").filter({
hasText: readMessage.text,
});
await readRow.getByRole("button", { name: "Steer queued message" }).click();
await expect(readRow.getByLabel("Steered", { exact: true })).toBeVisible();
await expect(
readRow.getByRole("button", { name: "Steer queued message" }),
).toBeHidden();
expect(steerRequests).toEqual([[readMessage.inboundMessageId]]);
// Close the delivery tooltip that opened under the pointer.
await page.mouse.move(0, 0);

// A draft with no room for the queued image keeps the message queued.
await composer.evaluate((element, data) => {
const dataTransfer = new DataTransfer();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {
type InputImage,
type MessageAttachment,
} from "@sentry/junior/api/schema";
import { Button } from "../components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../components/ui/tooltip";
import {
conversationAttachmentUrl,
inputImageSrc,
Expand All @@ -24,13 +30,6 @@ export function composerImagesFit(images: readonly InputImage[]): boolean {
);
}

import { Button } from "../components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../components/ui/tooltip";

/** Read a bounded image selection for the message request and local previews. */
export async function readComposerImages(
files: File[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
useAppendConversationMessage,
useArchiveConversation,
useCancelConversationPendingMessages,
useSteerConversationPendingMessages,
useConversationData,
useStopConversationTurn,
type PendingArchiveConversationUpdate,
Expand Down Expand Up @@ -566,7 +567,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
);
const [editTargetInboundMessageId, setEditTargetInboundMessageId] =
useState<string>();
const [editError, setEditError] = useState<string>();
const [queueActionError, setQueueActionError] = useState<string>();
// 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);
Expand Down Expand Up @@ -617,7 +618,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
cancelPendingMessagesRef.current.reset();
}
if (!stopTurnRef.current.isPending) stopTurnRef.current.reset();
setEditError(undefined);
setQueueActionError(undefined);
onPinRequestRef.current();
}, []);
const cancellableMessageIds = props.pendingMessages
Expand Down Expand Up @@ -659,7 +660,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
editingRef.current = true;
const inboundMessageId = message.inboundMessageId;
setEditTargetInboundMessageId(inboundMessageId);
setEditError(undefined);
setQueueActionError(undefined);
try {
// Read images first. A failed read must leave the message queued.
const images = await readStoredComposerImages(
Expand All @@ -670,7 +671,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
if (!composerRef.current) return;
// Check before the cancel. After it, images that do not fit are lost.
if (!composerRef.current.canRestoreImages(images)) {
setEditError("Remove some draft images to edit that message.");
setQueueActionError("Remove some draft images to edit that message.");
return;
}
const result = await editCancelRef.current.mutateAsync({
Expand All @@ -680,7 +681,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
// 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.");
setQueueActionError("Junior already read that message.");
return;
}
const restored = { images, text: message.text ?? "" };
Expand All @@ -692,7 +693,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
restoreStoredDraftText(props.conversationId, restored.text);
}
} catch {
setEditError("Could not edit the queued message. Try again.");
setQueueActionError("Could not edit the queued message. Try again.");
} finally {
editingRef.current = false;
setEditTargetInboundMessageId(undefined);
Expand All @@ -701,6 +702,28 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
[props.conversationId, props.pendingGeneratedAtRef],
);

const steerPendingMessages = useSteerConversationPendingMessages(
props.conversationId,
);
const steerPendingMessagesRef = useRef(steerPendingMessages);
steerPendingMessagesRef.current = steerPendingMessages;
const onSteerMessage = useCallback((message: ConversationMailboxMessage) => {
if (steerPendingMessagesRef.current.isPending) return;
setQueueActionError(undefined);
steerPendingMessagesRef.current.mutate(
{ inboundMessageIds: [message.inboundMessageId] },
{
onError: () =>
setQueueActionError(
"Could not send the queued message now. Try again.",
),
},
);
}, []);
const steerTargetInboundMessageId = steerPendingMessages.isPending
? steerPendingMessages.variables?.inboundMessageIds[0]
: undefined;

const onStop = useCallback(async () => {
const result = await stopTurnRef.current.mutateAsync();
const restored = result.stoppedMessages.map((message) => message.text);
Expand Down Expand Up @@ -737,6 +760,8 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
onEditMessage={onEditMessage}
onLayoutChange={onMailboxLayoutChange}
onRetry={onRetry}
onSteerMessage={props.stoppable ? onSteerMessage : undefined}
steerTargetInboundMessageId={steerTargetInboundMessageId}
viewerEmail={viewerEmail}
/>
</>
Expand All @@ -745,7 +770,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
<ConversationComposer
draftId={props.conversationId}
error={
editError ??
queueActionError ??
(stopTurn.error ? "Could not stop Junior. Try again." : undefined)
}
ref={composerRef}
Expand Down
Loading
Loading