From 0766e5f9f97dc4050967472bff96b7f197223a17 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:44:30 +0000 Subject: [PATCH 1/6] feat(dashboard): Add turn steering controls Co-Authored-By: Daniel Griesser --- .../conversations/ConversationComposer.tsx | 76 ++++++++---- .../client/conversations/ConversationPage.tsx | 31 ++++- .../conversations/conversationOutbox.ts | 9 +- .../src/client/conversations/queries.ts | 31 ++++- .../tests/conversation-outbox.test.ts | 13 ++ .../junior/src/api/conversations/create.ts | 1 + .../junior/src/api/conversations/routes.ts | 20 +++ packages/junior/src/api/conversations/stop.ts | 40 ++++++ packages/junior/src/api/schema.ts | 2 + .../junior/src/api/schema/conversation.ts | 12 ++ .../src/chat/conversations/web-input.ts | 6 +- .../api/conversations/turn-controls.test.ts | 116 ++++++++++++++++++ 12 files changed, 327 insertions(+), 30 deletions(-) create mode 100644 packages/junior/src/api/conversations/stop.ts create mode 100644 packages/junior/tests/integration/api/conversations/turn-controls.test.ts diff --git a/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx b/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx index ce04620afe..c6d8c4b1e5 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationComposer.tsx @@ -8,7 +8,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; -import { Send } from "lucide-react"; +import { Send, Zap } from "lucide-react"; import { Button } from "../components/Button"; import { useDashboardOnline } from "../connection"; @@ -63,8 +63,14 @@ type ConversationComposerProps = { */ restoreDraftOnError?: boolean; submitLabel: string; + /** Show an explicit action that interrupts the active Turn. */ + showSteer?: boolean; onFocus?: () => void; - onSubmit(message: string, idempotencyKey: string): Promise; + onSubmit( + message: string, + idempotencyKey: string, + delivery: "defer" | "interrupt", + ): Promise; onSubmitStart?: () => void; }; @@ -79,7 +85,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); @@ -182,7 +190,10 @@ export const ConversationComposer = memo(function ConversationComposer( syncTextareaHeight(); }; - const submit = async (event?: FormEvent) => { + const submit = async ( + event?: FormEvent, + delivery: "defer" | "interrupt" = "defer", + ) => { event?.preventDefault(); const text = draftRef.current.text.trim(); if (!text || !online || submittingRef.current || sendLocked) return; @@ -220,7 +231,7 @@ export const ConversationComposer = memo(function ConversationComposer( } try { - await props.onSubmit(text, attempt.idempotencyKey); + await props.onSubmit(text, attempt.idempotencyKey, delivery); } catch { if (!props.restoreDraftOnError) { // Parent keeps the failed message in the mailbox queue for retry. @@ -302,7 +313,9 @@ export const ConversationComposer = memo(function ConversationComposer(
@@ -313,24 +326,39 @@ export const ConversationComposer = memo(function ConversationComposer(
)}
- +
+ {props.showSteer ? ( + + ) : null} + +
diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index 1419709b72..b7232b00d1 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -18,6 +18,7 @@ import { useArchiveConversation, useCancelConversationPendingMessages, useConversationData, + useStopConversationTurn, type PendingArchiveConversationUpdate, } from "./queries"; import type { ConversationMailboxMessage } from "./conversationOutbox"; @@ -44,6 +45,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 +286,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 +307,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 +319,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { const cancelPendingMessages = useCancelConversationPendingMessages( 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); @@ -341,8 +346,13 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { onPinRequestRef.current(); }, [pendingMessageVersion]); const onSubmit = useCallback( - async (message: string, idempotencyKey: string) => { + async ( + message: string, + idempotencyKey: string, + delivery: "defer" | "interrupt", + ) => { await appendMessageRef.current.mutateAsync({ + delivery, idempotencyKey, message, }); @@ -352,6 +362,7 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { const onRetry = useCallback((message: ConversationMailboxMessage) => { if (!message.idempotencyKey || !message.text) return; void appendMessageRef.current.mutateAsync({ + delivery: message.delivery, idempotencyKey: message.idempotencyKey, message: message.text, }); @@ -419,8 +430,24 @@ const ConversationReplyFooter = memo(function ConversationReplyFooter(props: { > stopTurn.mutate()} + title="Stop active turn" + tone="danger" + > +