diff --git a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts index a2bab7bcbd..b4f76d65f4 100644 --- a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts +++ b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts @@ -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(); diff --git a/packages/junior-dashboard/e2e/conversations.spec.ts b/packages/junior-dashboard/e2e/conversations.spec.ts index d19f2f68ff..9f04ac40ab 100644 --- a/packages/junior-dashboard/e2e/conversations.spec.ts +++ b/packages/junior-dashboard/e2e/conversations.spec.ts @@ -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 ({ diff --git a/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx b/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx index ce04620afe..d00df69e7b 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx @@ -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(null); @@ -302,7 +304,9 @@ export const ConversationComposer = memo(function ConversationComposer(
@@ -313,24 +317,26 @@ export const ConversationComposer = memo(function ConversationComposer(
)}
- +
+ +
diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 1419709b72..bcf0b2ef50 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -18,6 +18,8 @@ import { useArchiveConversation, useCancelConversationPendingMessages, useConversationData, + usePromoteConversationPendingMessage, + useStopConversationTurn, type PendingArchiveConversationUpdate, } from "./queries"; import type { ConversationMailboxMessage } from "./conversationOutbox"; @@ -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"; @@ -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 } @@ -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; @@ -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); @@ -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; @@ -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 + } /> } > stopTurn.mutate()} + title="Stop active turn" + tone="danger" + > +