mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Bring the pro live-prompt composer to the local viewer
Replace the ad-hoc steer input with a faithful port of the pro app's ScanPromptComposer: a collapsible Guide the agent pill that opens into the Live prompt card with an auto-resizing textarea, a target picker, and a rounded send button. Drops the Enterprise lock and bridge states since local steering is immediate. Used under the agent graph with a target dropdown, and docked in the agent detail modal with a static target.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+146
-141
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-S0W-37yp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DIFG4xqP.css">
|
||||
<script type="module" crossorigin src="./assets/index-DeNYKQjA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B-nLXAmX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Radar,
|
||||
GitPullRequest,
|
||||
Rocket,
|
||||
Radio,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
@@ -25,7 +24,7 @@ import { IssueSeveritySummary } from "@/components/IssueSeveritySummary";
|
||||
import AgentGraph from "@/components/live/AgentGraph";
|
||||
import { buildGraphAgents } from "@/components/live/AgentTranscript";
|
||||
import AgentDetailModal from "@/components/live/AgentDetailModal";
|
||||
import { AgentSteerInput } from "@/components/live/AgentSteerInput";
|
||||
import { ScanPromptComposer } from "@/components/live/ScanPromptComposer";
|
||||
import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
|
||||
import {
|
||||
fetchAll,
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
type AuthStatus,
|
||||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
type TranscriptAgent,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
@@ -778,9 +776,7 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
</div>
|
||||
|
||||
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
|
||||
{steerable && (
|
||||
<SteerComposer agents={agents} selectedId={selectedId} />
|
||||
)}
|
||||
{steerable && <ScanPromptComposer agents={agents} />}
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
@@ -808,33 +804,3 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Live steering composer. Targets the agent selected in the graph; with none
|
||||
// selected it falls back to the root agent (no parent). Only rendered while the
|
||||
// scan is live and the viewer runs in-process (see AgentsTab.steerable).
|
||||
function SteerComposer({
|
||||
agents,
|
||||
selectedId,
|
||||
}: {
|
||||
agents: TranscriptAgent[];
|
||||
selectedId: string | null;
|
||||
}) {
|
||||
const rootAgent = agents.find((a) => !a.parent_id) ?? agents[0] ?? null;
|
||||
const target =
|
||||
(selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null) ?? rootAgent;
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Steer the agents</h2>
|
||||
</div>
|
||||
<p className="mt-1 mb-3 text-xs text-[#666]">
|
||||
Send a live instruction to <span className="text-[#aaa]">{target.name}</span>
|
||||
{selectedId ? " (selected)" : " (root)"}. Open an agent to steer it directly.
|
||||
</p>
|
||||
<AgentSteerInput key={target.id} agentId={target.id} agentName={target.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Radio, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { AgentTranscript } from "./AgentTranscript";
|
||||
import { AgentSteerInput } from "./AgentSteerInput";
|
||||
import { ScanPromptComposer } from "./ScanPromptComposer";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/** Status -> the small leading dot color, matching the graph node styling. */
|
||||
@@ -102,11 +102,11 @@ export function AgentDetailModal({
|
||||
|
||||
{steerable && (
|
||||
<div className="border-t border-[#222] px-5 py-3">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Radio className="h-3.5 w-3.5 text-[#888]" aria-hidden="true" />
|
||||
<span className="text-xs font-medium text-[#aaa]">Steer this agent</span>
|
||||
</div>
|
||||
<AgentSteerInput agentId={agent.id} agentName={agent.name} />
|
||||
<ScanPromptComposer
|
||||
agents={[agent]}
|
||||
fixedAgentId={agent.id}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { steerAgent } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Send a live instruction to one specific running agent. Only rendered when the
|
||||
* viewer runs in-process during a live scan (steering available). Reused by the
|
||||
* Agents graph composer and the per-agent detail modal, so both steer through
|
||||
* the same path.
|
||||
*/
|
||||
export function AgentSteerInput({
|
||||
agentId,
|
||||
agentName,
|
||||
placeholder = "e.g. Focus on the authentication flow next",
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = message.trim();
|
||||
if (!text || sending) return;
|
||||
setSending(true);
|
||||
setFeedback(null);
|
||||
const res = await steerAgent(agentId, text);
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setMessage("");
|
||||
setFeedback(`Sent to ${agentName}`);
|
||||
track("agent_steered");
|
||||
} else if (res.error === "not_delivered") {
|
||||
setFeedback("Could not reach that agent (it may have finished).");
|
||||
} else {
|
||||
setFeedback("Could not send that message. Try again.");
|
||||
}
|
||||
}, [message, sending, agentId, agentName]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
maxLength={4000}
|
||||
disabled={sending}
|
||||
className="flex-1 min-w-0 rounded-lg border border-[#2a2a2a] bg-[rgba(255,255,255,0.03)] px-3 py-2 text-sm text-white placeholder:text-[#555] focus:outline-none focus:border-[#3a3a3a] disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={sending || !message.trim()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-2 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{sending ? "Sending" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
{feedback && <p className="mt-2 text-xs text-[#888]">{feedback}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSteerInput;
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowUp, ChevronDown, ChevronUp, Loader2, Sparkles } from "lucide-react";
|
||||
import { steerAgent, type TranscriptAgent } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ROOT_TARGET_VALUE = "__root__";
|
||||
|
||||
interface ScanPromptComposerProps {
|
||||
/** All agents in the run; used to resolve the root and running children. */
|
||||
agents: TranscriptAgent[];
|
||||
/**
|
||||
* Single-agent (modal) mode: pins the composer to one agent and shows a
|
||||
* static "Target: <name>" pill instead of the dropdown. Omit for the
|
||||
* multi-agent graph variant.
|
||||
*/
|
||||
fixedAgentId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful port of the pro app's ScanPromptComposer for the local viewer.
|
||||
* Collapsed by default into a "Guide the agent" pill; expands into a card with
|
||||
* an auto-resizing textarea and a target control. The viewer's steering is
|
||||
* immediate (no Enterprise lock, no bridge-connecting state), so this is only
|
||||
* rendered by callers when steering is available. Sends via steerAgent, which
|
||||
* requires a concrete agent id, so "Root agent" resolves to the root agent's id.
|
||||
*/
|
||||
export function ScanPromptComposer({
|
||||
agents,
|
||||
fixedAgentId,
|
||||
className,
|
||||
}: ScanPromptComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const isModal = fixedAgentId != null;
|
||||
|
||||
// Root = the agent with no parent; fall back to the first agent.
|
||||
const rootAgent = useMemo(
|
||||
() => agents.find((a) => !a.parent_id) ?? agents[0] ?? null,
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Multi-agent dropdown options: running child agents plus Root (added in JSX).
|
||||
const targetOptions = useMemo(
|
||||
() => agents.filter((a) => a.parent_id && a.status === "running"),
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Selected target for the multi-agent variant. ROOT sentinel by default.
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(ROOT_TARGET_VALUE);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// If the selected child target disappears (finished), fall back to Root.
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTarget !== ROOT_TARGET_VALUE &&
|
||||
!targetOptions.some((a) => a.id === selectedTarget)
|
||||
) {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
}
|
||||
}, [selectedTarget, targetOptions]);
|
||||
|
||||
// Resolve the concrete agent id + display name for the current target.
|
||||
const { targetId, targetName } = useMemo(() => {
|
||||
if (isModal) {
|
||||
const agent = agents.find((a) => a.id === fixedAgentId) ?? null;
|
||||
return {
|
||||
targetId: fixedAgentId ?? null,
|
||||
targetName: agent?.name ?? "this agent",
|
||||
};
|
||||
}
|
||||
if (selectedTarget === ROOT_TARGET_VALUE) {
|
||||
return {
|
||||
targetId: rootAgent?.id ?? null,
|
||||
targetName: "Root agent",
|
||||
};
|
||||
}
|
||||
const agent = agents.find((a) => a.id === selectedTarget) ?? null;
|
||||
return {
|
||||
targetId: agent?.id ?? rootAgent?.id ?? null,
|
||||
targetName: agent?.name ?? "Root agent",
|
||||
};
|
||||
}, [agents, fixedAgentId, isModal, rootAgent, selectedTarget]);
|
||||
|
||||
const empty = value.trim().length === 0;
|
||||
|
||||
// Grow the textarea with its content, capped by max-h via CSS.
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
setExpanded(true);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setFocused(false);
|
||||
setMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (sending) return;
|
||||
const message = value.trim();
|
||||
if (!message || !targetId) return;
|
||||
|
||||
setSending(true);
|
||||
setFeedback(null);
|
||||
const name = targetName;
|
||||
const res = await steerAgent(targetId, message);
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setValue("");
|
||||
setFeedback(`Sent to ${name}`);
|
||||
track("agent_steered");
|
||||
} else if (res.error === "not_delivered") {
|
||||
setFeedback("Could not reach that agent (it may have finished).");
|
||||
} else {
|
||||
setFeedback("Could not send that message. Try again.");
|
||||
}
|
||||
}, [sending, value, targetId, targetName]);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",
|
||||
className
|
||||
)}
|
||||
aria-expanded={false}
|
||||
aria-label="Expand live prompt composer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-[#666]" />
|
||||
<span className="truncate text-sm font-medium text-white">Guide the agent</span>
|
||||
</div>
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-[#777]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",
|
||||
focused ? "border-white/[0.18]" : "hover:border-white/[0.12]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-[#666]" />
|
||||
<p className="text-sm font-medium text-white">Live prompt</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[#777]">Connected</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isModal ? (
|
||||
<div className="rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]">
|
||||
Target: <span className="text-white">{targetName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[#aaa]">Target:</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
onBlur={() => requestAnimationFrame(() => setMenuOpen(false))}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span className="max-w-[140px] truncate">{targetName}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[#999]" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
<TargetMenuItem
|
||||
label="Root agent"
|
||||
active={selectedTarget === ROOT_TARGET_VALUE}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
{targetOptions.map((option) => (
|
||||
<TargetMenuItem
|
||||
key={option.id}
|
||||
label={option.name}
|
||||
active={selectedTarget === option.id}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(option.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapse}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-label="Collapse live prompt composer"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a live prompt to the running scan…"
|
||||
maxLength={4000}
|
||||
disabled={sending}
|
||||
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-4 pb-4">
|
||||
<div className="text-xs text-[#666]">{feedback ?? "Press Enter to send."}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={sending || empty}
|
||||
className={cn(
|
||||
"inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",
|
||||
sending || empty
|
||||
? "bg-white/[0.08] text-[#666]"
|
||||
: "bg-white text-black hover:bg-neutral-200"
|
||||
)}
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" strokeWidth={2.5} />
|
||||
)}
|
||||
<span>Send prompt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetMenuItem({
|
||||
label,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
// onMouseDown so the click lands before the trigger's onBlur closes the menu.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScanPromptComposer;
|
||||
Reference in New Issue
Block a user