Enhance UI: favicon, AgentDetail overhaul, PageTabBar, and config form
Add favicon and web manifest branding assets. Major AgentDetail page rework with tabbed sections, run history, and live status. Add PageTabBar component for consistent page-level tabs. Expand AgentConfigForm with more adapter fields. Improve NewAgentDialog, OnboardingWizard, and Issues page layouts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -3,7 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#18181b" />
|
||||||
<title>Paperclip</title>
|
<title>Paperclip</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
ui/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
ui/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
ui/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
ui/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 669 B |
BIN
ui/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
ui/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
9
ui/public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<style>
|
||||||
|
path { stroke: #18181b; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { stroke: #e4e4e7; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path stroke-width="2" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 410 B |
19
ui/public/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperclip",
|
||||||
|
"short_name": "Paperclip",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#18181b",
|
||||||
|
"background_color": "#18181b",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export function App() {
|
|||||||
<Route path="org" element={<Navigate to="/agents" replace />} />
|
<Route path="org" element={<Navigate to="/agents" replace />} />
|
||||||
<Route path="agents" element={<Agents />} />
|
<Route path="agents" element={<Agents />} />
|
||||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||||
|
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
||||||
<Route path="projects" element={<Projects />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||||
<Route path="issues" element={<Issues />} />
|
<Route path="issues" element={<Issues />} />
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface CreateConfigValues {
|
|||||||
dangerouslyBypassSandbox: boolean;
|
dangerouslyBypassSandbox: boolean;
|
||||||
command: string;
|
command: string;
|
||||||
args: string;
|
args: string;
|
||||||
|
extraArgs: string;
|
||||||
|
envVars: string;
|
||||||
url: string;
|
url: string;
|
||||||
bootstrapPrompt: string;
|
bootstrapPrompt: string;
|
||||||
maxTurnsPerRun: number;
|
maxTurnsPerRun: number;
|
||||||
@@ -54,6 +56,8 @@ export const defaultCreateValues: CreateConfigValues = {
|
|||||||
dangerouslyBypassSandbox: false,
|
dangerouslyBypassSandbox: false,
|
||||||
command: "",
|
command: "",
|
||||||
args: "",
|
args: "",
|
||||||
|
extraArgs: "",
|
||||||
|
envVars: "",
|
||||||
url: "",
|
url: "",
|
||||||
bootstrapPrompt: "",
|
bootstrapPrompt: "",
|
||||||
maxTurnsPerRun: 80,
|
maxTurnsPerRun: 80,
|
||||||
@@ -65,6 +69,10 @@ export const defaultCreateValues: CreateConfigValues = {
|
|||||||
|
|
||||||
type AgentConfigFormProps = {
|
type AgentConfigFormProps = {
|
||||||
adapterModels?: AdapterModel[];
|
adapterModels?: AdapterModel[];
|
||||||
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
|
onSaveActionChange?: (save: (() => void) | null) => void;
|
||||||
|
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
||||||
|
hideInlineSave?: boolean;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
mode: "create";
|
mode: "create";
|
||||||
@@ -110,6 +118,51 @@ function isOverlayDirty(o: Overlay): boolean {
|
|||||||
const inputClass =
|
const inputClass =
|
||||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArgList(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvVars(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEnvVars(value: unknown): string {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
|
||||||
|
return Object.entries(value as Record<string, unknown>)
|
||||||
|
.filter(([, v]) => typeof v === "string")
|
||||||
|
.map(([k, v]) => `${k}=${String(v)}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPickedDirectoryPath(handle: unknown): string | null {
|
||||||
|
if (typeof handle !== "object" || handle === null) return null;
|
||||||
|
const maybePath = (handle as { path?: unknown }).path;
|
||||||
|
return typeof maybePath === "string" && maybePath.length > 0 ? maybePath : null;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Form ---- */
|
/* ---- Form ---- */
|
||||||
|
|
||||||
export function AgentConfigForm(props: AgentConfigFormProps) {
|
export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
@@ -175,6 +228,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
props.onSave(patch);
|
props.onSave(patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreate) {
|
||||||
|
props.onDirtyChange?.(isDirty);
|
||||||
|
props.onSaveActionChange?.(() => handleSave());
|
||||||
|
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
|
||||||
|
return () => {
|
||||||
|
props.onSaveActionChange?.(null);
|
||||||
|
props.onCancelActionChange?.(null);
|
||||||
|
props.onDirtyChange?.(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ---- Resolve values ----
|
// ---- Resolve values ----
|
||||||
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
||||||
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
|
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
|
||||||
@@ -195,6 +262,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
// Section toggle state — advanced always starts collapsed
|
// Section toggle state — advanced always starts collapsed
|
||||||
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
|
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
|
||||||
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
|
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
|
||||||
|
const [cwdPickerNotice, setCwdPickerNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
// Popover states
|
// Popover states
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
const [modelOpen, setModelOpen] = useState(false);
|
||||||
@@ -213,7 +281,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||||
{isDirty && (
|
{isDirty && !props.hideInlineSave && (
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
|
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||||
@@ -237,6 +305,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
<DraftInput
|
<DraftInput
|
||||||
value={eff("identity", "name", props.agent.name)}
|
value={eff("identity", "name", props.agent.name)}
|
||||||
onCommit={(v) => mark("identity", "name", v)}
|
onCommit={(v) => mark("identity", "name", v)}
|
||||||
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="Agent name"
|
placeholder="Agent name"
|
||||||
/>
|
/>
|
||||||
@@ -245,6 +314,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
<DraftInput
|
<DraftInput
|
||||||
value={eff("identity", "title", props.agent.title ?? "")}
|
value={eff("identity", "title", props.agent.title ?? "")}
|
||||||
onCommit={(v) => mark("identity", "title", v || null)}
|
onCommit={(v) => mark("identity", "title", v || null)}
|
||||||
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="e.g. VP of Engineering"
|
placeholder="e.g. VP of Engineering"
|
||||||
/>
|
/>
|
||||||
@@ -253,6 +323,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
<DraftTextarea
|
<DraftTextarea
|
||||||
value={eff("identity", "capabilities", props.agent.capabilities ?? "")}
|
value={eff("identity", "capabilities", props.agent.capabilities ?? "")}
|
||||||
onCommit={(v) => mark("identity", "capabilities", v || null)}
|
onCommit={(v) => mark("identity", "capabilities", v || null)}
|
||||||
|
immediate
|
||||||
placeholder="Describe what this agent can do..."
|
placeholder="Describe what this agent can do..."
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
@@ -303,7 +374,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? set!({ cwd: v })
|
? set!({ cwd: v })
|
||||||
: mark("adapterConfig", "cwd", v || undefined)
|
: mark("adapterConfig", "cwd", v || undefined)
|
||||||
}
|
}
|
||||||
immediate={isCreate}
|
immediate
|
||||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
||||||
placeholder="/path/to/project"
|
placeholder="/path/to/project"
|
||||||
/>
|
/>
|
||||||
@@ -312,10 +383,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
|
setCwdPickerNotice(null);
|
||||||
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
||||||
const handle = await window.showDirectoryPicker({ mode: "read" });
|
const handle = await window.showDirectoryPicker({ mode: "read" });
|
||||||
if (isCreate) set!({ cwd: handle.name });
|
const absolutePath = extractPickedDirectoryPath(handle);
|
||||||
else mark("adapterConfig", "cwd", handle.name);
|
if (absolutePath) {
|
||||||
|
if (isCreate) set!({ cwd: absolutePath });
|
||||||
|
else mark("adapterConfig", "cwd", absolutePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedName =
|
||||||
|
typeof handle === "object" &&
|
||||||
|
handle !== null &&
|
||||||
|
typeof (handle as { name?: unknown }).name === "string"
|
||||||
|
? String((handle as { name: string }).name)
|
||||||
|
: "selected folder";
|
||||||
|
setCwdPickerNotice(
|
||||||
|
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// user cancelled or API unsupported
|
// user cancelled or API unsupported
|
||||||
}
|
}
|
||||||
@@ -324,6 +409,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
Choose
|
Choose
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{cwdPickerNotice && (
|
||||||
|
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -347,6 +435,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
onCommit={(v) =>
|
onCommit={(v) =>
|
||||||
mark("adapterConfig", "promptTemplate", v || undefined)
|
mark("adapterConfig", "promptTemplate", v || undefined)
|
||||||
}
|
}
|
||||||
|
immediate
|
||||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||||
minRows={4}
|
minRows={4}
|
||||||
/>
|
/>
|
||||||
@@ -429,7 +518,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? set!({ command: v })
|
? set!({ command: v })
|
||||||
: mark("adapterConfig", "command", v || undefined)
|
: mark("adapterConfig", "command", v || undefined)
|
||||||
}
|
}
|
||||||
immediate={isCreate}
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="e.g. node, python"
|
placeholder="e.g. node, python"
|
||||||
/>
|
/>
|
||||||
@@ -439,7 +528,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
value={
|
value={
|
||||||
isCreate
|
isCreate
|
||||||
? val!.args
|
? val!.args
|
||||||
: eff("adapterConfig", "args", String(config.args ?? ""))
|
: eff("adapterConfig", "args", formatArgList(config.args))
|
||||||
}
|
}
|
||||||
onCommit={(v) =>
|
onCommit={(v) =>
|
||||||
isCreate
|
isCreate
|
||||||
@@ -447,15 +536,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
: mark(
|
: mark(
|
||||||
"adapterConfig",
|
"adapterConfig",
|
||||||
"args",
|
"args",
|
||||||
v
|
v ? parseCommaArgs(v) : undefined,
|
||||||
? v
|
|
||||||
.split(",")
|
|
||||||
.map((a) => a.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: undefined,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
immediate={isCreate}
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="e.g. script.js, --flag"
|
placeholder="e.g. script.js, --flag"
|
||||||
/>
|
/>
|
||||||
@@ -477,7 +561,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? set!({ url: v })
|
? set!({ url: v })
|
||||||
: mark("adapterConfig", "url", v || undefined)
|
: mark("adapterConfig", "url", v || undefined)
|
||||||
}
|
}
|
||||||
immediate={isCreate}
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
@@ -492,6 +576,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
|
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<Field label="Command" hint={help.localCommand}>
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? val!.command
|
||||||
|
: eff("adapterConfig", "command", String(config.command ?? ""))
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ command: v })
|
||||||
|
: mark("adapterConfig", "command", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder={adapterType === "codex_local" ? "codex" : "claude"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<ModelDropdown
|
<ModelDropdown
|
||||||
models={models}
|
models={models}
|
||||||
value={currentModelId}
|
value={currentModelId}
|
||||||
@@ -521,6 +623,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
onCommit={(v) =>
|
onCommit={(v) =>
|
||||||
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
||||||
}
|
}
|
||||||
|
immediate
|
||||||
placeholder="Optional initial setup prompt for the first run"
|
placeholder="Optional initial setup prompt for the first run"
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
@@ -543,12 +646,57 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
Number(config.maxTurnsPerRun ?? 80),
|
Number(config.maxTurnsPerRun ?? 80),
|
||||||
)}
|
)}
|
||||||
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
|
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
|
||||||
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? val!.extraArgs
|
||||||
|
: eff("adapterConfig", "extraArgs", formatArgList(config.extraArgs))
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ extraArgs: v })
|
||||||
|
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="e.g. --verbose, --foo=bar"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Environment variables" hint={help.envVars}>
|
||||||
|
{isCreate ? (
|
||||||
|
<AutoExpandTextarea
|
||||||
|
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
|
||||||
|
value={val!.envVars}
|
||||||
|
onChange={(v) => set!({ envVars: v })}
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DraftTextarea
|
||||||
|
value={eff("adapterConfig", "env", formatEnvVars(config.env))}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const parsed = parseEnvVars(v);
|
||||||
|
mark(
|
||||||
|
"adapterConfig",
|
||||||
|
"env",
|
||||||
|
Object.keys(parsed).length > 0 ? parsed : undefined,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
immediate
|
||||||
|
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
|
||||||
|
minRows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
{/* Edit-only: timeout + grace period */}
|
{/* Edit-only: timeout + grace period */}
|
||||||
{!isCreate && (
|
{!isCreate && (
|
||||||
<>
|
<>
|
||||||
@@ -560,6 +708,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
Number(config.timeoutSec ?? 0),
|
Number(config.timeoutSec ?? 0),
|
||||||
)}
|
)}
|
||||||
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
|
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
|
||||||
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -571,6 +720,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
Number(config.graceSec ?? 15),
|
Number(config.graceSec ?? 15),
|
||||||
)}
|
)}
|
||||||
onCommit={(v) => mark("adapterConfig", "graceSec", v)}
|
onCommit={(v) => mark("adapterConfig", "graceSec", v)}
|
||||||
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -669,6 +819,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
Number(heartbeat.cooldownSec ?? 10),
|
Number(heartbeat.cooldownSec ?? 10),
|
||||||
)}
|
)}
|
||||||
onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
|
onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
|
||||||
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -695,6 +846,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
props.agent.budgetMonthlyCents,
|
props.agent.budgetMonthlyCents,
|
||||||
)}
|
)}
|
||||||
onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)}
|
onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)}
|
||||||
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -30,6 +30,28 @@ import {
|
|||||||
type CreateConfigValues,
|
type CreateConfigValues,
|
||||||
} from "./AgentConfigForm";
|
} from "./AgentConfigForm";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvVars(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const valueAtKey = trimmed.slice(eq + 1);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
env[key] = valueAtKey;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
export function NewAgentDialog() {
|
export function NewAgentDialog() {
|
||||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
@@ -102,16 +124,22 @@ export function NewAgentDialog() {
|
|||||||
if (v.model) ac.model = v.model;
|
if (v.model) ac.model = v.model;
|
||||||
ac.timeoutSec = 0;
|
ac.timeoutSec = 0;
|
||||||
ac.graceSec = 15;
|
ac.graceSec = 15;
|
||||||
|
const env = parseEnvVars(v.envVars);
|
||||||
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
|
|
||||||
if (v.adapterType === "claude_local") {
|
if (v.adapterType === "claude_local") {
|
||||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
} else if (v.adapterType === "codex_local") {
|
} else if (v.adapterType === "codex_local") {
|
||||||
ac.search = v.search;
|
ac.search = v.search;
|
||||||
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
} else if (v.adapterType === "process") {
|
} else if (v.adapterType === "process") {
|
||||||
if (v.command) ac.command = v.command;
|
if (v.command) ac.command = v.command;
|
||||||
if (v.args) ac.args = v.args.split(",").map((a) => a.trim()).filter(Boolean);
|
if (v.args) ac.args = parseCommaArgs(v.args);
|
||||||
} else if (v.adapterType === "http") {
|
} else if (v.adapterType === "http") {
|
||||||
if (v.url) ac.url = v.url;
|
if (v.url) ac.url = v.url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function OnboardingWizard() {
|
|||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
const [args, setArgs] = useState("");
|
const [args, setArgs] = useState("");
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
|
const [cwdPickerNotice, setCwdPickerNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
// Step 3
|
// Step 3
|
||||||
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
|
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
|
||||||
@@ -88,6 +89,7 @@ export function OnboardingWizard() {
|
|||||||
setCommand("");
|
setCommand("");
|
||||||
setArgs("");
|
setArgs("");
|
||||||
setUrl("");
|
setUrl("");
|
||||||
|
setCwdPickerNotice(null);
|
||||||
setTaskTitle("Create your CEO HEARTBEAT.md");
|
setTaskTitle("Create your CEO HEARTBEAT.md");
|
||||||
setTaskDescription("You're the CEO of the company, make sure you have a file agents/ceo/HEARTBEAT.md that tells you your core loop. You MUST use the Paperclip SKILL.");
|
setTaskDescription("You're the CEO of the company, make sure you have a file agents/ceo/HEARTBEAT.md that tells you your core loop. You MUST use the Paperclip SKILL.");
|
||||||
setCreatedCompanyId(null);
|
setCreatedCompanyId(null);
|
||||||
@@ -406,9 +408,28 @@ export function OnboardingWizard() {
|
|||||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
|
setCwdPickerNotice(null);
|
||||||
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
||||||
const handle = await window.showDirectoryPicker({ mode: "read" });
|
const handle = await window.showDirectoryPicker({ mode: "read" });
|
||||||
setCwd(handle.name);
|
const pickedPath =
|
||||||
|
typeof handle === "object" &&
|
||||||
|
handle !== null &&
|
||||||
|
typeof (handle as { path?: unknown }).path === "string"
|
||||||
|
? String((handle as { path: string }).path)
|
||||||
|
: "";
|
||||||
|
if (pickedPath) {
|
||||||
|
setCwd(pickedPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedName =
|
||||||
|
typeof handle === "object" &&
|
||||||
|
handle !== null &&
|
||||||
|
typeof (handle as { name?: unknown }).name === "string"
|
||||||
|
? String((handle as { name: string }).name)
|
||||||
|
: "selected folder";
|
||||||
|
setCwdPickerNotice(
|
||||||
|
`Directory picker only exposed "${selectedName}". Paste the absolute path manually.`,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// user cancelled or API unsupported
|
// user cancelled or API unsupported
|
||||||
}
|
}
|
||||||
@@ -417,6 +438,9 @@ export function OnboardingWizard() {
|
|||||||
Choose
|
Choose
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{cwdPickerNotice && (
|
||||||
|
<p className="mt-1 text-xs text-amber-400">{cwdPickerNotice}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
|
|||||||
19
ui/src/components/PageTabBar.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
export interface PageTabItem {
|
||||||
|
value: string;
|
||||||
|
label: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTabBar({ items }: { items: PageTabItem[] }) {
|
||||||
|
return (
|
||||||
|
<TabsList variant="line">
|
||||||
|
{items.map((item) => (
|
||||||
|
<TabsTrigger key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export const help: Record<string, string> = {
|
|||||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||||
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
|
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
|
||||||
cwd: "The working directory where the agent operates. Should be an absolute path on the server.",
|
cwd: "The working directory where the agent operates. Use an absolute path on the machine running Paperclip.",
|
||||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||||
model: "Override the default model used by the adapter.",
|
model: "Override the default model used by the adapter.",
|
||||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
||||||
@@ -24,7 +24,10 @@ export const help: Record<string, string> = {
|
|||||||
bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.",
|
bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.",
|
||||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
||||||
command: "The command to execute (e.g. node, python).",
|
command: "The command to execute (e.g. node, python).",
|
||||||
|
localCommand: "Override the local CLI command (e.g. claude, /usr/local/bin/claude, codex).",
|
||||||
args: "Command-line arguments, comma-separated.",
|
args: "Command-line arguments, comma-separated.",
|
||||||
|
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||||
|
envVars: "Environment variables injected into the adapter process. One KEY=VALUE per line.",
|
||||||
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
||||||
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
||||||
intervalSec: "Seconds between automatic heartbeat invocations.",
|
intervalSec: "Seconds between automatic heartbeat invocations.",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
import { useParams, useNavigate, Link, useBeforeUnload, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { agentsApi, type AgentKey } from "../api/agents";
|
import { agentsApi, type AgentKey } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
@@ -9,14 +9,14 @@ import { useCompany } from "../context/CompanyContext";
|
|||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AgentProperties } from "../components/AgentProperties";
|
|
||||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||||
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
@@ -28,8 +28,6 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -63,16 +61,190 @@ const sourceLabels: Record<string, string> = {
|
|||||||
automation: "Automation",
|
automation: "Automation",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AgentDetailTab = "overview" | "configuration" | "runs" | "issues" | "costs" | "keys";
|
||||||
|
|
||||||
|
function parseAgentDetailTab(value: string | null): AgentDetailTab {
|
||||||
|
if (value === "configuration") return value;
|
||||||
|
if (value === "runs") return value;
|
||||||
|
if (value === "issues") return value;
|
||||||
|
if (value === "costs") return value;
|
||||||
|
if (value === "keys") return value;
|
||||||
|
return "overview";
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
|
||||||
|
if (!usage) return 0;
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = usage[key];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMetrics(run: HeartbeatRun) {
|
||||||
|
const usage = (run.usageJson ?? null) as Record<string, unknown> | null;
|
||||||
|
const result = (run.resultJson ?? null) as Record<string, unknown> | null;
|
||||||
|
const input = usageNumber(usage, "inputTokens", "input_tokens");
|
||||||
|
const output = usageNumber(usage, "outputTokens", "output_tokens");
|
||||||
|
const cached = usageNumber(
|
||||||
|
usage,
|
||||||
|
"cachedInputTokens",
|
||||||
|
"cached_input_tokens",
|
||||||
|
"cache_read_input_tokens",
|
||||||
|
);
|
||||||
|
const cost =
|
||||||
|
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
|
||||||
|
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cached,
|
||||||
|
cost,
|
||||||
|
totalTokens: input + output,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||||
|
|
||||||
|
type TranscriptEntry =
|
||||||
|
| { kind: "assistant"; ts: string; text: string }
|
||||||
|
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
||||||
|
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||||
|
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number }
|
||||||
|
| { kind: "stderr"; ts: string; text: string }
|
||||||
|
| { kind: "system"; ts: string; text: string }
|
||||||
|
| { kind: "stdout"; ts: string; text: string };
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
|
if (!parsed) {
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||||
|
if (type === "system" && parsed.subtype === "init") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: "init",
|
||||||
|
ts,
|
||||||
|
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
||||||
|
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
const message = asRecord(parsed.message) ?? {};
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
for (const blockRaw of content) {
|
||||||
|
const block = asRecord(blockRaw);
|
||||||
|
if (!block) continue;
|
||||||
|
const blockType = typeof block.type === "string" ? block.type : "";
|
||||||
|
if (blockType === "text") {
|
||||||
|
const text = typeof block.text === "string" ? block.text : "";
|
||||||
|
if (text) entries.push({ kind: "assistant", ts, text });
|
||||||
|
} else if (blockType === "tool_use") {
|
||||||
|
entries.push({
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name: typeof block.name === "string" ? block.name : "unknown",
|
||||||
|
input: block.input ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
const usage = asRecord(parsed.usage) ?? {};
|
||||||
|
const inputTokens = asNumber(usage.input_tokens);
|
||||||
|
const outputTokens = asNumber(usage.output_tokens);
|
||||||
|
const cachedTokens = asNumber(usage.cache_read_input_tokens);
|
||||||
|
const costUsd = asNumber(parsed.total_cost_usd);
|
||||||
|
const text = typeof parsed.result === "string" ? parsed.result : "";
|
||||||
|
return [{
|
||||||
|
kind: "result",
|
||||||
|
ts,
|
||||||
|
text,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cachedTokens,
|
||||||
|
costUsd,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTranscript(chunks: RunLogChunk[]): TranscriptEntry[] {
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
let stdoutBuffer = "";
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (chunk.stream === "stderr") {
|
||||||
|
entries.push({ kind: "stderr", ts: chunk.ts, text: chunk.chunk });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.stream === "system") {
|
||||||
|
entries.push({ kind: "system", ts: chunk.ts, text: chunk.chunk });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = stdoutBuffer + chunk.chunk;
|
||||||
|
const lines = combined.split(/\r?\n/);
|
||||||
|
stdoutBuffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
entries.push(...parseClaudeStdoutLine(trimmed, chunk.ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailing = stdoutBuffer.trim();
|
||||||
|
if (trailing) {
|
||||||
|
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||||
|
entries.push(...parseClaudeStdoutLine(trailing, ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonParse(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentDetail() {
|
export function AgentDetail() {
|
||||||
const { agentId } = useParams<{ agentId: string }>();
|
const { agentId, runId: urlRunId } = useParams<{ agentId: string; runId?: string }>();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openPanel, closePanel } = usePanel();
|
const { closePanel } = usePanel();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
const activeTab = urlRunId ? "runs" as AgentDetailTab : parseAgentDetailTab(searchParams.get("tab"));
|
||||||
|
const [configDirty, setConfigDirty] = useState(false);
|
||||||
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
|
const saveConfigActionRef = useRef<(() => void) | null>(null);
|
||||||
|
const cancelConfigActionRef = useRef<(() => void) | null>(null);
|
||||||
|
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
|
||||||
|
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
|
||||||
|
|
||||||
const { data: agent, isLoading, error } = useQuery({
|
const { data: agent, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.agents.detail(agentId!),
|
queryKey: queryKeys.agents.detail(agentId!),
|
||||||
@@ -140,11 +312,31 @@ export function AgentDetail() {
|
|||||||
}, [setBreadcrumbs, agent, agentId]);
|
}, [setBreadcrumbs, agent, agentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (agent) {
|
closePanel();
|
||||||
openPanel(<AgentProperties agent={agent} runtimeState={runtimeState ?? undefined} />);
|
|
||||||
}
|
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [agent, runtimeState]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useBeforeUnload(
|
||||||
|
useCallback((event) => {
|
||||||
|
if (!configDirty) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
}, [configDirty]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const setActiveTab = useCallback((nextTab: string) => {
|
||||||
|
const next = parseAgentDetailTab(nextTab);
|
||||||
|
// If we're on a /runs/:runId URL and switching tabs, navigate back to base agent URL
|
||||||
|
if (urlRunId) {
|
||||||
|
const tabParam = next === "overview" ? "" : `?tab=${next}`;
|
||||||
|
navigate(`/agents/${agentId}${tabParam}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (next === "overview") params.delete("tab");
|
||||||
|
else params.set("tab", next);
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [searchParams, setSearchParams, urlRunId, agentId, navigate]);
|
||||||
|
|
||||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
@@ -162,6 +354,14 @@ export function AgentDetail() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openNewIssue({ assigneeAgentId: agentId })}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Assign Task
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -171,25 +371,7 @@ export function AgentDetail() {
|
|||||||
<Play className="h-3.5 w-3.5 mr-1" />
|
<Play className="h-3.5 w-3.5 mr-1" />
|
||||||
Invoke
|
Invoke
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{agent.status === "paused" ? (
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openNewIssue({ assigneeAgentId: agentId })}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
||||||
Assign Task
|
|
||||||
</Button>
|
|
||||||
{agent.status === "active" || agent.status === "running" ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => agentAction.mutate("pause")}
|
|
||||||
disabled={agentAction.isPending}
|
|
||||||
>
|
|
||||||
<Pause className="h-3.5 w-3.5 mr-1" />
|
|
||||||
Pause
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -199,6 +381,16 @@ export function AgentDetail() {
|
|||||||
<Play className="h-3.5 w-3.5 mr-1" />
|
<Play className="h-3.5 w-3.5 mr-1" />
|
||||||
Resume
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => agentAction.mutate("pause")}
|
||||||
|
disabled={agentAction.isPending}
|
||||||
|
>
|
||||||
|
<Pause className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<StatusBadge status={agent.status} />
|
<StatusBadge status={agent.status} />
|
||||||
|
|
||||||
@@ -247,15 +439,43 @@ export function AgentDetail() {
|
|||||||
|
|
||||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList>
|
<div className="flex items-center justify-between">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<PageTabBar
|
||||||
<TabsTrigger value="configuration">Configuration</TabsTrigger>
|
items={[
|
||||||
<TabsTrigger value="runs">Runs{heartbeats ? ` (${heartbeats.length})` : ""}</TabsTrigger>
|
{ value: "overview", label: "Overview" },
|
||||||
<TabsTrigger value="issues">Issues ({assignedIssues.length})</TabsTrigger>
|
{ value: "configuration", label: "Configuration" },
|
||||||
<TabsTrigger value="costs">Costs</TabsTrigger>
|
{ value: "runs", label: `Runs${heartbeats ? ` (${heartbeats.length})` : ""}` },
|
||||||
<TabsTrigger value="keys">API Keys</TabsTrigger>
|
{ value: "issues", label: `Issues (${assignedIssues.length})` },
|
||||||
</TabsList>
|
{ value: "costs", label: "Costs" },
|
||||||
|
{ value: "keys", label: "API Keys" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 transition-opacity duration-150",
|
||||||
|
activeTab === "configuration" && configDirty
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => cancelConfigActionRef.current?.()}
|
||||||
|
disabled={configSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => saveConfigActionRef.current?.()}
|
||||||
|
disabled={configSaving}
|
||||||
|
>
|
||||||
|
{configSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* OVERVIEW TAB */}
|
{/* OVERVIEW TAB */}
|
||||||
<TabsContent value="overview" className="space-y-6 mt-4">
|
<TabsContent value="overview" className="space-y-6 mt-4">
|
||||||
@@ -354,12 +574,18 @@ export function AgentDetail() {
|
|||||||
|
|
||||||
{/* CONFIGURATION TAB */}
|
{/* CONFIGURATION TAB */}
|
||||||
<TabsContent value="configuration" className="mt-4">
|
<TabsContent value="configuration" className="mt-4">
|
||||||
<ConfigurationTab agent={agent} />
|
<ConfigurationTab
|
||||||
|
agent={agent}
|
||||||
|
onDirtyChange={setConfigDirty}
|
||||||
|
onSaveActionChange={setSaveConfigAction}
|
||||||
|
onCancelActionChange={setCancelConfigAction}
|
||||||
|
onSavingChange={setConfigSaving}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* RUNS TAB */}
|
{/* RUNS TAB */}
|
||||||
<TabsContent value="runs" className="mt-4">
|
<TabsContent value="runs" className="mt-4">
|
||||||
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} />
|
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} agentId={agentId!} selectedRunId={urlRunId ?? null} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ISSUES TAB */}
|
{/* ISSUES TAB */}
|
||||||
@@ -408,7 +634,19 @@ function SummaryRow({ label, children }: { label: string; children: React.ReactN
|
|||||||
|
|
||||||
/* ---- Configuration Tab ---- */
|
/* ---- Configuration Tab ---- */
|
||||||
|
|
||||||
function ConfigurationTab({ agent }: { agent: Agent }) {
|
function ConfigurationTab({
|
||||||
|
agent,
|
||||||
|
onDirtyChange,
|
||||||
|
onSaveActionChange,
|
||||||
|
onCancelActionChange,
|
||||||
|
onSavingChange,
|
||||||
|
}: {
|
||||||
|
agent: Agent;
|
||||||
|
onDirtyChange: (dirty: boolean) => void;
|
||||||
|
onSaveActionChange: (save: (() => void) | null) => void;
|
||||||
|
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||||
|
onSavingChange: (saving: boolean) => void;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
@@ -423,6 +661,10 @@ function ConfigurationTab({ agent }: { agent: Agent }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSavingChange(updateAgent.isPending);
|
||||||
|
}, [onSavingChange, updateAgent.isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl border border-border rounded-lg overflow-hidden">
|
<div className="max-w-2xl border border-border rounded-lg overflow-hidden">
|
||||||
<AgentConfigForm
|
<AgentConfigForm
|
||||||
@@ -431,6 +673,10 @@ function ConfigurationTab({ agent }: { agent: Agent }) {
|
|||||||
onSave={(patch) => updateAgent.mutate(patch)}
|
onSave={(patch) => updateAgent.mutate(patch)}
|
||||||
isSaving={updateAgent.isPending}
|
isSaving={updateAgent.isPending}
|
||||||
adapterModels={adapterModels}
|
adapterModels={adapterModels}
|
||||||
|
onDirtyChange={onDirtyChange}
|
||||||
|
onSaveActionChange={onSaveActionChange}
|
||||||
|
onCancelActionChange={onCancelActionChange}
|
||||||
|
hideInlineSave
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -438,8 +684,8 @@ function ConfigurationTab({ agent }: { agent: Agent }) {
|
|||||||
|
|
||||||
/* ---- Runs Tab ---- */
|
/* ---- Runs Tab ---- */
|
||||||
|
|
||||||
function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string }) {
|
function RunsTab({ runs, companyId, agentId, selectedRunId }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null }) {
|
||||||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (runs.length === 0) {
|
if (runs.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">No runs yet.</p>;
|
return <p className="text-sm text-muted-foreground">No runs yet.</p>;
|
||||||
@@ -450,29 +696,38 @@ function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string
|
|||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-select latest run when no run is selected
|
||||||
|
const effectiveRunId = selectedRunId ?? sorted[0]?.id ?? null;
|
||||||
|
const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border">
|
<div className="flex gap-0 border border-border rounded-lg overflow-hidden" style={{ height: "calc(100vh - 220px)" }}>
|
||||||
|
{/* Left: run list */}
|
||||||
|
<div className={cn(
|
||||||
|
"shrink-0 overflow-y-auto border-r border-border",
|
||||||
|
selectedRun ? "w-72" : "w-full",
|
||||||
|
)}>
|
||||||
{sorted.map((run) => {
|
{sorted.map((run) => {
|
||||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||||
const StatusIcon = statusInfo.icon;
|
const StatusIcon = statusInfo.icon;
|
||||||
const isExpanded = expandedRunId === run.id;
|
const isSelected = run.id === effectiveRunId;
|
||||||
const usage = run.usageJson as Record<string, unknown> | null;
|
const metrics = runMetrics(run);
|
||||||
const totalTokens = usage
|
|
||||||
? (Number(usage.input_tokens ?? 0) + Number(usage.output_tokens ?? 0))
|
|
||||||
: 0;
|
|
||||||
const cost = usage ? Number(usage.cost_usd ?? usage.total_cost_usd ?? 0) : 0;
|
|
||||||
const summary = run.resultJson
|
const summary = run.resultJson
|
||||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||||
: run.error ?? "";
|
: run.error ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={run.id} className="border-b border-border last:border-b-0">
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors text-left"
|
key={run.id}
|
||||||
onClick={() => setExpandedRunId(isExpanded ? null : run.id)}
|
className={cn(
|
||||||
|
"flex flex-col gap-1 w-full px-3 py-2.5 text-left border-b border-border last:border-b-0 transition-colors",
|
||||||
|
isSelected ? "bg-accent/40" : "hover:bg-accent/20",
|
||||||
|
)}
|
||||||
|
onClick={() => navigate(isSelected ? `/agents/${agentId}?tab=runs` : `/agents/${agentId}/runs/${run.id}`)}
|
||||||
>
|
>
|
||||||
<StatusIcon className={cn("h-4 w-4 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono text-xs text-muted-foreground shrink-0">
|
<StatusIcon className={cn("h-3.5 w-3.5 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{run.id.slice(0, 8)}
|
{run.id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -484,28 +739,33 @@ function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string
|
|||||||
)}>
|
)}>
|
||||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-muted-foreground text-xs">
|
<span className="ml-auto text-[11px] text-muted-foreground shrink-0">
|
||||||
{summary ? summary.slice(0, 80) : ""}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
|
||||||
{totalTokens > 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">{formatTokens(totalTokens)} tok</span>
|
|
||||||
)}
|
|
||||||
{cost > 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">${cost.toFixed(3)}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{relativeTime(run.createdAt)}
|
{relativeTime(run.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
||||||
</div>
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate pl-5.5">
|
||||||
|
{summary.slice(0, 60)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
|
||||||
|
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground">
|
||||||
|
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
|
||||||
|
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && <RunDetail run={run} />}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: run detail */}
|
||||||
|
{selectedRun && (
|
||||||
|
<div className="flex-1 min-w-0 overflow-y-auto pr-2">
|
||||||
|
<RunDetail key={selectedRun.id} run={selectedRun} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +773,7 @@ function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string
|
|||||||
|
|
||||||
function RunDetail({ run }: { run: HeartbeatRun }) {
|
function RunDetail({ run }: { run: HeartbeatRun }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const usage = run.usageJson as Record<string, unknown> | null;
|
const metrics = runMetrics(run);
|
||||||
|
|
||||||
const cancelRun = useMutation({
|
const cancelRun = useMutation({
|
||||||
mutationFn: () => heartbeatsApi.cancel(run.id),
|
mutationFn: () => heartbeatsApi.cancel(run.id),
|
||||||
@@ -523,9 +783,9 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 pb-4 space-y-4 bg-accent/10">
|
<div className="p-4 space-y-4">
|
||||||
{/* Status timeline */}
|
{/* Status timeline */}
|
||||||
<div className="flex items-center gap-6 text-xs">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Status: </span>
|
<span className="text-muted-foreground">Status: </span>
|
||||||
<StatusBadge status={run.status} />
|
<StatusBadge status={run.status} />
|
||||||
@@ -551,26 +811,26 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token breakdown */}
|
{/* Token breakdown */}
|
||||||
{usage && (
|
{(metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0) && (
|
||||||
<div className="flex items-center gap-6 text-xs">
|
<div className="flex items-center gap-6 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Input: </span>
|
<span className="text-muted-foreground">Input: </span>
|
||||||
<span>{formatTokens(Number(usage.input_tokens ?? 0))}</span>
|
<span>{formatTokens(metrics.input)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Output: </span>
|
<span className="text-muted-foreground">Output: </span>
|
||||||
<span>{formatTokens(Number(usage.output_tokens ?? 0))}</span>
|
<span>{formatTokens(metrics.output)}</span>
|
||||||
</div>
|
</div>
|
||||||
{Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0) > 0 && (
|
{metrics.cached > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Cached: </span>
|
<span className="text-muted-foreground">Cached: </span>
|
||||||
<span>{formatTokens(Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0))}</span>
|
<span>{formatTokens(metrics.cached)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Number(usage.cost_usd ?? usage.total_cost_usd ?? 0) > 0 && (
|
{metrics.cost > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Cost: </span>
|
<span className="text-muted-foreground">Cost: </span>
|
||||||
<span>${Number(usage.cost_usd ?? usage.total_cost_usd ?? 0).toFixed(4)}</span>
|
<span>${metrics.cost.toFixed(4)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -582,13 +842,25 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
|
|||||||
{run.sessionIdBefore && (
|
{run.sessionIdBefore && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Session before: </span>
|
<span className="text-muted-foreground">Session before: </span>
|
||||||
<span className="font-mono">{run.sessionIdBefore.slice(0, 16)}...</span>
|
<button
|
||||||
|
className="font-mono hover:text-foreground transition-colors cursor-copy"
|
||||||
|
title={`Click to copy: ${run.sessionIdBefore}`}
|
||||||
|
onClick={() => navigator.clipboard.writeText(run.sessionIdBefore!)}
|
||||||
|
>
|
||||||
|
{run.sessionIdBefore.slice(0, 16)}...
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{run.sessionIdAfter && (
|
{run.sessionIdAfter && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Session after: </span>
|
<span className="text-muted-foreground">Session after: </span>
|
||||||
<span className="font-mono">{run.sessionIdAfter.slice(0, 16)}...</span>
|
<button
|
||||||
|
className="font-mono hover:text-foreground transition-colors cursor-copy"
|
||||||
|
title={`Click to copy: ${run.sessionIdAfter}`}
|
||||||
|
onClick={() => navigator.clipboard.writeText(run.sessionIdAfter!)}
|
||||||
|
>
|
||||||
|
{run.sessionIdAfter.slice(0, 16)}...
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -612,6 +884,22 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* stderr excerpt for failed runs */}
|
||||||
|
{run.stderrExcerpt && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-red-400">stderr</span>
|
||||||
|
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">{run.stderrExcerpt}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* stdout excerpt when no log is available */}
|
||||||
|
{run.stdoutExcerpt && !run.logRef && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">stdout</span>
|
||||||
|
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">{run.stdoutExcerpt}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cancel button for running */}
|
{/* Cancel button for running */}
|
||||||
{(run.status === "running" || run.status === "queued") && (
|
{(run.status === "running" || run.status === "queued") && (
|
||||||
<Button
|
<Button
|
||||||
@@ -628,23 +916,60 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Log viewer */}
|
{/* Log viewer */}
|
||||||
<LogViewer runId={run.id} status={run.status} />
|
<LogViewer run={run} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Log Viewer ---- */
|
/* ---- Log Viewer ---- */
|
||||||
|
|
||||||
function LogViewer({ runId, status }: { runId: string; status: string }) {
|
function LogViewer({ run }: { run: HeartbeatRun }) {
|
||||||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||||||
|
const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [logLoading, setLogLoading] = useState(!!run.logRef);
|
||||||
|
const [logError, setLogError] = useState<string | null>(null);
|
||||||
|
const [logOffset, setLogOffset] = useState(0);
|
||||||
const logEndRef = useRef<HTMLDivElement>(null);
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
const isLive = status === "running" || status === "queued";
|
const pendingLogLineRef = useRef("");
|
||||||
|
const isLive = run.status === "running" || run.status === "queued";
|
||||||
|
|
||||||
|
function appendLogContent(content: string, finalize = false) {
|
||||||
|
if (!content && !finalize) return;
|
||||||
|
const combined = `${pendingLogLineRef.current}${content}`;
|
||||||
|
const split = combined.split("\n");
|
||||||
|
pendingLogLineRef.current = split.pop() ?? "";
|
||||||
|
if (finalize && pendingLogLineRef.current) {
|
||||||
|
split.push(pendingLogLineRef.current);
|
||||||
|
pendingLogLineRef.current = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = [];
|
||||||
|
for (const line of split) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
|
||||||
|
const stream =
|
||||||
|
raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
|
||||||
|
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
|
||||||
|
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
|
||||||
|
if (!chunk) continue;
|
||||||
|
parsed.push({ ts, stream, chunk });
|
||||||
|
} catch {
|
||||||
|
// ignore malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
setLogLines((prev) => [...prev, ...parsed]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch events
|
// Fetch events
|
||||||
const { data: initialEvents } = useQuery({
|
const { data: initialEvents } = useQuery({
|
||||||
queryKey: ["run-events", runId],
|
queryKey: ["run-events", run.id],
|
||||||
queryFn: () => heartbeatsApi.events(runId, 0, 200),
|
queryFn: () => heartbeatsApi.events(run.id, 0, 200),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -657,7 +982,56 @@ function LogViewer({ runId, status }: { runId: string; status: string }) {
|
|||||||
// Auto-scroll
|
// Auto-scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [events]);
|
}, [events, logLines]);
|
||||||
|
|
||||||
|
// Fetch persisted shell log
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
pendingLogLineRef.current = "";
|
||||||
|
setLogLines([]);
|
||||||
|
setLogOffset(0);
|
||||||
|
setLogError(null);
|
||||||
|
|
||||||
|
if (!run.logRef) {
|
||||||
|
setLogLoading(false);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogLoading(true);
|
||||||
|
const firstLimit =
|
||||||
|
typeof run.logBytes === "number" && run.logBytes > 0
|
||||||
|
? Math.min(Math.max(run.logBytes + 1024, 256_000), 2_000_000)
|
||||||
|
: 256_000;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
let offset = 0;
|
||||||
|
let first = true;
|
||||||
|
while (!cancelled) {
|
||||||
|
const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000);
|
||||||
|
appendLogContent(result.content, result.nextOffset === undefined);
|
||||||
|
const next = result.nextOffset ?? offset + result.content.length;
|
||||||
|
setLogOffset(next);
|
||||||
|
offset = next;
|
||||||
|
first = false;
|
||||||
|
if (result.nextOffset === undefined || isLive) break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLogError(err instanceof Error ? err.message : "Failed to load run log");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLogLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [run.id, run.logRef, run.logBytes, isLive]);
|
||||||
|
|
||||||
// Poll for live updates
|
// Poll for live updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -665,7 +1039,7 @@ function LogViewer({ runId, status }: { runId: string; status: string }) {
|
|||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
|
const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0;
|
||||||
try {
|
try {
|
||||||
const newEvents = await heartbeatsApi.events(runId, maxSeq, 100);
|
const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100);
|
||||||
if (newEvents.length > 0) {
|
if (newEvents.length > 0) {
|
||||||
setEvents((prev) => [...prev, ...newEvents]);
|
setEvents((prev) => [...prev, ...newEvents]);
|
||||||
}
|
}
|
||||||
@@ -674,13 +1048,41 @@ function LogViewer({ runId, status }: { runId: string; status: string }) {
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [runId, isLive, events]);
|
}, [run.id, isLive, events]);
|
||||||
|
|
||||||
if (loading) {
|
// Poll shell log for running runs
|
||||||
return <p className="text-xs text-muted-foreground">Loading events...</p>;
|
useEffect(() => {
|
||||||
|
if (!isLive || !run.logRef) return;
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
|
||||||
|
if (result.content) {
|
||||||
|
appendLogContent(result.content, result.nextOffset === undefined);
|
||||||
|
}
|
||||||
|
if (result.nextOffset !== undefined) {
|
||||||
|
setLogOffset(result.nextOffset);
|
||||||
|
} else if (result.content.length > 0) {
|
||||||
|
setLogOffset((prev) => prev + result.content.length);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore polling errors
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [run.id, run.logRef, isLive, logOffset]);
|
||||||
|
|
||||||
|
const adapterInvokePayload = useMemo(() => {
|
||||||
|
const evt = events.find((e) => e.eventType === "adapter.invoke");
|
||||||
|
return asRecord(evt?.payload ?? null);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const transcript = useMemo(() => buildTranscript(logLines), [logLines]);
|
||||||
|
|
||||||
|
if (loading && logLoading) {
|
||||||
|
return <p className="text-xs text-muted-foreground">Loading run logs...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0 && logLines.length === 0 && !logError) {
|
||||||
return <p className="text-xs text-muted-foreground">No log events.</p>;
|
return <p className="text-xs text-muted-foreground">No log events.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,9 +1099,62 @@ function LogViewer({ runId, status }: { runId: string; status: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{adapterInvokePayload && (
|
||||||
|
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
|
||||||
|
{typeof adapterInvokePayload.adapterType === "string" && (
|
||||||
|
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{adapterInvokePayload.adapterType}</div>
|
||||||
|
)}
|
||||||
|
{typeof adapterInvokePayload.cwd === "string" && (
|
||||||
|
<div className="text-xs"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{adapterInvokePayload.cwd}</span></div>
|
||||||
|
)}
|
||||||
|
{typeof adapterInvokePayload.command === "string" && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">Command: </span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{[
|
||||||
|
adapterInvokePayload.command,
|
||||||
|
...(Array.isArray(adapterInvokePayload.commandArgs)
|
||||||
|
? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string")
|
||||||
|
: []),
|
||||||
|
].join(" ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{adapterInvokePayload.prompt !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
|
||||||
<span className="text-xs font-medium text-muted-foreground">Events ({events.length})</span>
|
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{typeof adapterInvokePayload.prompt === "string"
|
||||||
|
? adapterInvokePayload.prompt
|
||||||
|
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{adapterInvokePayload.context !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Context</div>
|
||||||
|
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(adapterInvokePayload.context, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{adapterInvokePayload.env !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Environment</div>
|
||||||
|
<pre className="bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(adapterInvokePayload.env, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Transcript ({transcript.length})
|
||||||
|
</span>
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<span className="flex items-center gap-1 text-xs text-cyan-400">
|
<span className="flex items-center gap-1 text-xs text-cyan-400">
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
@@ -711,6 +1166,96 @@ function LogViewer({ runId, status }: { runId: string; status: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs max-h-80 overflow-y-auto space-y-0.5">
|
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs max-h-80 overflow-y-auto space-y-0.5">
|
||||||
|
{transcript.length === 0 && !run.logRef && (
|
||||||
|
<div className="text-neutral-500">No persisted transcript for this run.</div>
|
||||||
|
)}
|
||||||
|
{transcript.map((entry, idx) => {
|
||||||
|
const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false });
|
||||||
|
if (entry.kind === "assistant") {
|
||||||
|
return (
|
||||||
|
<div key={`${entry.ts}-assistant-${idx}`} className="space-y-1 py-0.5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-neutral-600 shrink-0 select-none w-16">{time}</span>
|
||||||
|
<span className="shrink-0 w-14 text-green-300">assistant</span>
|
||||||
|
<span className="whitespace-pre-wrap break-all text-green-100">{entry.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "tool_call") {
|
||||||
|
return (
|
||||||
|
<div key={`${entry.ts}-tool-${idx}`} className="space-y-1 py-0.5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-neutral-600 shrink-0 select-none w-16">{time}</span>
|
||||||
|
<span className="shrink-0 w-14 text-yellow-300">tool</span>
|
||||||
|
<span className="text-yellow-100">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="ml-[74px] bg-neutral-900 rounded p-2 text-[11px] overflow-x-auto whitespace-pre-wrap text-neutral-200">
|
||||||
|
{JSON.stringify(entry.input, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "init") {
|
||||||
|
return (
|
||||||
|
<div key={`${entry.ts}-init-${idx}`} className="flex gap-2">
|
||||||
|
<span className="text-neutral-600 shrink-0 select-none w-16">{time}</span>
|
||||||
|
<span className="shrink-0 w-14 text-blue-300">init</span>
|
||||||
|
<span className="text-blue-100">Claude initialized (model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""})</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "result") {
|
||||||
|
return (
|
||||||
|
<div key={`${entry.ts}-result-${idx}`} className="space-y-1 py-0.5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-neutral-600 shrink-0 select-none w-16">{time}</span>
|
||||||
|
<span className="shrink-0 w-14 text-cyan-300">result</span>
|
||||||
|
<span className="text-cyan-100">
|
||||||
|
tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{entry.text && (
|
||||||
|
<div className="ml-[74px] whitespace-pre-wrap break-all text-neutral-100">{entry.text}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = entry.text;
|
||||||
|
const label =
|
||||||
|
entry.kind === "stderr" ? "stderr" :
|
||||||
|
entry.kind === "system" ? "system" :
|
||||||
|
"stdout";
|
||||||
|
const color =
|
||||||
|
entry.kind === "stderr" ? "text-red-300" :
|
||||||
|
entry.kind === "system" ? "text-blue-300" :
|
||||||
|
"text-foreground";
|
||||||
|
return (
|
||||||
|
<div key={`${entry.ts}-raw-${idx}`} className="flex gap-2">
|
||||||
|
<span className="text-neutral-600 shrink-0 select-none w-16">
|
||||||
|
{time}
|
||||||
|
</span>
|
||||||
|
<span className={cn("shrink-0 w-14", color)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className={cn("whitespace-pre-wrap break-all", color)}>
|
||||||
|
{rawText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{logError && <div className="text-red-300">{logError}</div>}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-medium text-muted-foreground">Events ({events.length})</div>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs max-h-56 overflow-y-auto space-y-0.5">
|
||||||
{events.map((evt) => {
|
{events.map((evt) => {
|
||||||
const color = evt.color
|
const color = evt.color
|
||||||
?? (evt.level ? levelColors[evt.level] : null)
|
?? (evt.level ? levelColors[evt.level] : null)
|
||||||
@@ -722,20 +1267,19 @@ function LogViewer({ runId, status }: { runId: string; status: string }) {
|
|||||||
<span className="text-neutral-600 shrink-0 select-none w-16">
|
<span className="text-neutral-600 shrink-0 select-none w-16">
|
||||||
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
|
{new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })}
|
||||||
</span>
|
</span>
|
||||||
{evt.stream && (
|
<span className={cn("shrink-0 w-14", evt.stream ? (streamColors[evt.stream] ?? "text-neutral-500") : "text-neutral-500")}>
|
||||||
<span className={cn("shrink-0 w-12", streamColors[evt.stream] ?? "text-neutral-500")}>
|
{evt.stream ? `[${evt.stream}]` : ""}
|
||||||
[{evt.stream}]
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
<span className={cn("break-all", color)}>
|
<span className={cn("break-all", color)}>
|
||||||
{evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")}
|
{evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div ref={logEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -12,8 +12,9 @@ import { StatusIcon } from "../components/StatusIcon";
|
|||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { CircleDot, Plus } from "lucide-react";
|
import { CircleDot, Plus } from "lucide-react";
|
||||||
import { formatDate } from "../lib/utils";
|
import { formatDate } from "../lib/utils";
|
||||||
import type { Issue } from "@paperclip/shared";
|
import type { Issue } from "@paperclip/shared";
|
||||||
@@ -26,6 +27,18 @@ function statusLabel(status: string): string {
|
|||||||
|
|
||||||
type TabFilter = "all" | "active" | "backlog" | "done";
|
type TabFilter = "all" | "active" | "backlog" | "done";
|
||||||
|
|
||||||
|
const issueTabItems = [
|
||||||
|
{ value: "all", label: "All Issues" },
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "backlog", label: "Backlog" },
|
||||||
|
{ value: "done", label: "Done" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function parseIssueTab(value: string | null): TabFilter {
|
||||||
|
if (value === "active" || value === "backlog" || value === "done") return value;
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
function filterIssues(issues: Issue[], tab: TabFilter): Issue[] {
|
function filterIssues(issues: Issue[], tab: TabFilter): Issue[] {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "active":
|
case "active":
|
||||||
@@ -45,7 +58,8 @@ export function Issues() {
|
|||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<TabFilter>("all");
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tab = parseIssueTab(searchParams.get("tab"));
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -86,16 +100,18 @@ export function Issues() {
|
|||||||
.filter((s) => grouped[s]?.length)
|
.filter((s) => grouped[s]?.length)
|
||||||
.map((s) => ({ status: s, items: grouped[s]! }));
|
.map((s) => ({ status: s, items: grouped[s]! }));
|
||||||
|
|
||||||
|
const setTab = (nextTab: TabFilter) => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (nextTab === "all") next.delete("tab");
|
||||||
|
else next.set("tab", nextTab);
|
||||||
|
setSearchParams(next);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
|
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
|
||||||
<TabsList variant="line">
|
<PageTabBar items={[...issueTabItems]} />
|
||||||
<TabsTrigger value="all">All Issues</TabsTrigger>
|
|
||||||
<TabsTrigger value="active">Active</TabsTrigger>
|
|
||||||
<TabsTrigger value="backlog">Backlog</TabsTrigger>
|
|
||||||
<TabsTrigger value="done">Done</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button size="sm" onClick={() => openNewIssue()}>
|
<Button size="sm" onClick={() => openNewIssue()}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
|||||||