mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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
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:
parent
525233f94a
commit
094ebe164a
4 changed files with 116 additions and 8 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ mock.module("../lib/queries", () => ({
|
|||
mock.module("../lib/mutations", () => ({
|
||||
useArchiveRun: () => noopMutation(),
|
||||
useCancelRun: () => noopMutation(),
|
||||
useInterruptRun: () => noopMutation(),
|
||||
usePreviewRun: () => noopMutation(),
|
||||
useSteerRun: () => noopMutation(),
|
||||
useSubmitInterviewAnswer: () => noopMutation(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue