feat(web): add Send interrupt and Send steering actions to the run menu
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run

Adds two new items to the run-detail Actions dropdown, always
present and disabled when unavailable. Send interrupt is wired to
POST /api/v1/runs/{id}/interrupt and gated on running status. Send
steering… focuses the bottom-dock steer textarea via a new
forwardRef handle on SteerBar; gated on running status with no
pending questions. Bumps the separator after the new pair to render
whenever any later group exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-07 14:26:10 -07:00
parent 525233f94a
commit 094ebe164a
No known key found for this signature in database
4 changed files with 116 additions and 8 deletions

View file

@ -1,13 +1,33 @@
import { useState, type FormEvent, type KeyboardEvent } from "react";
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
} from "react";
import { CheckIcon } from "@heroicons/react/16/solid";
export interface SteerBarProps {
runId: string;
}
export function SteerBar({ runId: _runId }: SteerBarProps) {
export interface SteerBarHandle {
focus(): void;
}
export const SteerBar = forwardRef<SteerBarHandle, SteerBarProps>(function SteerBar(
{ runId: _runId },
ref,
) {
const [text, setText] = useState("");
const [interrupt, setInterrupt] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
useImperativeHandle(ref, () => ({
focus() {
textareaRef.current?.focus();
},
}));
const trimmed = text.trim();
const canSubmit = trimmed.length > 0;
@ -33,6 +53,7 @@ export function SteerBar({ runId: _runId }: SteerBarProps) {
className="mx-auto flex max-w-4xl items-end gap-2 px-4 py-3 sm:px-6 lg:px-8"
>
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
@ -76,4 +97,4 @@ export function SteerBar({ runId: _runId }: SteerBarProps) {
</button>
</form>
);
}
});

View file

@ -119,6 +119,24 @@ export function useSubmitInterviewAnswer(runId: string | undefined) {
export type SteerRunArg = SteerRunRequest;
export function useInterruptRun(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `interrupt-run:${runId}` : null,
async (_key: string) => {
if (!runId) throw new Error("runId is required");
const path = `/api/v1/runs/${encodeURIComponent(runId)}/interrupt`;
await apiJsonMutation<void, undefined>(path, { arg: undefined });
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useSteerRun(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(

View file

@ -44,6 +44,7 @@ mock.module("../lib/queries", () => ({
mock.module("../lib/mutations", () => ({
useArchiveRun: () => noopMutation(),
useCancelRun: () => noopMutation(),
useInterruptRun: () => noopMutation(),
usePreviewRun: () => noopMutation(),
useSteerRun: () => noopMutation(),
useSubmitInterviewAnswer: () => noopMutation(),

View file

@ -17,7 +17,7 @@ import { Link, Outlet, useLocation, useMatches } from "react-router";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { InterviewDock } from "../components/interview-dock";
import { SteerBar } from "../components/steer-bar";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
import { SteerComposer } from "../components/steer-composer";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
@ -32,6 +32,7 @@ import { useDemoMode } from "../lib/demo-mode";
import {
useArchiveRun,
useCancelRun,
useInterruptRun,
usePreviewRun,
useUnarchiveRun,
type LifecycleMutationResult,
@ -147,9 +148,11 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const cancelMutation = useCancelRun(params.id);
const archiveMutation = useArchiveRun(params.id);
const unarchiveMutation = useUnarchiveRun(params.id);
const interruptMutation = useInterruptRun(params.id);
const { push, dismiss } = useToast();
const tabs = allTabs.filter((t) => !t.demoOnly || demoMode);
const lifecycleToastStateRef = useRef<LifecycleToastState>(INITIAL_LIFECYCLE_TOAST_STATE);
const steerBarRef = useRef<SteerBarHandle | null>(null);
const [steerOpen, setSteerOpen] = useState(false);
const now = useTickingNow(30_000);
const fullHeight = matches.some(
@ -281,9 +284,16 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</div>
</div>
{demoMode && <ConnectMenu />}
<ActionsMenu
canSteer={statusKind === "running"}
onSteer={() => setSteerOpen(true)}
canSendInterrupt={statusKind === "running"}
interruptPending={interruptMutation.isMutating}
onSendInterrupt={() => void interruptMutation.trigger()}
canFocusSteer={statusKind === "running" && !hasPendingQuestions}
onFocusSteer={() => steerBarRef.current?.focus()}
canPreview={!!run.sandboxId}
previewPending={previewPending}
onPreview={() => void previewMutation.trigger({
@ -352,7 +362,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
{hasPendingQuestions ? (
<InterviewDock runId={params.id} questions={pendingQuestions} />
) : (
<SteerBar runId={params.id} />
<SteerBar ref={steerBarRef} runId={params.id} />
)}
</div>
</div>
@ -406,9 +416,41 @@ export function handleLifecycleToastResult(
return { ...nextState, activeArchiveToastId: null };
}
function ConnectMenu() {
return (
<Menu as="div" className="shrink-0">
<MenuButton className={ACTIONS_TRIGGER_CLASS}>
Connect
<ChevronDownIcon className="-mr-1 size-4 text-fg-muted" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
anchor={{ to: "bottom end", gap: 4 }}
className="z-20 w-44 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<button type="button" className={MENU_ITEM_CLASS}>
Preview
</button>
</MenuItem>
<MenuItem>
<button type="button" className={MENU_ITEM_CLASS}>
SSH
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}
interface ActionsMenuProps {
canSteer: boolean;
onSteer: () => void;
canSendInterrupt: boolean;
interruptPending: boolean;
onSendInterrupt: () => void;
canFocusSteer: boolean;
onFocusSteer: () => void;
canPreview: boolean;
previewPending: boolean;
onPreview: () => void;
@ -426,17 +468,21 @@ interface ActionsMenuProps {
function ActionsMenu(props: ActionsMenuProps) {
const {
canSteer, onSteer,
canSendInterrupt, interruptPending, onSendInterrupt,
canFocusSteer, onFocusSteer,
canPreview, previewPending, onPreview,
canArchive, archivePending, onArchive,
canUnarchive, unarchivePending, onUnarchive,
canCancel, cancelPending, onCancel,
} = props;
const hasOps = canPreview || canSteer;
const hasOps =
canPreview || canSteer || canSendInterrupt || canFocusSteer;
const hasLifecycle = canArchive || canUnarchive;
const hasDestructive = canCancel;
const hasAny = hasOps || hasLifecycle || hasDestructive;
const anyPending = previewPending || archivePending || unarchivePending || cancelPending;
const anyPending =
previewPending || archivePending || unarchivePending || cancelPending || interruptPending;
if (!hasAny) return null;
@ -471,7 +517,29 @@ function ActionsMenu(props: ActionsMenuProps) {
</button>
</MenuItem>
)}
{hasOps && hasLifecycle && <div className="my-1 h-px bg-line" role="separator" />}
<MenuItem>
<button
type="button"
onClick={onSendInterrupt}
disabled={!canSendInterrupt || interruptPending}
className={MENU_ITEM_CLASS}
>
{interruptPending ? "Interrupting…" : "Send interrupt"}
</button>
</MenuItem>
<MenuItem>
<button
type="button"
onClick={onFocusSteer}
disabled={!canFocusSteer}
className={MENU_ITEM_CLASS}
>
Send steering…
</button>
</MenuItem>
{(hasLifecycle || hasDestructive) && (
<div className="my-1 h-px bg-line" role="separator" />
)}
{canArchive && (
<MenuItem>
<button