feat(web): add a sticky steer bar at the bottom of run views

Adds a single-row text input + Send button that pins to the viewport
bottom on every run-detail tab as a placeholder for the future
steering composer (will replace the modal). Renders mutually
exclusive with InterviewDock: the dock takes precedence on blocked
runs with pending questions, otherwise the steer bar shows. Reuses
the existing --fabro-interview-dock-clearance variable so consumers
that already pad for the dock pick up the steer bar's clearance too.
Send is intentionally a no-op for now until the steering API is
swapped over from the modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-07 14:07:58 -07:00
parent 1b9920e9e5
commit 427a24d145
No known key found for this signature in database
2 changed files with 64 additions and 2 deletions

View file

@ -0,0 +1,58 @@
import { useState, type FormEvent, type KeyboardEvent } from "react";
export interface SteerBarProps {
runId: string;
}
export function SteerBar({ runId: _runId }: SteerBarProps) {
const [text, setText] = useState("");
const trimmed = text.trim();
const canSubmit = trimmed.length > 0;
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setText("");
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSubmit) {
setText("");
}
}
}
return (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30">
<div className="bg-linear-to-t from-page via-page/80 to-transparent pt-10">
<div className="pointer-events-auto mx-auto max-w-5xl px-4 pb-4 sm:px-6 lg:px-8">
<form
onSubmit={handleSubmit}
aria-label="Steer running agent"
className="flex items-end gap-2 rounded-2xl bg-panel p-2 shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.5)] ring-1 ring-line-strong"
>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Steer the agent…"
rows={1}
maxLength={8192}
aria-label="Steering message"
className="flex-1 resize-none bg-transparent px-3 py-2 text-sm text-fg outline-hidden placeholder:text-fg-muted"
/>
<button
type="submit"
disabled={!canSubmit}
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"
>
Send
</button>
</form>
</div>
</div>
</div>
);
}

View file

@ -17,6 +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 { SteerComposer } from "../components/steer-composer";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
@ -212,8 +213,9 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const archivePending = archiveMutation.isMutating;
const unarchivePending = unarchiveMutation.isMutating;
const hasPendingQuestions = isBlocked && pendingQuestions.length > 0;
const rootStyle = fullHeight && hasPendingQuestions
? ({ "--fabro-interview-dock-clearance": "18rem" } as CSSProperties)
const dockClearance = hasPendingQuestions ? "18rem" : "5rem";
const rootStyle = fullHeight
? ({ "--fabro-interview-dock-clearance": dockClearance } as CSSProperties)
: undefined;
return (
@ -346,6 +348,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
onClose={() => setSteerOpen(false)}
/>
{!hasPendingQuestions && <SteerBar runId={params.id} />}
{hasPendingQuestions && (
fullHeight ? (
<InterviewDock runId={params.id} questions={pendingQuestions} />