Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/junior-dashboard/e2e/conversations-mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,9 @@ test("opens and closes a conversation on mobile", async ({

const composer = page.getByPlaceholder("Message Junior…");
await expect(composer).toBeVisible();
await expect(page.getByRole("button", { name: "Send" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Send", exact: true }),
).toBeVisible();
await composer.focus();
await expect(composer).toBeFocused();

Expand Down
8 changes: 6 additions & 2 deletions packages/junior-dashboard/e2e/conversations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,18 @@ test("keeps cached conversation and draft available through reconnect", async ({
await expect(
page.getByText("Connect to send. Your draft is saved."),
).toBeVisible();
await expect(page.getByRole("button", { name: "Send" })).toBeDisabled();
await expect(
page.getByRole("button", { name: "Send", exact: true }),
).toBeDisabled();

await context.setOffline(false);
await expect(
page.getByText("You’re offline. Drafts stay on this device."),
).toBeHidden();
await expect(composer).toHaveValue("Keep this draft through reconnect");
await expect(page.getByRole("button", { name: "Send" })).toBeEnabled();
await expect(
page.getByRole("button", { name: "Send", exact: true }),
).toBeEnabled();
});

test("shows the repo name for one annotation scope on mobile", async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export const ConversationComposer = memo(function ConversationComposer(
// New-conversation create holds the send control until accept settles so a
// failed restore cannot race a later submit.
const [createPending, setCreatePending] = useState(false);
const [canSend, setCanSend] = useState(() => Boolean(initialDraft.text.trim()));
const [canSend, setCanSend] = useState(() =>
Boolean(initialDraft.text.trim()),
);
const online = useDashboardOnline();
const id = useId();
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -302,7 +304,9 @@ export const ConversationComposer = memo(function ConversationComposer(
<div
className={cn(
"flex min-w-0 items-center gap-3 px-2 py-1.5 md:px-3 md:py-2",
props.footerStart ? "justify-between" : "justify-end md:justify-between",
props.footerStart
? "justify-between"
: "justify-end md:justify-between",
)}
>
<div className="flex min-w-0 items-center gap-3">
Expand All @@ -313,24 +317,26 @@ export const ConversationComposer = memo(function ConversationComposer(
</div>
)}
</div>
<Button
aria-label={sendLocked ? "Sending message" : props.submitLabel}
className="!border-0 !bg-transparent hover:!border-0 hover:!bg-white/[0.06] focus-visible:outline focus-visible:outline-1 focus-visible:outline-cyan-300/55 disabled:hover:!border-0 disabled:hover:!bg-transparent"
disabled={!canSend || !online || sendLocked}
title={
!online
? "Connect to send"
: sendLocked
? "Sending message"
: props.submitLabel
}
type="submit"
>
<Send aria-hidden="true" size={14} />
<span className="hidden md:inline">
{sendLocked ? "Sending…" : props.submitLabel}
</span>
</Button>
<div className="flex items-center gap-1">
<Button
aria-label={sendLocked ? "Sending message" : props.submitLabel}
className="!border-0 !bg-transparent hover:!border-0 hover:!bg-white/[0.06] focus-visible:outline focus-visible:outline-1 focus-visible:outline-cyan-300/55 disabled:hover:!border-0 disabled:hover:!bg-transparent"
disabled={!canSend || !online || sendLocked}
title={
!online
? "Connect to send"
: sendLocked
? "Sending message"
: props.submitLabel
}
type="submit"
>
<Send aria-hidden="true" size={14} />
<span className="hidden md:inline">
{sendLocked ? "Sending…" : props.submitLabel}
</span>
</Button>
</div>
</div>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
useArchiveConversation,
useCancelConversationPendingMessages,
useConversationData,
usePromoteConversationPendingMessage,
useStopConversationTurn,
type PendingArchiveConversationUpdate,
} from "./queries";
import type { ConversationMailboxMessage } from "./conversationOutbox";
Expand All @@ -44,6 +46,7 @@ import {
conversationFromDetail,
visualStatusForConversation,
} from "../format";
import { Button } from "../components/Button";
import { Card } from "../components/layout/Card";
import { ChatLayout } from "./ChatLayout";
import { ComposerDock } from "./ComposerDock";
Expand Down Expand Up @@ -284,6 +287,7 @@ export function ConversationPage(props: {
// every 2s; a prop would bust footer memo while the reader types.
pendingGeneratedAtRef={pendingGeneratedAtRef}
pendingMessages={detail.pendingMessages}
active={live}
/>
) : undefined
}
Expand All @@ -304,6 +308,7 @@ export function ConversationPage(props: {
* footer tree. Fast chat UIs isolate the composer the same way.
*/
const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
active: boolean;
committedMessageIds: readonly string[];
conversationId: string;
onPinRequest: () => void;
Expand All @@ -315,6 +320,10 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
const cancelPendingMessages = useCancelConversationPendingMessages(
props.conversationId,
);
const promotePendingMessage = usePromoteConversationPendingMessage(
props.conversationId,
);
const stopTurn = useStopConversationTurn(props.conversationId);
// 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 @@ -366,6 +375,12 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
const cancellableMessageIds = props.pendingMessages
.filter((message) => message.clientStatus === undefined)
.map((message) => message.inboundMessageId);
const onPromoteMessage = useCallback(
(message: ConversationMailboxMessage) => {
promotePendingMessage.mutate(message.inboundMessageId);
},
[promotePendingMessage],
);
const onCancelMessage = useCallback(
(message: ConversationMailboxMessage) => {
const receivedBefore = props.pendingGeneratedAtRef.current;
Expand Down Expand Up @@ -412,13 +427,39 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: {
messages={props.pendingMessages}
onCancelMessage={onCancelMessage}
onLayoutChange={onMailboxLayoutChange}
onPromoteMessage={props.active ? onPromoteMessage : undefined}
onRetry={onRetry}
promoteErrorMessageId={
promotePendingMessage.error
? promotePendingMessage.variables
: undefined
}
promotePendingMessageId={
promotePendingMessage.isPending
? promotePendingMessage.variables
: undefined
}
/>
</>
}
>
<ConversationComposer
draftId={props.conversationId}
error={stopTurn.error ? "Could not stop the active turn." : undefined}
footerStart={
props.active ? (
<Button
aria-label="Stop active turn"
disabled={stopTurn.isPending}
onClick={() => stopTurn.mutate()}
title="Stop active turn"
tone="danger"
>
<span aria-hidden="true" className="size-2.5 bg-current" />
{stopTurn.isPending ? "Stopping…" : "Stop"}
</Button>
) : undefined
}
label="Continue this conversation"
submitLabel="Send"
onFocus={onComposerFocus}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
LoaderCircle,
SkipForward,
X,
Zap,
type LucideIcon,
} from "lucide-react";

Expand Down Expand Up @@ -61,6 +62,8 @@ function PendingMetaIcons(props: {
cancelPending: boolean;
message: ConversationMailboxMessage;
onCancel?: () => void;
onPromote?: () => void;
promotePending: boolean;
showSlack: boolean;
}) {
const delivery = pendingDeliveryMeta(props.message);
Expand All @@ -83,6 +86,28 @@ function PendingMetaIcons(props: {
strokeWidth={2.2}
/>
</PendingMetaIcon>
{props.onPromote ? (
<Tooltip content="Steer into active turn" placement="above">
<button
aria-label="Steer queued message"
className="inline-flex cursor-pointer items-center gap-1 rounded border-0 bg-transparent px-1 py-0.5 font-sans text-xs font-medium text-amber-100/80 transition-colors hover:bg-amber-300/[0.08] 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.promotePending}
onClick={props.onPromote}
type="button"
>
{props.promotePending ? (
<LoaderCircle
aria-hidden="true"
className="animate-spin"
size={12}
/>
) : (
<Zap aria-hidden="true" size={12} />
)}
Steer
</button>
</Tooltip>
) : null}
{props.onCancel ? (
<Tooltip content="Remove queued message" placement="above">
<button
Expand Down Expand Up @@ -118,7 +143,10 @@ function PendingRow(props: {
cancelPending: boolean;
message: ConversationMailboxMessage;
onCancel?(message: ConversationMailboxMessage): void;
onPromote?(message: ConversationMailboxMessage): void;
onRetry?(message: ConversationMailboxMessage): void;
promoteError: boolean;
promotePending: boolean;
}) {
const text = props.message.text ?? "";
const redacted = Boolean(props.message.redacted);
Expand Down Expand Up @@ -149,6 +177,12 @@ function PendingRow(props: {
onCancel={
props.onCancel ? () => props.onCancel?.(props.message) : undefined
}
onPromote={
props.onPromote
? () => props.onPromote?.(props.message)
: undefined
}
promotePending={props.promotePending}
showSlack={showSlack}
/>
}
Expand Down Expand Up @@ -176,9 +210,11 @@ function PendingRow(props: {
</button>
</div>
) : null}
{props.cancelError ? (
{props.cancelError || props.promoteError ? (
<p className="m-0 font-sans text-xs text-amber-100/75">
Could not remove. Try again.
{props.promoteError
? "Could not steer. Try again."
: "Could not remove. Try again."}
</p>
) : null}
</article>
Expand Down Expand Up @@ -231,6 +267,9 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
committedMessageIds?: readonly string[];
messages: readonly ConversationMailboxMessage[];
onCancelMessage?: (message: ConversationMailboxMessage) => void;
onPromoteMessage?: (message: ConversationMailboxMessage) => void;
promoteErrorMessageId?: string;
promotePendingMessageId?: string;
/** Fires after expand/collapse changes the stack height above the composer. */
onLayoutChange?: () => void;
onRetry?(message: ConversationMailboxMessage): void;
Expand Down Expand Up @@ -290,7 +329,19 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
? props.onCancelMessage
: undefined
}
onPromote={
message.clientStatus === undefined &&
message.delivery === "defer"
? props.onPromoteMessage
: undefined
}
onRetry={props.onRetry}
promoteError={
props.promoteErrorMessageId === message.inboundMessageId
}
promotePending={
props.promotePendingMessageId === message.inboundMessageId
}
/>
))}
</div>
Expand All @@ -313,7 +364,18 @@ export const PendingMailboxStack = memo(function PendingMailboxStack(props: {
? props.onCancelMessage
: undefined
}
onPromote={
message.clientStatus === undefined && message.delivery === "defer"
? props.onPromoteMessage
: undefined
}
onRetry={props.onRetry}
promoteError={
props.promoteErrorMessageId === message.inboundMessageId
}
promotePending={
props.promotePendingMessageId === message.inboundMessageId
}
/>
))
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ConversationPendingMessage } from "@sentry/junior/api/schema";
/** Client-owned mailbox row waiting on accept or retry. */
export type ConversationOutboxMessage = {
createdAt: string;
delivery: "defer" | "interrupt";
idempotencyKey: string;
message: string;
messageId: string;
Expand All @@ -22,13 +23,15 @@ export function conversationOutboxQueryKey(conversationId: string | undefined) {

/** Build one optimistic outbox row for a composer submit. */
export function conversationOutboxMessageForSubmit(input: {
delivery?: "defer" | "interrupt";
idempotencyKey: string;
message: string;
now?: string;
}): ConversationOutboxMessage {
const createdAt = input.now ?? new Date().toISOString();
return {
createdAt,
delivery: input.delivery ?? "defer",
idempotencyKey: input.idempotencyKey,
message: input.message,
messageId: `client:${input.idempotencyKey}`,
Expand All @@ -43,7 +46,7 @@ export function mailboxMessageFromOutbox(
return {
clientStatus: message.status,
createdAt: message.createdAt,
delivery: "defer",
delivery: message.delivery,
idempotencyKey: message.idempotencyKey,
inboundMessageId: message.messageId,
messageId: message.messageId,
Expand Down Expand Up @@ -72,7 +75,9 @@ export function mergeConversationMailboxMessages(
const outboxMessages = outbox ?? [];
let next: readonly ConversationMailboxMessage[] = serverMessages;
if (outboxMessages.length > 0) {
const serverIds = new Set(serverMessages.map((message) => message.messageId));
const serverIds = new Set(
serverMessages.map((message) => message.messageId),
);
const extras = outboxMessages
.filter((message) => !serverIds.has(message.messageId))
.map(mailboxMessageFromOutbox);
Expand Down
Loading
Loading