diff --git a/ui/index.html b/ui/index.html index 8b5d8752..8f0a47e3 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,7 +3,14 @@ + Paperclip + + + + + +
diff --git a/ui/public/android-chrome-192x192.png b/ui/public/android-chrome-192x192.png new file mode 100644 index 00000000..32120f8a Binary files /dev/null and b/ui/public/android-chrome-192x192.png differ diff --git a/ui/public/android-chrome-512x512.png b/ui/public/android-chrome-512x512.png new file mode 100644 index 00000000..a7493ed4 Binary files /dev/null and b/ui/public/android-chrome-512x512.png differ diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png new file mode 100644 index 00000000..0104f97e Binary files /dev/null and b/ui/public/apple-touch-icon.png differ diff --git a/ui/public/favicon-16x16.png b/ui/public/favicon-16x16.png new file mode 100644 index 00000000..a8884bff Binary files /dev/null and b/ui/public/favicon-16x16.png differ diff --git a/ui/public/favicon-32x32.png b/ui/public/favicon-32x32.png new file mode 100644 index 00000000..e0b26d57 Binary files /dev/null and b/ui/public/favicon-32x32.png differ diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 00000000..0b205ec2 Binary files /dev/null and b/ui/public/favicon.ico differ diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 00000000..c3b25eff --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/ui/public/site.webmanifest b/ui/public/site.webmanifest new file mode 100644 index 00000000..8861bd64 --- /dev/null +++ b/ui/public/site.webmanifest @@ -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" +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f0876ccc..01b26557 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -27,6 +27,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 0f94586f..ee941215 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -37,6 +37,8 @@ export interface CreateConfigValues { dangerouslyBypassSandbox: boolean; command: string; args: string; + extraArgs: string; + envVars: string; url: string; bootstrapPrompt: string; maxTurnsPerRun: number; @@ -54,6 +56,8 @@ export const defaultCreateValues: CreateConfigValues = { dangerouslyBypassSandbox: false, command: "", args: "", + extraArgs: "", + envVars: "", url: "", bootstrapPrompt: "", maxTurnsPerRun: 80, @@ -65,6 +69,10 @@ export const defaultCreateValues: CreateConfigValues = { type AgentConfigFormProps = { adapterModels?: AdapterModel[]; + onDirtyChange?: (dirty: boolean) => void; + onSaveActionChange?: (save: (() => void) | null) => void; + onCancelActionChange?: (cancel: (() => void) | null) => void; + hideInlineSave?: boolean; } & ( | { mode: "create"; @@ -110,6 +118,51 @@ function isOverlayDirty(o: Overlay): boolean { 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"; +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 { + const env: Record = {}; + 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) + .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 ---- */ export function AgentConfigForm(props: AgentConfigFormProps) { @@ -175,6 +228,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) { 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 ---- const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; @@ -195,6 +262,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // Section toggle state — advanced always starts collapsed const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false); const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate); + const [cwdPickerNotice, setCwdPickerNotice] = useState(null); // Popover states const [modelOpen, setModelOpen] = useState(false); @@ -213,7 +281,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { return (
{/* ---- Floating Save button (edit mode, when dirty) ---- */} - {isDirty && ( + {isDirty && !props.hideInlineSave && (
Unsaved changes @@ -237,6 +305,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { mark("identity", "name", v)} + immediate className={inputClass} placeholder="Agent name" /> @@ -245,6 +314,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { mark("identity", "title", v || null)} + immediate className={inputClass} placeholder="e.g. VP of Engineering" /> @@ -253,6 +323,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { mark("identity", "capabilities", v || null)} + immediate placeholder="Describe what this agent can do..." minRows={2} /> @@ -303,7 +374,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? set!({ cwd: v }) : mark("adapterConfig", "cwd", v || undefined) } - immediate={isCreate} + immediate className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40" 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" onClick={async () => { try { + setCwdPickerNotice(null); // @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet const handle = await window.showDirectoryPicker({ mode: "read" }); - if (isCreate) set!({ cwd: handle.name }); - else mark("adapterConfig", "cwd", handle.name); + const absolutePath = extractPickedDirectoryPath(handle); + 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 { // user cancelled or API unsupported } @@ -324,6 +409,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { Choose
+ {cwdPickerNotice && ( +

{cwdPickerNotice}

+ )} )} @@ -347,6 +435,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onCommit={(v) => mark("adapterConfig", "promptTemplate", v || undefined) } + immediate placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." minRows={4} /> @@ -429,7 +518,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? set!({ command: v }) : mark("adapterConfig", "command", v || undefined) } - immediate={isCreate} + immediate className={inputClass} placeholder="e.g. node, python" /> @@ -439,7 +528,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { value={ isCreate ? val!.args - : eff("adapterConfig", "args", String(config.args ?? "")) + : eff("adapterConfig", "args", formatArgList(config.args)) } onCommit={(v) => isCreate @@ -447,15 +536,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : mark( "adapterConfig", "args", - v - ? v - .split(",") - .map((a) => a.trim()) - .filter(Boolean) - : undefined, + v ? parseCommaArgs(v) : undefined, ) } - immediate={isCreate} + immediate className={inputClass} placeholder="e.g. script.js, --flag" /> @@ -477,7 +561,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? set!({ url: v }) : mark("adapterConfig", "url", v || undefined) } - immediate={isCreate} + immediate className={inputClass} placeholder="https://..." /> @@ -492,6 +576,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)} >
+ + + isCreate + ? set!({ command: v }) + : mark("adapterConfig", "command", v || undefined) + } + immediate + className={inputClass} + placeholder={adapterType === "codex_local" ? "codex" : "claude"} + /> + + mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) } + immediate placeholder="Optional initial setup prompt for the first run" minRows={2} /> @@ -543,12 +646,57 @@ export function AgentConfigForm(props: AgentConfigFormProps) { Number(config.maxTurnsPerRun ?? 80), )} onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)} + immediate className={inputClass} /> )} )} + + + isCreate + ? set!({ extraArgs: v }) + : mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined) + } + immediate + className={inputClass} + placeholder="e.g. --verbose, --foo=bar" + /> + + + + {isCreate ? ( + set!({ envVars: v })} + minRows={3} + /> + ) : ( + { + 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} + /> + )} + + {/* Edit-only: timeout + grace period */} {!isCreate && ( <> @@ -560,6 +708,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { Number(config.timeoutSec ?? 0), )} onCommit={(v) => mark("adapterConfig", "timeoutSec", v)} + immediate className={inputClass} /> @@ -571,6 +720,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { Number(config.graceSec ?? 15), )} onCommit={(v) => mark("adapterConfig", "graceSec", v)} + immediate className={inputClass} /> @@ -669,6 +819,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { Number(heartbeat.cooldownSec ?? 10), )} onCommit={(v) => mark("heartbeat", "cooldownSec", v)} + immediate className={inputClass} /> @@ -695,6 +846,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { props.agent.budgetMonthlyCents, )} onCommit={(v) => mark("runtime", "budgetMonthlyCents", v)} + immediate className={inputClass} /> diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 23a23b60..5cce8abc 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -30,6 +30,28 @@ import { type CreateConfigValues, } from "./AgentConfigForm"; +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + 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() { const { newAgentOpen, closeNewAgent } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); @@ -102,16 +124,22 @@ export function NewAgentDialog() { if (v.model) ac.model = v.model; ac.timeoutSec = 0; ac.graceSec = 15; + const env = parseEnvVars(v.envVars); + if (Object.keys(env).length > 0) ac.env = env; if (v.adapterType === "claude_local") { ac.maxTurnsPerRun = v.maxTurnsPerRun; 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") { ac.search = v.search; ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox; + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); } else if (v.adapterType === "process") { 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") { if (v.url) ac.url = v.url; } diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 4ab5f953..91015774 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -58,6 +58,7 @@ export function OnboardingWizard() { const [command, setCommand] = useState(""); const [args, setArgs] = useState(""); const [url, setUrl] = useState(""); + const [cwdPickerNotice, setCwdPickerNotice] = useState(null); // Step 3 const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md"); @@ -88,6 +89,7 @@ export function OnboardingWizard() { setCommand(""); setArgs(""); setUrl(""); + setCwdPickerNotice(null); 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."); 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" onClick={async () => { try { + setCwdPickerNotice(null); // @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet 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 { // user cancelled or API unsupported } @@ -417,6 +438,9 @@ export function OnboardingWizard() { Choose
+ {cwdPickerNotice && ( +

{cwdPickerNotice}

+ )}
+ - - {agent.status === "active" || agent.status === "running" ? ( - - ) : ( + {agent.status === "paused" ? ( + ) : ( + )} @@ -247,15 +439,43 @@ export function AgentDetail() { {actionError &&

{actionError}

} - - - Overview - Configuration - Runs{heartbeats ? ` (${heartbeats.length})` : ""} - Issues ({assignedIssues.length}) - Costs - API Keys - + +
+ +
+ + +
+
{/* OVERVIEW TAB */} @@ -354,12 +574,18 @@ export function AgentDetail() { {/* CONFIGURATION TAB */} - + {/* RUNS TAB */} - + {/* ISSUES TAB */} @@ -408,7 +634,19 @@ function SummaryRow({ label, children }: { label: string; children: React.ReactN /* ---- 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 { data: adapterModels } = useQuery({ @@ -423,6 +661,10 @@ function ConfigurationTab({ agent }: { agent: Agent }) { }, }); + useEffect(() => { + onSavingChange(updateAgent.isPending); + }, [onSavingChange, updateAgent.isPending]); + return (
updateAgent.mutate(patch)} isSaving={updateAgent.isPending} adapterModels={adapterModels} + onDirtyChange={onDirtyChange} + onSaveActionChange={onSaveActionChange} + onCancelActionChange={onCancelActionChange} + hideInlineSave />
); @@ -438,8 +684,8 @@ function ConfigurationTab({ agent }: { agent: Agent }) { /* ---- Runs Tab ---- */ -function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string }) { - const [expandedRunId, setExpandedRunId] = useState(null); +function RunsTab({ runs, companyId, agentId, selectedRunId }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null }) { + const navigate = useNavigate(); if (runs.length === 0) { return

No runs yet.

; @@ -450,61 +696,75 @@ function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); - return ( -
- {sorted.map((run) => { - const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; - const StatusIcon = statusInfo.icon; - const isExpanded = expandedRunId === run.id; - const usage = run.usageJson as Record | null; - 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 - ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") - : run.error ?? ""; + // 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 ( +
+ {/* Left: run list */} +
+ {sorted.map((run) => { + const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; + const StatusIcon = statusInfo.icon; + const isSelected = run.id === effectiveRunId; + const metrics = runMetrics(run); + const summary = run.resultJson + ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") + : run.error ?? ""; + + return ( + ); + })} +
- {isExpanded && } -
- ); - })} + {/* Right: run detail */} + {selectedRun && ( +
+ +
+ )}
); } @@ -513,7 +773,7 @@ function RunsTab({ runs, companyId }: { runs: HeartbeatRun[]; companyId: string function RunDetail({ run }: { run: HeartbeatRun }) { const queryClient = useQueryClient(); - const usage = run.usageJson as Record | null; + const metrics = runMetrics(run); const cancelRun = useMutation({ mutationFn: () => heartbeatsApi.cancel(run.id), @@ -523,9 +783,9 @@ function RunDetail({ run }: { run: HeartbeatRun }) { }); return ( -
+
{/* Status timeline */} -
+
Status: @@ -551,26 +811,26 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
{/* Token breakdown */} - {usage && ( + {(metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0) && (
Input: - {formatTokens(Number(usage.input_tokens ?? 0))} + {formatTokens(metrics.input)}
Output: - {formatTokens(Number(usage.output_tokens ?? 0))} + {formatTokens(metrics.output)}
- {Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0) > 0 && ( + {metrics.cached > 0 && (
Cached: - {formatTokens(Number(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? 0))} + {formatTokens(metrics.cached)}
)} - {Number(usage.cost_usd ?? usage.total_cost_usd ?? 0) > 0 && ( + {metrics.cost > 0 && (
Cost: - ${Number(usage.cost_usd ?? usage.total_cost_usd ?? 0).toFixed(4)} + ${metrics.cost.toFixed(4)}
)}
@@ -582,13 +842,25 @@ function RunDetail({ run }: { run: HeartbeatRun }) { {run.sessionIdBefore && (
Session before: - {run.sessionIdBefore.slice(0, 16)}... +
)} {run.sessionIdAfter && (
Session after: - {run.sessionIdAfter.slice(0, 16)}... +
)}
@@ -612,6 +884,22 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
)} + {/* stderr excerpt for failed runs */} + {run.stderrExcerpt && ( +
+ stderr +
{run.stderrExcerpt}
+
+ )} + + {/* stdout excerpt when no log is available */} + {run.stdoutExcerpt && !run.logRef && ( +
+ stdout +
{run.stdoutExcerpt}
+
+ )} + {/* Cancel button for running */} {(run.status === "running" || run.status === "queued") && (
); } /* ---- Log Viewer ---- */ -function LogViewer({ runId, status }: { runId: string; status: string }) { +function LogViewer({ run }: { run: HeartbeatRun }) { const [events, setEvents] = useState([]); + const [logLines, setLogLines] = useState>([]); const [loading, setLoading] = useState(true); + const [logLoading, setLogLoading] = useState(!!run.logRef); + const [logError, setLogError] = useState(null); + const [logOffset, setLogOffset] = useState(0); const logEndRef = useRef(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 const { data: initialEvents } = useQuery({ - queryKey: ["run-events", runId], - queryFn: () => heartbeatsApi.events(runId, 0, 200), + queryKey: ["run-events", run.id], + queryFn: () => heartbeatsApi.events(run.id, 0, 200), }); useEffect(() => { @@ -657,7 +982,56 @@ function LogViewer({ runId, status }: { runId: string; status: string }) { // Auto-scroll useEffect(() => { 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 useEffect(() => { @@ -665,7 +1039,7 @@ function LogViewer({ runId, status }: { runId: string; status: string }) { const interval = setInterval(async () => { const maxSeq = events.length > 0 ? Math.max(...events.map((e) => e.seq)) : 0; try { - const newEvents = await heartbeatsApi.events(runId, maxSeq, 100); + const newEvents = await heartbeatsApi.events(run.id, maxSeq, 100); if (newEvents.length > 0) { setEvents((prev) => [...prev, ...newEvents]); } @@ -674,13 +1048,41 @@ function LogViewer({ runId, status }: { runId: string; status: string }) { } }, 2000); return () => clearInterval(interval); - }, [runId, isLive, events]); + }, [run.id, isLive, events]); - if (loading) { - return

Loading events...

; + // Poll shell log for running runs + 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

Loading run logs...

; } - if (events.length === 0) { + if (events.length === 0 && logLines.length === 0 && !logError) { return

No log events.

; } @@ -697,9 +1099,62 @@ function LogViewer({ runId, status }: { runId: string; status: string }) { }; return ( -
-
- Events ({events.length}) +
+ {adapterInvokePayload && ( +
+
Invocation
+ {typeof adapterInvokePayload.adapterType === "string" && ( +
Adapter: {adapterInvokePayload.adapterType}
+ )} + {typeof adapterInvokePayload.cwd === "string" && ( +
Working dir: {adapterInvokePayload.cwd}
+ )} + {typeof adapterInvokePayload.command === "string" && ( +
+ Command: + + {[ + adapterInvokePayload.command, + ...(Array.isArray(adapterInvokePayload.commandArgs) + ? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string") + : []), + ].join(" ")} + +
+ )} + {adapterInvokePayload.prompt !== undefined && ( +
+
Prompt
+
+                {typeof adapterInvokePayload.prompt === "string"
+                  ? adapterInvokePayload.prompt
+                  : JSON.stringify(adapterInvokePayload.prompt, null, 2)}
+              
+
+ )} + {adapterInvokePayload.context !== undefined && ( +
+
Context
+
+                {JSON.stringify(adapterInvokePayload.context, null, 2)}
+              
+
+ )} + {adapterInvokePayload.env !== undefined && ( +
+
Environment
+
+                {JSON.stringify(adapterInvokePayload.env, null, 2)}
+              
+
+ )} +
+ )} + +
+ + Transcript ({transcript.length}) + {isLive && ( @@ -711,30 +1166,119 @@ function LogViewer({ runId, status }: { runId: string; status: string }) { )}
- {events.map((evt) => { - const color = evt.color - ?? (evt.level ? levelColors[evt.level] : null) - ?? (evt.stream ? streamColors[evt.stream] : null) - ?? "text-foreground"; + {transcript.length === 0 && !run.logRef && ( +
No persisted transcript for this run.
+ )} + {transcript.map((entry, idx) => { + const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false }); + if (entry.kind === "assistant") { + return ( +
+
+ {time} + assistant + {entry.text} +
+
+ ); + } + if (entry.kind === "tool_call") { + return ( +
+
+ {time} + tool + {entry.name} +
+
+                  {JSON.stringify(entry.input, null, 2)}
+                
+
+ ); + } + + if (entry.kind === "init") { + return ( +
+ {time} + init + Claude initialized (model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}) +
+ ); + } + + if (entry.kind === "result") { + return ( +
+
+ {time} + result + + tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)} + +
+ {entry.text && ( +
{entry.text}
+ )} +
+ ); + } + + 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 ( -
+
- {new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })} + {time} - {evt.stream && ( - - [{evt.stream}] - - )} - - {evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")} + + {label} + + + {rawText}
- ); + ) })} + {logError &&
{logError}
}
+ + {events.length > 0 && ( +
+
Events ({events.length})
+
+ {events.map((evt) => { + const color = evt.color + ?? (evt.level ? levelColors[evt.level] : null) + ?? (evt.stream ? streamColors[evt.stream] : null) + ?? "text-foreground"; + + return ( +
+ + {new Date(evt.createdAt).toLocaleTimeString("en-US", { hour12: false })} + + + {evt.stream ? `[${evt.stream}]` : ""} + + + {evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")} + +
+ ); + })} +
+
+ )}
); } diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index ea7b9f3e..2dec2246 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -12,8 +12,9 @@ import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; +import { PageTabBar } from "../components/PageTabBar"; 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 { formatDate } from "../lib/utils"; import type { Issue } from "@paperclip/shared"; @@ -26,6 +27,18 @@ function statusLabel(status: string): string { 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[] { switch (tab) { case "active": @@ -45,7 +58,8 @@ export function Issues() { const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const [tab, setTab] = useState("all"); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = parseIssueTab(searchParams.get("tab")); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -86,16 +100,18 @@ export function Issues() { .filter((s) => grouped[s]?.length) .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 (
setTab(v as TabFilter)}> - - All Issues - Active - Backlog - Done - +