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
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 @@ -428,11 +428,11 @@ test("opens and closes a conversation in the mobile workspace", async ({

const pending = page.getByLabel("Pending messages");
await expect(pending).toBeVisible();
// Cancel bar owns the total count on mobile; expand uses a distinct label.
await expect(pending.getByText("5 queued messages")).toHaveCount(1);
const expand = pending.getByRole("button", { name: "Show queued messages" });
// Mobile collapsed control owns the total count (no separate cancel bar).
const expand = pending.getByRole("button", { name: "5 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("Also check the canary traffic from the last deploy."),
).toBeHidden();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,28 +288,27 @@ function ConversationReplyFooter(props: {
onPinRequestRef.current();
}, []);
const onSubmitStart = useCallback(() => {
cancelPendingMessagesRef.current.reset();
// Keep an in-flight remove intact so optimistic cache rollback stays coherent.
if (!cancelPendingMessagesRef.current.isPending) {
cancelPendingMessagesRef.current.reset();
}
onPinRequestRef.current();
}, []);
const cancellableMessageIds = props.pendingMessages
.filter((message) => message.clientStatus === undefined)
.map((message) => message.inboundMessageId);
const cancellableMessageIdsRef = useRef(cancellableMessageIds);
Comment thread
cursor[bot] marked this conversation as resolved.
cancellableMessageIdsRef.current = cancellableMessageIds;
const hasSendingOutboxMessage = props.pendingMessages.some(
(message) => message.clientStatus === "sending",
);
const pendingGeneratedAtRef = useRef(props.pendingGeneratedAt);
pendingGeneratedAtRef.current = props.pendingGeneratedAt;
const onCancelQueue = useCallback(() => {
const inboundMessageIds = cancellableMessageIdsRef.current;
const onCancelMessage = useCallback((message: ConversationMailboxMessage) => {
const receivedBefore = pendingGeneratedAtRef.current;
if (!receivedBefore || inboundMessageIds.length === 0) return;
if (!receivedBefore) return;
cancelPendingMessagesRef.current.mutate({
inboundMessageIds,
inboundMessageIds: [message.inboundMessageId],
receivedBefore,
});
}, []);
const cancelTargetInboundMessageId =
cancelPendingMessages.variables?.inboundMessageIds[0];
const cancelError = Boolean(
cancelPendingMessages.error &&
cancelPendingMessages.variables?.inboundMessageIds.some((id) =>
Expand All @@ -336,15 +335,15 @@ function ConversationReplyFooter(props: {
<PendingMailboxStack
cancelError={cancelError}
cancelPending={cancelPendingMessages.isPending}
cancelTargetInboundMessageId={cancelTargetInboundMessageId}
conversation={props.conversation}
messages={props.pendingMessages}
onCancelQueue={hasSendingOutboxMessage ? undefined : onCancelQueue}
onCancelMessage={onCancelMessage}
onRetry={onRetry}
/>
</div>
<div className="min-w-0 shrink-0">
<ConversationComposer
disabled={cancelPendingMessages.isPending}
draftId={props.conversationId}
label="Continue this conversation"
submitLabel="Send"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import {
Clock3,
LoaderCircle,
SkipForward,
X,
type LucideIcon,
} from "lucide-react";

import { Button } from "../components/Button";
import { Tooltip } from "../components/Tooltip";
import { transcriptMessageActorLabel } from "../format";
import type { ConversationTranscript, TranscriptViewMessage } from "../types";
Expand Down Expand Up @@ -55,7 +55,11 @@ function PendingMetaIcon(props: { children: ReactElement; label: string }) {
}

function PendingMetaIcons(props: {
cancelDisabled: boolean;
cancelError: boolean;
cancelPending: boolean;
message: ConversationMailboxMessage;
onCancel?: () => void;
showSlack: boolean;
}) {
const delivery = pendingDeliveryMeta(props.message);
Expand All @@ -78,13 +82,42 @@ function PendingMetaIcons(props: {
strokeWidth={2.2}
/>
</PendingMetaIcon>
{props.onCancel ? (
<Tooltip content="Remove queued message" placement="above">
<button
aria-label={
props.cancelError
? "Could not remove. Try again."
: "Remove queued message"
}
className="inline-flex size-5 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-dashboard-text-muted transition-colors hover:bg-white/[0.06] hover:text-amber-50 focus-visible:outline focus-visible:outline-1 focus-visible:outline-amber-200/55 disabled:cursor-default disabled:opacity-50"
disabled={props.cancelDisabled}
onClick={props.onCancel}
type="button"
>
{props.cancelPending ? (
<LoaderCircle
aria-hidden="true"
className="animate-spin"
size={13}
/>
) : (
<X aria-hidden="true" size={14} strokeWidth={2.2} />
)}
</button>
</Tooltip>
) : null}
Comment thread
cursor[bot] marked this conversation as resolved.
</TranscriptHeadingMeta>
);
}

function PendingRow(props: {
cancelDisabled: boolean;
cancelError: boolean;
cancelPending: boolean;
conversation: ConversationTranscript;
message: ConversationMailboxMessage;
onCancel?(message: ConversationMailboxMessage): void;
onRetry?(message: ConversationMailboxMessage): void;
}) {
const text = props.message.text ?? "";
Expand Down Expand Up @@ -119,7 +152,16 @@ function PendingRow(props: {
}
leftClassName="text-sm leading-snug text-dashboard-text"
right={
<PendingMetaIcons message={props.message} showSlack={showSlack} />
<PendingMetaIcons
cancelDisabled={props.cancelDisabled}
cancelError={props.cancelError}
cancelPending={props.cancelPending}
message={props.message}
onCancel={
props.onCancel ? () => props.onCancel?.(props.message) : undefined
}
showSlack={showSlack}
/>
}
/>
{redacted ? (
Expand All @@ -145,13 +187,16 @@ function PendingRow(props: {
</button>
</div>
) : null}
{props.cancelError ? (
<p className="m-0 font-sans text-xs text-amber-100/75">
Could not remove. Try again.
</p>
) : null}
</article>
);
}

function ExpandQueuedMessagesButton(props: {
/** When the cancel bar already shows the total count, avoid repeating it on mobile. */
countShownInCancelBar: boolean;
expanded: boolean;
hiddenCount: number;
onClick(): void;
Expand All @@ -166,10 +211,7 @@ function ExpandQueuedMessagesButton(props: {
? `${props.hiddenCount} more queued messages`
: totalLabel;
// Mobile collapses previews and uses the total count as the expand control.
// When cancel already owns that count, keep a distinct expand action label.
const mobileCollapsedLabel = props.countShownInCancelBar
? "Show queued messages"
: totalLabel;
const mobileCollapsedLabel = totalLabel;
const label = props.expanded ? "Show fewer queued messages" : moreLabel;

return (
Expand All @@ -195,9 +237,10 @@ function ExpandQueuedMessagesButton(props: {
export function PendingMailboxStack(props: {
cancelError?: boolean;
cancelPending?: boolean;
cancelTargetInboundMessageId?: string;
conversation: ConversationTranscript;
messages: readonly ConversationMailboxMessage[];
onCancelQueue?: () => void;
onCancelMessage?: (message: ConversationMailboxMessage) => void;
onRetry?(message: ConversationMailboxMessage): void;
}): ReactNode {
const [expanded, setExpanded] = useState(false);
Expand All @@ -219,67 +262,64 @@ export 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 cancellableCount = rows.filter(
(message) => message.clientStatus === undefined,
).length;
const hasSendingRow = rows.some(
(message) => message.clientStatus === "sending",
);
const showCancel =
cancellableCount > 0 && !hasSendingRow && Boolean(props.onCancelQueue);
const countLabel =
rows.length === 1 ? "1 queued message" : `${rows.length} queued messages`;

return (
<div
aria-label="Pending messages"
className="mx-2 overflow-hidden rounded-t-lg bg-amber-300/[0.055] md:mx-3"
>
{showCancel ? (
<div className="flex items-center justify-between gap-2 px-3 py-2 md:px-3.5">
<div className="min-w-0 font-sans text-xs font-medium text-amber-100/80">
{countLabel}
</div>
<Button
aria-label="Cancel queued messages"
className="h-7 shrink-0 border-white/10 bg-transparent px-2 text-xs font-medium text-amber-100/85 hover:border-white/25 hover:bg-white/[0.06] hover:text-amber-50"
disabled={props.cancelPending}
onClick={props.onCancelQueue}
>
{props.cancelPending ? "Cancelling…" : "Cancel queue"}
</Button>
</div>
) : null}
{showCancel && props.cancelError ? (
<div className="border-t border-amber-300/15 px-3 py-1.5 font-sans text-xs text-amber-100/75 md:px-3.5">
Could not cancel queued messages. Try again.
</div>
) : null}
{showCollapsed ? (
// Desktop keeps a two-row preview; mobile collapses to the control only.
<div className="hidden md:block">
{previewRows.map((message, index) => (
<PendingRow
cancelDisabled={Boolean(props.cancelPending)}
cancelError={Boolean(
props.cancelError &&
props.cancelTargetInboundMessageId === message.inboundMessageId,
)}
cancelPending={Boolean(
props.cancelPending &&
props.cancelTargetInboundMessageId === message.inboundMessageId,
)}
conversation={props.conversation}
key={message.messageId ?? `${message.inboundMessageId}:${index}`}
message={message}
onCancel={
message.clientStatus === undefined
? props.onCancelMessage
: undefined
}
onRetry={props.onRetry}
/>
))}
</div>
) : (
visibleRows.map((message, index) => (
<PendingRow
cancelDisabled={Boolean(props.cancelPending)}
cancelError={Boolean(
props.cancelError &&
props.cancelTargetInboundMessageId === message.inboundMessageId,
)}
cancelPending={Boolean(
props.cancelPending &&
props.cancelTargetInboundMessageId === message.inboundMessageId,
)}
conversation={props.conversation}
key={message.messageId ?? `${message.inboundMessageId}:${index}`}
message={message}
onCancel={
message.clientStatus === undefined
? props.onCancelMessage
: undefined
}
onRetry={props.onRetry}
/>
))
)}
{canCollapse ? (
<ExpandQueuedMessagesButton
countShownInCancelBar={showCancel}
expanded={expanded}
hiddenCount={hiddenCount}
onClick={toggleExpanded}
Expand Down
46 changes: 31 additions & 15 deletions packages/junior-dashboard/tests/pending-mailbox-stack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ function message(
};
}

describe("PendingMailboxStack cancel control", () => {
it("shows cancel only when an accepted mailbox row exists", () => {
describe("PendingMailboxStack remove control", () => {
it("shows remove only for an accepted mailbox row", () => {
const accepted = renderToStaticMarkup(
<PendingMailboxStack
conversation={conversation()}
messages={[message()]}
onCancelQueue={() => undefined}
onCancelMessage={() => undefined}
/>,
);
const localOnly = renderToStaticMarkup(
Expand All @@ -59,15 +59,15 @@ describe("PendingMailboxStack cancel control", () => {
messageId: "client:1",
}),
]}
onCancelQueue={() => undefined}
onCancelMessage={() => undefined}
/>,
);

expect(accepted).toContain("Cancel queue");
expect(localOnly).not.toContain("Cancel queue");
expect(accepted).toContain("Remove queued message");
expect(localOnly).not.toContain("Remove queued message");
});

it("hides cancel while a local send can still become accepted", () => {
it("keeps remove available for accepted rows while a local send is pending", () => {
const html = renderToStaticMarkup(
<PendingMailboxStack
conversation={conversation()}
Expand All @@ -79,14 +79,14 @@ describe("PendingMailboxStack cancel control", () => {
messageId: "client:2",
}),
]}
onCancelQueue={() => undefined}
onCancelMessage={() => undefined}
/>,
);

expect(html).not.toContain("Cancel queue");
expect(html.match(/aria-label="Remove queued message"/g)).toHaveLength(1);
});

it("hides a stale cancel error when only local outbox rows remain", () => {
it("does not show remove for failed local outbox rows", () => {
const html = renderToStaticMarkup(
<PendingMailboxStack
cancelError
Expand All @@ -99,14 +99,30 @@ describe("PendingMailboxStack cancel control", () => {
messageId: "client:retry-1",
}),
]}
onCancelQueue={() => undefined}
onCancelMessage={() => undefined}
/>,
);

expect(html).not.toContain("Could not cancel queued messages");
expect(html).not.toContain("Remove queued message");
expect(html).not.toContain("Could not remove. Try again.");
});

it("does not repeat the queue count on the mobile expand control when cancel shows it", () => {
it("shows an inline remove error on the target accepted row", () => {
const html = renderToStaticMarkup(
<PendingMailboxStack
cancelError
cancelTargetInboundMessageId="accepted-1"
conversation={conversation()}
messages={[message()]}
onCancelMessage={() => undefined}
/>,
);

expect(html).toContain("Could not remove. Try again.");
expect(html).toContain('aria-label="Could not remove. Try again."');
});

it("uses the queue count on the mobile expand control", () => {
const messages = Array.from({ length: 5 }, (_, index) =>
message({
inboundMessageId: `accepted-${index + 1}`,
Expand All @@ -118,12 +134,12 @@ describe("PendingMailboxStack cancel control", () => {
<PendingMailboxStack
conversation={conversation()}
messages={messages}
onCancelQueue={() => undefined}
onCancelMessage={() => undefined}
/>,
);

expect(html).toContain("5 queued messages");
expect(html).toContain("Show queued messages");
expect(html).not.toContain("Show queued messages");
expect(html.match(/5 queued messages/g)).toHaveLength(1);
});
});
Loading