feat(ui): active agents panel, sidebar context, and page enhancements

Add live ActiveAgentsPanel with real-time transcript feed, SidebarContext
for responsive sidebar state, agent config form with reasoning effort,
improved inbox with failed run alerts, enriched issue detail with project
picker, and various component refinements across pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 10:32:32 -06:00
parent b327687c92
commit adca44849a
29 changed files with 1461 additions and 146 deletions

View File

@@ -9,6 +9,8 @@ export interface RunForIssue {
finishedAt: string | null; finishedAt: string | null;
createdAt: string; createdAt: string;
invocationSource: string; invocationSource: string;
usageJson: Record<string, unknown> | null;
resultJson: Record<string, unknown> | null;
} }
export interface IssueForRun { export interface IssueForRun {

View File

@@ -38,4 +38,6 @@ export const heartbeatsApi = {
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`), api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
activeRunForIssue: (issueId: string) => activeRunForIssue: (issueId: string) =>
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`), api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (companyId: string) =>
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs`),
}; };

View File

@@ -0,0 +1,402 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclip/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { getUIAdapter } from "../adapters";
import type { TranscriptEntry } from "../adapters";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { ExternalLink } from "lucide-react";
import { Identity } from "./Identity";
type FeedTone = "info" | "warn" | "error" | "assistant" | "tool";
interface FeedItem {
id: string;
ts: string;
runId: string;
agentId: string;
agentName: string;
text: string;
tone: FeedTone;
}
const MAX_FEED_ITEMS = 40;
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null {
if (entry.kind === "assistant") {
const text = entry.text.trim();
return text ? { text, tone: "assistant" } : null;
}
if (entry.kind === "thinking") {
const text = entry.text.trim();
return text ? { text: `[thinking] ${text}`, tone: "info" } : null;
}
if (entry.kind === "tool_call") {
return { text: `tool ${entry.name}`, tone: "tool" };
}
if (entry.kind === "tool_result") {
const base = entry.content.trim();
return {
text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`,
tone: entry.isError ? "error" : "tool",
};
}
if (entry.kind === "stderr") {
const text = entry.text.trim();
return text ? { text, tone: "error" } : null;
}
if (entry.kind === "system") {
const text = entry.text.trim();
return text ? { text, tone: "warn" } : null;
}
if (entry.kind === "stdout") {
const text = entry.text.trim();
return text ? { text, tone: "info" } : null;
}
return null;
}
function createFeedItem(
run: LiveRunForIssue,
ts: string,
text: string,
tone: FeedTone,
nextId: number,
): FeedItem | null {
const trimmed = text.trim();
if (!trimmed) return null;
return {
id: `${run.id}:${nextId}`,
ts,
runId: run.id,
agentId: run.agentId,
agentName: run.agentName,
text: trimmed.slice(0, 220),
tone,
};
}
function parseStdoutChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stdout`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const adapter = getUIAdapter(run.adapterType);
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = adapter.parseStdoutLine(trimmed, ts);
if (parsed.length === 0) {
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
if (fallback) items.push(fallback);
continue;
}
for (const entry of parsed) {
const summary = summarizeEntry(entry);
if (!summary) continue;
const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++);
if (item) items.push(item);
}
}
return items;
}
function parseStderrChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stderr`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const item = createFeedItem(run, ts, line, "error", nextIdRef.current++);
if (item) items.push(item);
}
return items;
}
interface ActiveAgentsPanelProps {
companyId: string;
}
interface AgentRunGroup {
agentId: string;
agentName: string;
runs: LiveRunForIssue[];
}
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
const [feedByAgent, setFeedByAgent] = useState<Map<string, FeedItem[]>>(new Map());
const seenKeysRef = useRef(new Set<string>());
const pendingByRunRef = useRef(new Map<string, string>());
const nextIdRef = useRef(1);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(companyId),
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
refetchInterval: 5000,
});
const runs = liveRuns ?? [];
const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]);
const activeRunIds = useMemo(() => new Set(runs.map((r) => r.id)), [runs]);
const agentGroups = useMemo(() => {
const map = new Map<string, AgentRunGroup>();
for (const run of runs) {
let group = map.get(run.agentId);
if (!group) {
group = { agentId: run.agentId, agentName: run.agentName, runs: [] };
map.set(run.agentId, group);
}
group.runs.push(run);
}
return Array.from(map.values());
}, [runs]);
// Clean up pending buffers for runs that ended
useEffect(() => {
const stillActive = new Set<string>();
for (const runId of activeRunIds) {
stillActive.add(`${runId}:stdout`);
stillActive.add(`${runId}:stderr`);
}
for (const key of pendingByRunRef.current.keys()) {
if (!stillActive.has(key)) {
pendingByRunRef.current.delete(key);
}
}
}, [activeRunIds]);
// WebSocket connection for streaming
useEffect(() => {
if (activeRunIds.size === 0) return;
let closed = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const appendItems = (agentId: string, items: FeedItem[]) => {
if (items.length === 0) return;
setFeedByAgent((prev) => {
const next = new Map(prev);
const existing = next.get(agentId) ?? [];
next.set(agentId, [...existing, ...items].slice(-MAX_FEED_ITEMS));
return next;
});
};
const scheduleReconnect = () => {
if (closed) return;
reconnectTimer = window.setTimeout(connect, 1500);
};
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
socket = new WebSocket(url);
socket.onmessage = (message) => {
const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return;
let event: LiveEvent;
try {
event = JSON.parse(raw) as LiveEvent;
} catch {
return;
}
if (event.companyId !== companyId) return;
const payload = event.payload ?? {};
const runId = readString(payload["runId"]);
if (!runId || !activeRunIds.has(runId)) return;
const run = runById.get(runId);
if (!run) return;
if (event.type === "heartbeat.run.event") {
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
const eventType = readString(payload["eventType"]) ?? "event";
const messageText = readString(payload["message"]) ?? eventType;
const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 2000) seenKeysRef.current.clear();
const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info";
const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++);
if (item) appendItems(run.agentId, [item]);
return;
}
if (event.type === "heartbeat.run.status") {
const status = readString(payload["status"]) ?? "updated";
const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 2000) seenKeysRef.current.clear();
const tone = status === "failed" || status === "timed_out" ? "error" : "warn";
const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++);
if (item) appendItems(run.agentId, [item]);
return;
}
if (event.type === "heartbeat.run.log") {
const chunk = readString(payload["chunk"]);
if (!chunk) return;
const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout";
if (stream === "stderr") {
appendItems(run.agentId, parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
return;
}
appendItems(run.agentId, parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
}
};
socket.onerror = () => {
socket?.close();
};
socket.onclose = () => {
scheduleReconnect();
};
};
connect();
return () => {
closed = true;
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
if (socket) {
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "active_agents_panel_unmount");
}
};
}, [activeRunIds, companyId, runById]);
if (agentGroups.length === 0) return null;
return (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Active Agents
</h3>
<div className="grid md:grid-cols-2 gap-4">
{agentGroups.map((group) => (
<AgentRunCard
key={group.agentId}
group={group}
feed={feedByAgent.get(group.agentId) ?? []}
/>
))}
</div>
</div>
);
}
function AgentRunCard({ group, feed }: { group: AgentRunGroup; feed: FeedItem[] }) {
const bodyRef = useRef<HTMLDivElement>(null);
const recent = feed.slice(-20);
const primaryRun = group.runs[0];
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
body.scrollTo({ top: body.scrollHeight, behavior: "smooth" });
}, [feed.length]);
return (
<div className="rounded-lg border border-blue-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(59,130,246,0.08)]">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<Identity name={group.agentName} size="sm" />
<span className="text-[11px] font-medium text-blue-400">Live</span>
{group.runs.length > 1 && (
<span className="text-[10px] text-muted-foreground">
({group.runs.length} runs)
</span>
)}
</div>
{primaryRun && (
<Link
to={`/agents/${primaryRun.agentId}/runs/${primaryRun.id}`}
className="inline-flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300"
>
Open run
<ExternalLink className="h-2.5 w-2.5" />
</Link>
)}
</div>
<div ref={bodyRef} className="max-h-[180px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
{recent.length === 0 && (
<div className="text-xs text-muted-foreground">Waiting for output...</div>
)}
{recent.map((item, index) => (
<div
key={item.id}
className={cn(
"flex gap-2 items-start",
index === recent.length - 1 && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)}
>
<span className="text-[10px] text-muted-foreground shrink-0">{relativeTime(item.ts)}</span>
<span className={cn(
"min-w-0 break-words",
item.tone === "error" && "text-red-300",
item.tone === "warn" && "text-amber-300",
item.tone === "assistant" && "text-emerald-200",
item.tone === "tool" && "text-cyan-300",
item.tone === "info" && "text-foreground/80",
)}>
{item.text}
</span>
</div>
))}
</div>
{group.runs.length > 1 && (
<div className="border-t border-border/50 px-3 py-1.5 flex flex-wrap gap-2">
{group.runs.map((run) => (
<Link
key={run.id}
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300"
>
{run.id.slice(0, 8)}
<ExternalLink className="h-2.5 w-2.5" />
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -42,6 +42,7 @@ export const defaultCreateValues: CreateConfigValues = {
cwd: "", cwd: "",
promptTemplate: "", promptTemplate: "",
model: "", model: "",
thinkingEffort: "",
dangerouslySkipPermissions: false, dangerouslySkipPermissions: false,
search: false, search: false,
dangerouslyBypassSandbox: false, dangerouslyBypassSandbox: false,
@@ -126,6 +127,21 @@ function formatArgList(value: unknown): string {
return typeof value === "string" ? value : ""; return typeof value === "string" ? value : "";
} }
const codexThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
] as const;
const claudeThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
] as const;
function extractPickedDirectoryPath(handle: unknown): string | null { function extractPickedDirectoryPath(handle: unknown): string | null {
if (typeof handle !== "object" || handle === null) return null; if (typeof handle !== "object" || handle === null) return null;
@@ -269,6 +285,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Popover states // Popover states
const [modelOpen, setModelOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false);
const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false);
// Create mode helpers // Create mode helpers
const val = isCreate ? props.values : null; const val = isCreate ? props.values : null;
@@ -281,6 +298,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? val!.model ? val!.model
: eff("adapterConfig", "model", String(config.model ?? "")); : eff("adapterConfig", "model", String(config.model ?? ""));
const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : "effort";
const thinkingEffortOptions =
adapterType === "codex_local" ? codexThinkingEffortOptions : claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: adapterType === "codex_local"
? eff(
"adapterConfig",
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
)
: eff("adapterConfig", "effort", String(config.effort ?? ""));
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
return ( return (
<div className="relative"> <div className="relative">
{/* ---- Floating Save button (edit mode, when dirty) ---- */} {/* ---- Floating Save button (edit mode, when dirty) ---- */}
@@ -342,7 +375,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
value={adapterType} value={adapterType}
onChange={(t) => { onChange={(t) => {
if (isCreate) { if (isCreate) {
set!({ adapterType: t }); set!({ adapterType: t, model: "", thinkingEffort: "" });
} else { } else {
setOverlay((prev) => ({ setOverlay((prev) => ({
...prev, ...prev,
@@ -486,6 +519,25 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
open={modelOpen} open={modelOpen}
onOpenChange={setModelOpen} onOpenChange={setModelOpen}
/> />
<ThinkingEffortDropdown
value={currentThinkingEffort}
options={thinkingEffortOptions}
onChange={(v) =>
isCreate
? set!({ thinkingEffort: v })
: mark("adapterConfig", thinkingEffortKey, v || undefined)
}
open={thinkingEffortOpen}
onOpenChange={setThinkingEffortOpen}
/>
{adapterType === "codex_local" &&
codexSearchEnabled &&
currentThinkingEffort === "minimal" && (
<p className="text-xs text-amber-400">
Codex may reject `minimal` thinking when search is enabled.
</p>
)}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}> <Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
{isCreate ? ( {isCreate ? (
<AutoExpandTextarea <AutoExpandTextarea
@@ -985,11 +1037,23 @@ function ModelDropdown({
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const [modelSearch, setModelSearch] = useState("");
const selected = models.find((m) => m.id === value); const selected = models.find((m) => m.id === value);
const filteredModels = models.filter((m) => {
if (!modelSearch.trim()) return true;
const q = modelSearch.toLowerCase();
return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q);
});
return ( return (
<Field label="Model" hint={help.model}> <Field label="Model" hint={help.model}>
<Popover open={open} onOpenChange={onOpenChange}> <Popover
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between"> <button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}> <span className={cn(!value && "text-muted-foreground")}>
@@ -999,6 +1063,13 @@ function ModelDropdown({
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start"> <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
<button <button
className={cn( className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50", "flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
@@ -1011,7 +1082,7 @@ function ModelDropdown({
> >
Default Default
</button> </button>
{models.map((m) => ( {filteredModels.map((m) => (
<button <button
key={m.id} key={m.id}
className={cn( className={cn(
@@ -1027,6 +1098,56 @@ function ModelDropdown({
<span className="text-xs text-muted-foreground font-mono">{m.id}</span> <span className="text-xs text-muted-foreground font-mono">{m.id}</span>
</button> </button>
))} ))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
)}
</PopoverContent>
</Popover>
</Field>
);
}
function ThinkingEffortDropdown({
value,
options,
onChange,
open,
onOpenChange,
}: {
value: string;
options: ReadonlyArray<{ id: string; label: string }>;
onChange: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const selected = options.find((option) => option.id === value) ?? options[0];
return (
<Field label="Thinking effort" hint={help.thinkingEffort}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>{selected?.label ?? "Auto"}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
{options.map((option) => (
<button
key={option.id || "auto"}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
option.id === value && "bg-accent",
)}
onClick={() => {
onChange(option.id);
onOpenChange(false);
}}
>
<span>{option.label}</span>
{option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : null}
</button>
))}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</Field> </Field>

View File

@@ -16,7 +16,7 @@ function PayloadField({ label, value }: { label: string; value: unknown }) {
if (!value) return null; if (!value) return null;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span> <span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">{label}</span>
<span>{String(value)}</span> <span>{String(value)}</span>
</div> </div>
); );
@@ -26,20 +26,20 @@ export function HireAgentPayload({ payload }: { payload: Record<string, unknown>
return ( return (
<div className="mt-3 space-y-1.5 text-sm"> <div className="mt-3 space-y-1.5 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span> <span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">Name</span>
<span className="font-medium">{String(payload.name ?? "—")}</span> <span className="font-medium">{String(payload.name ?? "—")}</span>
</div> </div>
<PayloadField label="Role" value={payload.role} /> <PayloadField label="Role" value={payload.role} />
<PayloadField label="Title" value={payload.title} /> <PayloadField label="Title" value={payload.title} />
{!!payload.capabilities && ( {!!payload.capabilities && (
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span> <span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
<span className="text-muted-foreground">{String(payload.capabilities)}</span> <span className="text-muted-foreground">{String(payload.capabilities)}</span>
</div> </div>
)} )}
{!!payload.adapterType && ( {!!payload.adapterType && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span> <span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">Adapter</span>
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded"> <span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{String(payload.adapterType)} {String(payload.adapterType)}
</span> </span>

View File

@@ -1,5 +1,8 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Menu } from "lucide-react";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { Button } from "@/components/ui/button";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -12,13 +15,26 @@ import { Fragment } from "react";
export function BreadcrumbBar() { export function BreadcrumbBar() {
const { breadcrumbs } = useBreadcrumbs(); const { breadcrumbs } = useBreadcrumbs();
const { toggleSidebar, isMobile } = useSidebar();
if (breadcrumbs.length === 0) return null; if (breadcrumbs.length === 0) return null;
const menuButton = isMobile && (
<Button
variant="ghost"
size="icon-sm"
className="mr-2 shrink-0"
onClick={toggleSidebar}
>
<Menu className="h-5 w-5" />
</Button>
);
// Single breadcrumb = page title (uppercase) // Single breadcrumb = page title (uppercase)
if (breadcrumbs.length === 1) { if (breadcrumbs.length === 1) {
return ( return (
<div className="border-b border-border px-6 py-4"> <div className="border-b border-border px-4 md:px-6 py-4 flex items-center">
{menuButton}
<h1 className="text-sm font-semibold uppercase tracking-wider"> <h1 className="text-sm font-semibold uppercase tracking-wider">
{breadcrumbs[0].label} {breadcrumbs[0].label}
</h1> </h1>
@@ -28,7 +44,8 @@ export function BreadcrumbBar() {
// Multiple breadcrumbs = breadcrumb trail // Multiple breadcrumbs = breadcrumb trail
return ( return (
<div className="border-b border-border px-6 py-3"> <div className="border-b border-border px-4 md:px-6 py-3 flex items-center">
{menuButton}
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
{breadcrumbs.map((crumb, i) => { {breadcrumbs.map((crumb, i) => {

View File

@@ -27,7 +27,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
className={cn( className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50", "flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
)} )}
style={{ paddingLeft: `${depth * 20 + 12}px` }} style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => onSelect?.(goal)} onClick={() => onSelect?.(goal)}
> >
{hasChildren ? ( {hasChildren ? (

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Issue } from "@paperclip/shared"; import type { Issue } from "@paperclip/shared";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -8,9 +9,11 @@ import { queryKeys } from "../lib/queryKeys";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon"; import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { formatDate } from "../lib/utils"; import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight } from "lucide-react";
interface IssuePropertiesProps { interface IssuePropertiesProps {
issue: Issue; issue: Issue;
@@ -26,16 +29,12 @@ function PropertyRow({ label, children }: { label: string; children: React.React
); );
} }
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function priorityLabel(priority: string): string {
return priority.charAt(0).toUpperCase() + priority.slice(1);
}
export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -46,7 +45,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
const { data: projects } = useQuery({ const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!), queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && !!issue.projectId, enabled: !!selectedCompanyId,
}); });
const agentName = (id: string | null) => { const agentName = (id: string | null) => {
@@ -72,41 +71,142 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
<StatusIcon <StatusIcon
status={issue.status} status={issue.status}
onChange={(status) => onUpdate({ status })} onChange={(status) => onUpdate({ status })}
showLabel
/> />
<span className="text-sm">{statusLabel(issue.status)}</span>
</PropertyRow> </PropertyRow>
<PropertyRow label="Priority"> <PropertyRow label="Priority">
<PriorityIcon <PriorityIcon
priority={issue.priority} priority={issue.priority}
onChange={(priority) => onUpdate({ priority })} onChange={(priority) => onUpdate({ priority })}
showLabel
/> />
<span className="text-sm">{priorityLabel(issue.priority)}</span>
</PropertyRow> </PropertyRow>
<PropertyRow label="Assignee"> <PropertyRow label="Assignee">
{assignee ? ( <Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
{assignee ? (
<Identity name={assignee.name} size="sm" />
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Unassigned</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent"
)}
onClick={() => { onUpdate({ assigneeAgentId: null }); setAssigneeOpen(false); }}
>
No assignee
</button>
{(agents ?? [])
.filter((a) => a.status !== "terminated")
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
a.id === issue.assigneeAgentId && "bg-accent"
)}
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
>
{a.name}
</button>
))}
</PopoverContent>
</Popover>
{issue.assigneeAgentId && (
<Link <Link
to={`/agents/${assignee.id}`} to={`/agents/${issue.assigneeAgentId}`}
className="hover:underline" className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
> >
<Identity name={assignee.name} size="sm" /> <ArrowUpRight className="h-3 w-3" />
</Link> </Link>
) : (
<span className="text-sm text-muted-foreground">Unassigned</span>
)} )}
</PropertyRow> </PropertyRow>
{issue.projectId && ( <PropertyRow label="Project">
<PropertyRow label="Project"> <Popover open={projectOpen} onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
{issue.projectId ? (
<span className="text-sm">{projectName(issue.projectId)}</span>
) : (
<>
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">No project</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
>
No project
</button>
{(projects ?? [])
.filter((p) => {
if (!projectSearch.trim()) return true;
const q = projectSearch.toLowerCase();
return p.name.toLowerCase().includes(q);
})
.map((p) => (
<button
key={p.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
>
{p.name}
</button>
))}
</PopoverContent>
</Popover>
{issue.projectId && (
<Link <Link
to={`/projects/${issue.projectId}`} to={`/projects/${issue.projectId}`}
className="text-sm hover:underline" className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
> >
{projectName(issue.projectId)} <ArrowUpRight className="h-3 w-3" />
</Link> </Link>
</PropertyRow> )}
)} </PropertyRow>
{issue.parentId && ( {issue.parentId && (
<PropertyRow label="Parent"> <PropertyRow label="Parent">

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { BreadcrumbBar } from "./BreadcrumbBar"; import { BreadcrumbBar } from "./BreadcrumbBar";
@@ -11,11 +11,12 @@ import { OnboardingWizard } from "./OnboardingWizard";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
export function Layout() { export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(true); const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog(); const { openNewIssue, openOnboarding } = useDialog();
const { panelContent, closePanel } = usePanel(); const { panelContent, closePanel } = usePanel();
const { companies, loading: companiesLoading } = useCompany(); const { companies, loading: companiesLoading } = useCompany();
@@ -29,7 +30,6 @@ export function Layout() {
} }
}, [companies, companiesLoading, openOnboarding]); }, [companies, companiesLoading, openOnboarding]);
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
const togglePanel = useCallback(() => { const togglePanel = useCallback(() => {
if (panelContent) closePanel(); if (panelContent) closePanel();
}, [panelContent, closePanel]); }, [panelContent, closePanel]);
@@ -42,18 +42,40 @@ export function Layout() {
return ( return (
<div className="flex h-screen bg-background text-foreground overflow-hidden"> <div className="flex h-screen bg-background text-foreground overflow-hidden">
<div {/* Mobile backdrop */}
className={cn( {isMobile && sidebarOpen && (
"transition-all duration-200 ease-in-out shrink-0 h-full overflow-hidden", <div
sidebarOpen ? "w-60" : "w-0" className="fixed inset-0 z-40 bg-black/50"
)} onClick={() => setSidebarOpen(false)}
> />
<Sidebar /> )}
</div>
{/* Sidebar */}
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-60 transition-transform duration-200 ease-in-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<Sidebar />
</div>
) : (
<div
className={cn(
"shrink-0 h-full overflow-hidden transition-all duration-200 ease-in-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
<Sidebar />
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-full"> <div className="flex-1 flex flex-col min-w-0 h-full">
<BreadcrumbBar /> <BreadcrumbBar />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-4 md:p-6">
<Outlet /> <Outlet />
</main> </main>
<PropertiesPanel /> <PropertiesPanel />

View File

@@ -95,6 +95,7 @@ export function NewIssueDialog() {
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false); const [priorityOpen, setPriorityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false); const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false); const [projectOpen, setProjectOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
@@ -341,14 +342,21 @@ export function NewIssueDialog() {
</Popover> </Popover>
{/* Assignee chip */} {/* Assignee chip */}
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}> <Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors"> <button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<User className="h-3 w-3 text-muted-foreground" /> <User className="h-3 w-3 text-muted-foreground" />
{currentAssignee ? currentAssignee.name : "Assignee"} {currentAssignee ? currentAssignee.name : "Assignee"}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start"> <PopoverContent className="w-52 p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<button <button
className={cn( className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50", "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
@@ -358,7 +366,14 @@ export function NewIssueDialog() {
> >
No assignee No assignee
</button> </button>
{(agents ?? []).map((a) => ( {(agents ?? [])
.filter((a) => a.status !== "terminated")
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button <button
key={a.id} key={a.id}
className={cn( className={cn(

View File

@@ -17,9 +17,10 @@ interface PriorityIconProps {
priority: string; priority: string;
onChange?: (priority: string) => void; onChange?: (priority: string) => void;
className?: string; className?: string;
showLabel?: boolean;
} }
export function PriorityIcon({ priority, onChange, className }: PriorityIconProps) { export function PriorityIcon({ priority, onChange, className, showLabel }: PriorityIconProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const config = priorityConfig[priority] ?? priorityConfig.medium!; const config = priorityConfig[priority] ?? priorityConfig.medium!;
const Icon = config.icon; const Icon = config.icon;
@@ -29,7 +30,7 @@ export function PriorityIcon({ priority, onChange, className }: PriorityIconProp
className={cn( className={cn(
"inline-flex items-center justify-center shrink-0", "inline-flex items-center justify-center shrink-0",
config.color, config.color,
onChange && "cursor-pointer", onChange && !showLabel && "cursor-pointer",
className className
)} )}
> >
@@ -37,11 +38,18 @@ export function PriorityIcon({ priority, onChange, className }: PriorityIconProp
</span> </span>
); );
if (!onChange) return icon; if (!onChange) return showLabel ? <span className="inline-flex items-center gap-1.5">{icon}<span className="text-sm">{config.label}</span></span> : icon;
const trigger = showLabel ? (
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
{icon}
<span className="text-sm">{config.label}</span>
</button>
) : icon;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{icon}</PopoverTrigger> <PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start"> <PopoverContent className="w-36 p-1" align="start">
{allPriorities.map((p) => { {allPriorities.map((p) => {
const c = priorityConfig[p]!; const c = priorityConfig[p]!;

View File

@@ -5,6 +5,7 @@ import { Separator } from "@/components/ui/separator";
interface ProjectPropertiesProps { interface ProjectPropertiesProps {
project: Project; project: Project;
onUpdate?: (data: Record<string, unknown>) => void;
} }
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
@@ -16,7 +17,7 @@ function PropertyRow({ label, children }: { label: string; children: React.React
); );
} }
export function ProjectProperties({ project }: ProjectPropertiesProps) { export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">

View File

@@ -9,7 +9,7 @@ export function PropertiesPanel() {
if (!panelContent) return null; if (!panelContent) return null;
return ( return (
<aside className="w-80 border-l border-border bg-card flex flex-col shrink-0"> <aside className="hidden md:flex w-80 border-l border-border bg-card flex-col shrink-0">
<div className="flex items-center justify-between px-4 py-2 border-b border-border"> <div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-sm font-medium">Properties</span> <span className="text-sm font-medium">Properties</span>
<Button variant="ghost" size="icon-xs" onClick={closePanel}> <Button variant="ghost" size="icon-xs" onClick={closePanel}>

View File

@@ -77,6 +77,8 @@ export function Sidebar() {
label="Inbox" label="Inbox"
icon={Inbox} icon={Inbox}
badge={sidebarBadges?.inbox} badge={sidebarBadges?.inbox}
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
/> />
</div> </div>

View File

@@ -1,5 +1,6 @@
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
interface SidebarNavItemProps { interface SidebarNavItemProps {
@@ -8,6 +9,8 @@ interface SidebarNavItemProps {
icon: LucideIcon; icon: LucideIcon;
end?: boolean; end?: boolean;
badge?: number; badge?: number;
badgeTone?: "default" | "danger";
alert?: boolean;
} }
export function SidebarNavItem({ export function SidebarNavItem({
@@ -16,11 +19,16 @@ export function SidebarNavItem({
icon: Icon, icon: Icon,
end, end,
badge, badge,
badgeTone = "default",
alert = false,
}: SidebarNavItemProps) { }: SidebarNavItemProps) {
const { isMobile, setSidebarOpen } = useSidebar();
return ( return (
<NavLink <NavLink
to={to} to={to}
end={end} end={end}
onClick={() => { if (isMobile) setSidebarOpen(false); }}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors", "flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
@@ -30,10 +38,22 @@ export function SidebarNavItem({
) )
} }
> >
<Icon className="h-4 w-4 shrink-0" /> <span className="relative shrink-0">
<Icon className="h-4 w-4" />
{alert && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_2px_hsl(var(--background))]" />
)}
</span>
<span className="flex-1 truncate">{label}</span> <span className="flex-1 truncate">{label}</span>
{badge != null && badge > 0 && ( {badge != null && badge > 0 && (
<span className="ml-auto text-xs bg-primary text-primary-foreground rounded-full px-1.5 py-0.5 leading-none"> <span
className={cn(
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
badgeTone === "danger"
? "bg-red-600/90 text-red-50"
: "bg-primary text-primary-foreground",
)}
>
{badge} {badge}
</span> </span>
)} )}

View File

@@ -10,6 +10,7 @@ const statusColors: Record<string, string> = {
achieved: "bg-green-900/50 text-green-300", achieved: "bg-green-900/50 text-green-300",
completed: "bg-green-900/50 text-green-300", completed: "bg-green-900/50 text-green-300",
failed: "bg-red-900/50 text-red-300", failed: "bg-red-900/50 text-red-300",
timed_out: "bg-orange-900/50 text-orange-300",
succeeded: "bg-green-900/50 text-green-300", succeeded: "bg-green-900/50 text-green-300",
error: "bg-red-900/50 text-red-300", error: "bg-red-900/50 text-red-300",
pending_approval: "bg-amber-900/50 text-amber-300", pending_approval: "bg-amber-900/50 text-amber-300",

View File

@@ -23,9 +23,10 @@ interface StatusIconProps {
status: string; status: string;
onChange?: (status: string) => void; onChange?: (status: string) => void;
className?: string; className?: string;
showLabel?: boolean;
} }
export function StatusIcon({ status, onChange, className }: StatusIconProps) { export function StatusIcon({ status, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const colorClass = statusColors[status] ?? "text-muted-foreground border-muted-foreground"; const colorClass = statusColors[status] ?? "text-muted-foreground border-muted-foreground";
const isDone = status === "done"; const isDone = status === "done";
@@ -35,7 +36,7 @@ export function StatusIcon({ status, onChange, className }: StatusIconProps) {
className={cn( className={cn(
"relative inline-flex h-4 w-4 rounded-full border-2 shrink-0", "relative inline-flex h-4 w-4 rounded-full border-2 shrink-0",
colorClass, colorClass,
onChange && "cursor-pointer", onChange && !showLabel && "cursor-pointer",
className className
)} )}
> >
@@ -45,11 +46,18 @@ export function StatusIcon({ status, onChange, className }: StatusIconProps) {
</span> </span>
); );
if (!onChange) return circle; if (!onChange) return showLabel ? <span className="inline-flex items-center gap-1.5">{circle}<span className="text-sm">{statusLabel(status)}</span></span> : circle;
const trigger = showLabel ? (
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
{circle}
<span className="text-sm">{statusLabel(status)}</span>
</button>
) : circle;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{circle}</PopoverTrigger> <PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start"> <PopoverContent className="w-40 p-1" align="start">
{allStatuses.map((s) => ( {allStatuses.map((s) => (
<Button <Button

View File

@@ -18,6 +18,7 @@ export const help: Record<string, string> = {
cwd: "The working directory where the agent operates. Use an absolute path on the machine running Paperclip.", 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.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.", search: "Enable Codex web search capability during runs.",

View File

@@ -17,6 +17,7 @@ function invalidateHeartbeatQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
const agentId = readString(payload.agentId); const agentId = readString(payload.agentId);
if (agentId) { if (agentId) {

View File

@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useState, useEffect, type ReactNode } from "react";
interface SidebarContextValue {
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
isMobile: boolean;
}
const SidebarContext = createContext<SidebarContextValue | null>(null);
const MOBILE_BREAKPOINT = 768;
export function SidebarProvider({ children }: { children: ReactNode }) {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth >= MOBILE_BREAKPOINT);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
setSidebarOpen(!e.matches);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
return (
<SidebarContext.Provider value={{ sidebarOpen, setSidebarOpen, toggleSidebar, isMobile }}>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const ctx = useContext(SidebarContext);
if (!ctx) {
throw new Error("useSidebar must be used within SidebarProvider");
}
return ctx;
}

View File

@@ -16,6 +16,7 @@ export const queryKeys = {
list: (companyId: string) => ["issues", companyId] as const, list: (companyId: string) => ["issues", companyId] as const,
detail: (id: string) => ["issues", "detail", id] as const, detail: (id: string) => ["issues", "detail", id] as const,
comments: (issueId: string) => ["issues", "comments", issueId] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const,
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
activity: (issueId: string) => ["issues", "activity", issueId] as const, activity: (issueId: string) => ["issues", "activity", issueId] as const,
runs: (issueId: string) => ["issues", "runs", issueId] as const, runs: (issueId: string) => ["issues", "runs", issueId] as const,
approvals: (issueId: string) => ["issues", "approvals", issueId] as const, approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
@@ -48,6 +49,7 @@ export const queryKeys = {
["costs", companyId, from, to] as const, ["costs", companyId, from, to] as const,
heartbeats: (companyId: string, agentId?: string) => heartbeats: (companyId: string, agentId?: string) =>
["heartbeats", companyId, agentId] as const, ["heartbeats", companyId, agentId] as const,
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
runIssues: (runId: string) => ["run-issues", runId] as const, runIssues: (runId: string) => ["run-issues", runId] as const,
org: (companyId: string) => ["org", companyId] as const, org: (companyId: string) => ["org", companyId] as const,
}; };

View File

@@ -7,6 +7,7 @@ import { CompanyProvider } from "./context/CompanyContext";
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider"; import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
import { BreadcrumbProvider } from "./context/BreadcrumbContext"; import { BreadcrumbProvider } from "./context/BreadcrumbContext";
import { PanelProvider } from "./context/PanelContext"; import { PanelProvider } from "./context/PanelContext";
import { SidebarProvider } from "./context/SidebarContext";
import { DialogProvider } from "./context/DialogContext"; import { DialogProvider } from "./context/DialogContext";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import "./index.css"; import "./index.css";
@@ -28,11 +29,13 @@ createRoot(document.getElementById("root")!).render(
<BrowserRouter> <BrowserRouter>
<TooltipProvider> <TooltipProvider>
<BreadcrumbProvider> <BreadcrumbProvider>
<SidebarProvider>
<PanelProvider> <PanelProvider>
<DialogProvider> <DialogProvider>
<App /> <App />
</DialogProvider> </DialogProvider>
</PanelProvider> </PanelProvider>
</SidebarProvider>
</BreadcrumbProvider> </BreadcrumbProvider>
</TooltipProvider> </TooltipProvider>
</BrowserRouter> </BrowserRouter>

View File

@@ -297,22 +297,22 @@ export function AgentDetail() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<div> <div className="min-w-0">
<h2 className="text-xl font-bold">{agent.name}</h2> <h2 className="text-xl font-bold truncate">{agent.name}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground truncate">
{roleLabels[agent.role] ?? agent.role} {roleLabels[agent.role] ?? agent.role}
{agent.title ? ` - ${agent.title}` : ""} {agent.title ? ` - ${agent.title}` : ""}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1 sm:gap-2 shrink-0">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => openNewIssue({ assigneeAgentId: agentId })} onClick={() => openNewIssue({ assigneeAgentId: agentId })}
> >
<Plus className="h-3.5 w-3.5 mr-1" /> <Plus className="h-3.5 w-3.5 sm:mr-1" />
Assign Task <span className="hidden sm:inline">Assign Task</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -320,8 +320,8 @@ export function AgentDetail() {
onClick={() => agentAction.mutate("invoke")} onClick={() => agentAction.mutate("invoke")}
disabled={agentAction.isPending || isPendingApproval} disabled={agentAction.isPending || isPendingApproval}
> >
<Play className="h-3.5 w-3.5 mr-1" /> <Play className="h-3.5 w-3.5 sm:mr-1" />
Invoke <span className="hidden sm:inline">Invoke</span>
</Button> </Button>
{agent.status === "paused" ? ( {agent.status === "paused" ? (
<Button <Button
@@ -330,8 +330,8 @@ export function AgentDetail() {
onClick={() => agentAction.mutate("resume")} onClick={() => agentAction.mutate("resume")}
disabled={agentAction.isPending || isPendingApproval} disabled={agentAction.isPending || isPendingApproval}
> >
<Play className="h-3.5 w-3.5 mr-1" /> <Play className="h-3.5 w-3.5 sm:mr-1" />
Resume <span className="hidden sm:inline">Resume</span>
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -340,11 +340,11 @@ export function AgentDetail() {
onClick={() => agentAction.mutate("pause")} onClick={() => agentAction.mutate("pause")}
disabled={agentAction.isPending || isPendingApproval} disabled={agentAction.isPending || isPendingApproval}
> >
<Pause className="h-3.5 w-3.5 mr-1" /> <Pause className="h-3.5 w-3.5 sm:mr-1" />
Pause <span className="hidden sm:inline">Pause</span>
</Button> </Button>
)} )}
<StatusBadge status={agent.status} /> <span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
{/* Overflow menu */} {/* Overflow menu */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}> <Popover open={moreOpen} onOpenChange={setMoreOpen}>
@@ -434,6 +434,8 @@ export function AgentDetail() {
{ value: "costs", label: "Costs" }, { value: "costs", label: "Costs" },
{ value: "keys", label: "API Keys" }, { value: "keys", label: "API Keys" },
]} ]}
value={activeTab}
onValueChange={setActiveTab}
/> />
{/* OVERVIEW TAB */} {/* OVERVIEW TAB */}
@@ -732,7 +734,7 @@ function ConfigurationTab({
}, [onSavingChange, updateAgent.isPending]); }, [onSavingChange, updateAgent.isPending]);
return ( return (
<div className="max-w-2xl space-y-4"> <div className="space-y-4">
<div className="border border-border rounded-lg overflow-hidden"> <div className="border border-border rounded-lg overflow-hidden">
<AgentConfigForm <AgentConfigForm
mode="edit" mode="edit"
@@ -913,6 +915,23 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
queryKey: queryKeys.runIssues(run.id), queryKey: queryKeys.runIssues(run.id),
queryFn: () => activityApi.issuesForRun(run.id), queryFn: () => activityApi.issuesForRun(run.id),
}); });
const touchedIssueIds = useMemo(
() => Array.from(new Set((touchedIssues ?? []).map((issue) => issue.issueId))),
[touchedIssues],
);
const clearSessionsForTouchedIssues = useMutation({
mutationFn: async () => {
if (touchedIssueIds.length === 0) return 0;
await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId)));
return touchedIssueIds.length;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(run.agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(run.agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.runIssues(run.id) });
},
});
const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }; const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null; const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null;
@@ -1027,6 +1046,34 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<CopyText text={run.sessionIdAfter} className="font-mono" /> <CopyText text={run.sessionIdAfter} className="font-mono" />
</div> </div>
)} )}
{touchedIssueIds.length > 0 && (
<div className="pt-1">
<button
type="button"
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-60"
disabled={clearSessionsForTouchedIssues.isPending}
onClick={() => {
const issueCount = touchedIssueIds.length;
const confirmed = window.confirm(
`Clear session for ${issueCount} issue${issueCount === 1 ? "" : "s"} touched by this run?`,
);
if (!confirmed) return;
clearSessionsForTouchedIssues.mutate();
}}
>
{clearSessionsForTouchedIssues.isPending
? "clearing session..."
: "clear session for these issues"}
</button>
{clearSessionsForTouchedIssues.isError && (
<p className="text-[11px] text-destructive mt-1">
{clearSessionsForTouchedIssues.error instanceof Error
? clearSessionsForTouchedIssues.error.message
: "Failed to clear sessions"}
</p>
)}
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -1086,6 +1133,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const [logLoading, setLogLoading] = useState(!!run.logRef); const [logLoading, setLogLoading] = useState(!!run.logRef);
const [logError, setLogError] = useState<string | null>(null); const [logError, setLogError] = useState<string | null>(null);
const [logOffset, setLogOffset] = useState(0); const [logOffset, setLogOffset] = useState(0);
const [isFollowing, setIsFollowing] = useState(true);
const logEndRef = useRef<HTMLDivElement>(null); const logEndRef = useRef<HTMLDivElement>(null);
const pendingLogLineRef = useRef(""); const pendingLogLineRef = useRef("");
const isLive = run.status === "running" || run.status === "queued"; const isLive = run.status === "running" || run.status === "queued";
@@ -1135,12 +1183,36 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
} }
}, [initialEvents]); }, [initialEvents]);
// Auto-scroll only for live runs const updateFollowingState = useCallback(() => {
const el = logEndRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top <= window.innerHeight && rect.bottom >= 0;
setIsFollowing((prev) => (prev === inView ? prev : inView));
}, []);
useEffect(() => { useEffect(() => {
if (isLive) { if (!isLive) return;
setIsFollowing(true);
}, [isLive, run.id]);
useEffect(() => {
if (!isLive) return;
updateFollowingState();
window.addEventListener("scroll", updateFollowingState, { passive: true });
window.addEventListener("resize", updateFollowingState);
return () => {
window.removeEventListener("scroll", updateFollowingState);
window.removeEventListener("resize", updateFollowingState);
};
}, [isLive, updateFollowingState]);
// Auto-scroll only for live runs when following
useEffect(() => {
if (isLive && isFollowing) {
logEndRef.current?.scrollIntoView({ behavior: "smooth" }); logEndRef.current?.scrollIntoView({ behavior: "smooth" });
} }
}, [events, logLines, isLive]); }, [events, logLines, isLive, isFollowing]);
// Fetch persisted shell log // Fetch persisted shell log
useEffect(() => { useEffect(() => {
@@ -1315,15 +1387,29 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
Transcript ({transcript.length}) Transcript ({transcript.length})
</span> </span>
{isLive && ( <div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-xs text-cyan-400"> {isLive && !isFollowing && (
<span className="relative flex h-2 w-2"> <Button
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" /> variant="ghost"
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" /> size="xs"
onClick={() => {
setIsFollowing(true);
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}}
>
Jump to live
</Button>
)}
{isLive && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span>
Live
</span> </span>
Live )}
</span> </div>
)}
</div> </div>
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5"> <div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
{transcript.length === 0 && !run.logRef && ( {transcript.length === 0 && !run.logRef && (
@@ -1536,7 +1622,7 @@ function CostsTab({
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6">
{/* Cumulative totals */} {/* Cumulative totals */}
{runtimeState && ( {runtimeState && (
<div className="border border-border rounded-lg p-4"> <div className="border border-border rounded-lg p-4">

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext"; 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";
@@ -78,6 +79,24 @@ export function Agents() {
enabled: !!selectedCompanyId && view === "org", enabled: !!selectedCompanyId && view === "org",
}); });
const { data: runs } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 15_000,
});
// Map agentId -> first live run (running or queued)
const liveRunByAgent = useMemo(() => {
const map = new Map<string, { runId: string }>();
for (const r of runs ?? []) {
if ((r.status === "running" || r.status === "queued") && !map.has(r.agentId)) {
map.set(r.agentId, { runId: r.id });
}
}
return map;
}, [runs]);
const agentMap = useMemo(() => { const agentMap = useMemo(() => {
const map = new Map<string, Agent>(); const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a); for (const a of agents ?? []) map.set(a.id, a);
@@ -97,14 +116,18 @@ export function Agents() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}> <Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
<PageTabBar items={[ <PageTabBar
{ value: "all", label: "All" }, items={[
{ value: "active", label: "Active" }, { value: "all", label: "All" },
{ value: "paused", label: "Paused" }, { value: "active", label: "Active" },
{ value: "error", label: "Error" }, { value: "paused", label: "Paused" },
]} /> { value: "error", label: "Error" },
]}
value={tab}
onValueChange={(v) => navigate(`/agents/${v}`)}
/>
</Tabs> </Tabs>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Filters */} {/* Filters */}
@@ -217,6 +240,13 @@ export function Agents() {
} }
trailing={ trailing={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentId={agent.id}
runId={liveRunByAgent.get(agent.id)!.runId}
navigate={navigate}
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right"> <span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType} {adapterLabels[agent.adapterType] ?? agent.adapterType}
</span> </span>
@@ -261,7 +291,7 @@ export function Agents() {
{view === "org" && filteredOrg.length > 0 && ( {view === "org" && filteredOrg.length > 0 && (
<div className="border border-border py-1"> <div className="border border-border py-1">
{filteredOrg.map((node) => ( {filteredOrg.map((node) => (
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} /> <OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))} ))}
</div> </div>
)} )}
@@ -286,11 +316,13 @@ function OrgTreeNode({
depth, depth,
navigate, navigate,
agentMap, agentMap,
liveRunByAgent,
}: { }: {
node: OrgNode; node: OrgNode;
depth: number; depth: number;
navigate: (path: string) => void; navigate: (path: string) => void;
agentMap: Map<string, Agent>; agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string }>;
}) { }) {
const agent = agentMap.get(node.id); const agent = agentMap.get(node.id);
@@ -329,6 +361,13 @@ function OrgTreeNode({
</span> </span>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
{liveRunByAgent.has(node.id) && (
<LiveRunIndicator
agentId={node.id}
runId={liveRunByAgent.get(node.id)!.runId}
navigate={navigate}
/>
)}
{agent && ( {agent && (
<> <>
<span className="text-xs text-muted-foreground font-mono w-14 text-right"> <span className="text-xs text-muted-foreground font-mono w-14 text-right">
@@ -364,10 +403,36 @@ function OrgTreeNode({
{node.reports && node.reports.length > 0 && ( {node.reports && node.reports.length > 0 && (
<div className="border-l border-border/50 ml-4"> <div className="border-l border-border/50 ml-4">
{node.reports.map((child) => ( {node.reports.map((child) => (
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} /> <OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))} ))}
</div> </div>
)} )}
</div> </div>
); );
} }
function LiveRunIndicator({
agentId,
runId,
navigate,
}: {
agentId: string;
runId: string;
navigate: (path: string) => void;
}) {
return (
<button
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigate(`/agents/${agentId}/runs/${runId}`);
}}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">Live</span>
</button>
);
}

View File

@@ -19,6 +19,7 @@ import { Identity } from "../components/Identity";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils"; import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react"; import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import type { Agent, Issue } from "@paperclip/shared"; import type { Agent, Issue } from "@paperclip/shared";
function getRecentIssues(issues: Issue[]): Issue[] { function getRecentIssues(issues: Issue[]): Issue[] {
@@ -271,8 +272,8 @@ export function Dashboard() {
{issue.assigneeAgentId && (() => { {issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId); const name = agentName(issue.assigneeAgentId);
return name return name
? <Identity name={name} size="sm" className="shrink-0" /> ? <Identity name={name} size="sm" className="shrink-0 hidden sm:flex" />
: <span className="text-xs text-muted-foreground font-mono shrink-0">{issue.assigneeAgentId.slice(0, 8)}</span>; : <span className="text-xs text-muted-foreground font-mono shrink-0 hidden sm:inline">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()} })()}
<span className="text-xs text-muted-foreground shrink-0"> <span className="text-xs text-muted-foreground shrink-0">
{timeAgo(issue.updatedAt)} {timeAgo(issue.updatedAt)}
@@ -283,6 +284,8 @@ export function Dashboard() {
)} )}
</div> </div>
</div> </div>
<ActiveAgentsPanel companyId={selectedCompanyId!} />
</> </>
)} )}
</div> </div>

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard"; import { dashboardApi } from "../api/dashboard";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
@@ -12,6 +13,7 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon"; import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { ApprovalCard } from "../components/ApprovalCard"; import { ApprovalCard } from "../components/ApprovalCard";
import { StatusBadge } from "../components/StatusBadge";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -20,11 +22,21 @@ import {
AlertTriangle, AlertTriangle,
Clock, Clock,
ExternalLink, ExternalLink,
ArrowUpRight,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { Identity } from "../components/Identity"; import { Identity } from "../components/Identity";
import type { Issue } from "@paperclip/shared"; import type { HeartbeatRun, Issue } from "@paperclip/shared";
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
const RUN_SOURCE_LABELS: Record<string, string> = {
timer: "Scheduled",
assignment: "Assignment",
on_demand: "Manual",
automation: "Automation",
};
function getStaleIssues(issues: Issue[]): Issue[] { function getStaleIssues(issues: Issue[]): Issue[] {
const now = Date.now(); const now = Date.now();
@@ -40,6 +52,50 @@ function getStaleIssues(issues: Issue[]): Issue[] {
); );
} }
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) =>
FAILED_RUN_STATUSES.has(run.status),
);
}
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
return line ?? null;
}
function runFailureMessage(run: HeartbeatRun): string {
return (
firstNonEmptyLine(run.error) ??
firstNonEmptyLine(run.stderrExcerpt) ??
"Run exited with an error."
);
}
function readIssueIdFromRun(run: HeartbeatRun): string | null {
const context = run.contextSnapshot;
if (!context) return null;
const issueId = context["issueId"];
if (typeof issueId === "string" && issueId.length > 0) return issueId;
const taskId = context["taskId"];
if (typeof taskId === "string" && taskId.length > 0) return taskId;
return null;
}
export function Inbox() { export function Inbox() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@@ -57,7 +113,7 @@ export function Inbox() {
setBreadcrumbs([{ label: "Inbox" }]); setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
const { data: approvals, isLoading, error } = useQuery({ const { data: approvals, isLoading: isApprovalsLoading, error } = useQuery({
queryKey: queryKeys.approvals.list(selectedCompanyId!), queryKey: queryKeys.approvals.list(selectedCompanyId!),
queryFn: () => approvalsApi.list(selectedCompanyId!), queryFn: () => approvalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
@@ -75,12 +131,34 @@ export function Inbox() {
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: heartbeatRuns } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const staleIssues = issues ? getStaleIssues(issues) : []; const staleIssues = issues ? getStaleIssues(issues) : [];
const agentById = useMemo(() => {
const map = new Map<string, string>();
for (const agent of agents ?? []) map.set(agent.id, agent.name);
return map;
}, [agents]);
const issueById = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues ?? []) map.set(issue.id, issue);
return map;
}, [issues]);
const failedRuns = useMemo(
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []),
[heartbeatRuns],
);
const agentName = (id: string | null) => { const agentName = (id: string | null) => {
if (!id || !agents) return null; if (!id) return null;
const agent = agents.find((a) => a.id === id); return agentById.get(id) ?? null;
return agent?.name ?? null;
}; };
const approveMutation = useMutation({ const approveMutation = useMutation({
@@ -112,35 +190,37 @@ export function Inbox() {
(approval) => approval.status === "pending" || approval.status === "revision_requested", (approval) => approval.status === "pending" || approval.status === "revision_requested",
); );
const hasActionableApprovals = actionableApprovals.length > 0; const hasActionableApprovals = actionableApprovals.length > 0;
const hasRunFailures = failedRuns.length > 0;
const showAggregateAgentError =
!!dashboard && dashboard.agents.error > 0 && !hasRunFailures;
const hasAlerts = const hasAlerts =
dashboard && !!dashboard &&
(dashboard.agents.error > 0 || (showAggregateAgentError || dashboard.costs.monthUtilizationPercent >= 80);
dashboard.costs.monthUtilizationPercent >= 80);
const hasStale = staleIssues.length > 0; const hasStale = staleIssues.length > 0;
const hasContent = hasActionableApprovals || hasAlerts || hasStale; const hasContent = hasActionableApprovals || hasRunFailures || hasAlerts || hasStale;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>} {isApprovalsLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>} {actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!isLoading && !hasContent && ( {!isApprovalsLoading && !hasContent && (
<EmptyState icon={InboxIcon} message="You're all caught up!" /> <EmptyState icon={InboxIcon} message="You're all caught up!" />
)} )}
{/* Pending Approvals */} {/* Pending Approvals */}
{hasActionableApprovals && ( {hasActionableApprovals && (
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Approvals Approvals
</h3> </h3>
<button <button
className="text-xs text-muted-foreground hover:text-foreground transition-colors" className="text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => navigate("/approvals")} onClick={() => navigate("/approvals")}
> >
See all approvals <ExternalLink className="inline h-3 w-3 ml-0.5" /> See all approvals <ExternalLink className="ml-0.5 inline h-3 w-3" />
</button> </button>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
@@ -159,21 +239,100 @@ export function Inbox() {
</div> </div>
)} )}
{/* Alerts */} {/* Failed Runs */}
{hasAlerts && ( {hasRunFailures && (
<> <>
{hasActionableApprovals && <Separator />} {hasActionableApprovals && <Separator />}
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3"> <h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Failed Runs
</h3>
<div className="grid gap-3">
{failedRuns.map((run) => {
const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null;
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
const displayError = runFailureMessage(run);
const linkedAgentName = agentName(run.agentId);
return (
<div
key={run.id}
className="group relative overflow-hidden rounded-xl border border-red-500/30 bg-gradient-to-br from-red-500/10 via-card to-card p-4"
>
<div className="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" />
<div className="relative space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-md bg-red-500/20 p-1.5">
<XCircle className="h-4 w-4 text-red-400" />
</span>
{linkedAgentName
? <Identity name={linkedAgentName} size="sm" />
: <span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>}
<StatusBadge status={run.status} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{sourceLabel} run failed {timeAgo(run.createdAt)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5"
onClick={() => navigate(`/agents/${run.agentId}/runs/${run.id}`)}
>
Open run
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</Button>
</div>
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm">
{displayError}
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
{issue ? (
<button
type="button"
className="truncate text-muted-foreground transition-colors hover:text-foreground"
onClick={() => navigate(`/issues/${issue.id}`)}
>
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
</button>
) : (
<span className="text-muted-foreground">
{run.errorCode ? `code: ${run.errorCode}` : "No linked issue"}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</>
)}
{/* Alerts */}
{hasAlerts && (
<>
{(hasActionableApprovals || hasRunFailures) && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Alerts Alerts
</h3> </h3>
<div className="border border-border divide-y divide-border"> <div className="divide-y divide-border border border-border">
{dashboard!.agents.error > 0 && ( {showAggregateAgentError && (
<div <div
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors" className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate("/agents")} onClick={() => navigate("/agents")}
> >
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" /> <AlertTriangle className="h-4 w-4 shrink-0 text-red-400" />
<span className="text-sm"> <span className="text-sm">
<span className="font-medium">{dashboard!.agents.error}</span>{" "} <span className="font-medium">{dashboard!.agents.error}</span>{" "}
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors {dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
@@ -182,10 +341,10 @@ export function Inbox() {
)} )}
{dashboard!.costs.monthUtilizationPercent >= 80 && ( {dashboard!.costs.monthUtilizationPercent >= 80 && (
<div <div
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors" className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate("/costs")} onClick={() => navigate("/costs")}
> >
<AlertTriangle className="h-4 w-4 text-yellow-400 shrink-0" /> <AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" />
<span className="text-sm"> <span className="text-sm">
Budget at{" "} Budget at{" "}
<span className="font-medium"> <span className="font-medium">
@@ -203,32 +362,32 @@ export function Inbox() {
{/* Stale Work */} {/* Stale Work */}
{hasStale && ( {hasStale && (
<> <>
{(hasActionableApprovals || hasAlerts) && <Separator />} {(hasActionableApprovals || hasRunFailures || hasAlerts) && <Separator />}
<div> <div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3"> <h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Stale Work Stale Work
</h3> </h3>
<div className="border border-border divide-y divide-border"> <div className="divide-y divide-border border border-border">
{staleIssues.map((issue) => ( {staleIssues.map((issue) => (
<div <div
key={issue.id} key={issue.id}
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors" className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate(`/issues/${issue.id}`)} onClick={() => navigate(`/issues/${issue.id}`)}
> >
<Clock className="h-4 w-4 text-muted-foreground shrink-0" /> <Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<PriorityIcon priority={issue.priority} /> <PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} /> <StatusIcon status={issue.status} />
<span className="text-xs font-mono text-muted-foreground"> <span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)} {issue.identifier ?? issue.id.slice(0, 8)}
</span> </span>
<span className="text-sm truncate flex-1">{issue.title}</span> <span className="flex-1 truncate text-sm">{issue.title}</span>
{issue.assigneeAgentId && (() => { {issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId); const name = agentName(issue.assigneeAgentId);
return name return name
? <Identity name={name} size="sm" /> ? <Identity name={name} size="sm" />
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>; : <span className="font-mono text-xs text-muted-foreground">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()} })()}
<span className="text-xs text-muted-foreground shrink-0"> <span className="shrink-0 text-xs text-muted-foreground">
updated {timeAgo(issue.updatedAt)} updated {timeAgo(issue.updatedAt)}
</span> </span>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useParams, Link, useNavigate } from "react-router-dom"; import { useParams, Link, useNavigate } 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";
@@ -9,7 +9,7 @@ import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { relativeTime, cn } from "../lib/utils"; import { relativeTime, cn, formatTokens } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread"; import { CommentThread } from "../components/CommentThread";
import { IssueProperties } from "../components/IssueProperties"; import { IssueProperties } from "../components/IssueProperties";
@@ -21,9 +21,9 @@ import { Identity } from "../components/Identity";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ChevronRight, MoreHorizontal, EyeOff, Hexagon } from "lucide-react"; import { ChevronRight, MoreHorizontal, EyeOff, Hexagon, Paperclip, Trash2 } from "lucide-react";
import type { ActivityEvent } from "@paperclip/shared"; import type { ActivityEvent } from "@paperclip/shared";
import type { Agent } from "@paperclip/shared"; import type { Agent, IssueAttachment } from "@paperclip/shared";
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
"issue.created": "created the issue", "issue.created": "created the issue",
@@ -49,6 +49,20 @@ function humanizeValue(value: unknown): string {
return value.replace(/_/g, " "); return value.replace(/_/g, " ");
} }
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 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 formatAction(action: string, details?: Record<string, unknown> | null): string { function formatAction(action: string, details?: Record<string, unknown> | null): string {
if (action === "issue.updated" && details) { if (action === "issue.updated" && details) {
const previous = (details._previous ?? {}) as Record<string, unknown>; const previous = (details._previous ?? {}) as Record<string, unknown>;
@@ -101,6 +115,8 @@ export function IssueDetail() {
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const [projectOpen, setProjectOpen] = useState(false); const [projectOpen, setProjectOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState(""); const [projectSearch, setProjectSearch] = useState("");
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { data: issue, isLoading, error } = useQuery({ const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!), queryKey: queryKeys.issues.detail(issueId!),
@@ -133,6 +149,12 @@ export function IssueDetail() {
enabled: !!issueId, enabled: !!issueId,
}); });
const { data: attachments } = useQuery({
queryKey: queryKeys.issues.attachments(issueId!),
queryFn: () => issuesApi.listAttachments(issueId!),
enabled: !!issueId,
});
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!),
@@ -173,11 +195,53 @@ export function IssueDetail() {
}); });
}, [activity, comments, linkedRuns]); }, [activity, comments, linkedRuns]);
const issueCostSummary = useMemo(() => {
let input = 0;
let output = 0;
let cached = 0;
let cost = 0;
let hasCost = false;
let hasTokens = false;
for (const run of linkedRuns ?? []) {
const usage = asRecord(run.usageJson);
const result = asRecord(run.resultJson);
const runInput = usageNumber(usage, "inputTokens", "input_tokens");
const runOutput = usageNumber(usage, "outputTokens", "output_tokens");
const runCached = usageNumber(
usage,
"cachedInputTokens",
"cached_input_tokens",
"cache_read_input_tokens",
);
const runCost =
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
if (runCost > 0) hasCost = true;
if (runInput + runOutput + runCached > 0) hasTokens = true;
input += runInput;
output += runOutput;
cached += runCached;
cost += runCost;
}
return {
input,
output,
cached,
cost,
totalTokens: input + output,
hasCost,
hasTokens,
};
}, [linkedRuns]);
const invalidateIssue = () => { const invalidateIssue = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
if (selectedCompanyId) { if (selectedCompanyId) {
@@ -199,6 +263,33 @@ export function IssueDetail() {
}, },
}); });
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return issuesApi.uploadAttachment(selectedCompanyId, issueId!, file);
},
onSuccess: () => {
setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Upload failed");
},
});
const deleteAttachment = useMutation({
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
onSuccess: () => {
setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Delete failed");
},
});
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
{ label: "Issues", href: "/issues" }, { label: "Issues", href: "/issues" },
@@ -222,6 +313,17 @@ export function IssueDetail() {
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start) // Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? []; const ancestors = issue.ancestors ?? [];
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const file = evt.target.files?.[0];
if (!file) return;
await uploadAttachment.mutateAsync(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
return ( return (
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
{/* Parent chain breadcrumb */} {/* Parent chain breadcrumb */}
@@ -357,6 +459,80 @@ export function IssueDetail() {
<Separator /> <Separator />
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleFilePicked}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAttachment.isPending}
>
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
</Button>
</div>
</div>
{attachmentError && (
<p className="text-xs text-destructive">{attachmentError}</p>
)}
{(!attachments || attachments.length === 0) ? (
<p className="text-xs text-muted-foreground">No attachments yet.</p>
) : (
<div className="space-y-2">
{attachments.map((attachment) => (
<div key={attachment.id} className="border border-border rounded-md p-2">
<div className="flex items-center justify-between gap-2">
<a
href={attachment.contentPath}
target="_blank"
rel="noreferrer"
className="text-xs hover:underline truncate"
title={attachment.originalFilename ?? attachment.id}
>
{attachment.originalFilename ?? attachment.id}
</a>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteAttachment.mutate(attachment.id)}
disabled={deleteAttachment.isPending}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-muted-foreground">
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
</p>
{isImageAttachment(attachment) && (
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
loading="lazy"
/>
</a>
)}
</div>
))}
</div>
)}
</div>
<Separator />
<CommentThread <CommentThread
comments={commentsWithRunMeta} comments={commentsWithRunMeta}
issueStatus={issue.status} issueStatus={issue.status}
@@ -437,6 +613,34 @@ export function IssueDetail() {
</div> </div>
</> </>
)} )}
{(linkedRuns && linkedRuns.length > 0) && (
<>
<Separator />
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Cost</h3>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
)}
{issueCostSummary.hasTokens && (
<span>
Tokens {formatTokens(issueCostSummary.totalTokens)}
{issueCostSummary.cached > 0
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
)}
</div>
)}
</div>
</>
)}
</div> </div>
); );
} }

View File

@@ -44,7 +44,7 @@ function OrgTreeNode({
<div> <div>
<div <div
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent/50" className="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent/50"
style={{ paddingLeft: `${depth * 24 + 12}px` }} style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => onSelect(node.id)} onClick={() => onSelect(node.id)}
> >
{hasChildren ? ( {hasChildren ? (

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
@@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties } from "../components/ProjectProperties"; import { ProjectProperties } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow"; import { EntityRow } from "../components/EntityRow";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -18,6 +19,8 @@ export function ProjectDetail() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel(); const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: project, isLoading, error } = useQuery({ const { data: project, isLoading, error } = useQuery({
queryKey: queryKeys.projects.detail(projectId!), queryKey: queryKeys.projects.detail(projectId!),
@@ -33,6 +36,18 @@ export function ProjectDetail() {
const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId); const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId);
const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
}
};
const updateProject = useMutation({
mutationFn: (data: Record<string, unknown>) => projectsApi.update(projectId!, data),
onSuccess: invalidateProject,
});
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
{ label: "Projects", href: "/projects" }, { label: "Projects", href: "/projects" },
@@ -42,7 +57,7 @@ export function ProjectDetail() {
useEffect(() => { useEffect(() => {
if (project) { if (project) {
openPanel(<ProjectProperties project={project} />); openPanel(<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />);
} }
return () => closePanel(); return () => closePanel();
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps }, [project]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -53,11 +68,22 @@ export function ProjectDetail() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="space-y-3">
<h2 className="text-xl font-bold">{project.name}</h2> <InlineEditor
{project.description && ( value={project.name}
<p className="text-sm text-muted-foreground mt-1">{project.description}</p> onSave={(name) => updateProject.mutate({ name })}
)} as="h2"
className="text-xl font-bold"
/>
<InlineEditor
value={project.description ?? ""}
onSave={(description) => updateProject.mutate({ description })}
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
/>
</div> </div>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
@@ -67,7 +93,7 @@ export function ProjectDetail() {
</TabsList> </TabsList>
<TabsContent value="overview" className="mt-4"> <TabsContent value="overview" className="mt-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="text-muted-foreground">Status</span> <span className="text-muted-foreground">Status</span>
<div className="mt-1"> <div className="mt-1">
@@ -94,6 +120,7 @@ export function ProjectDetail() {
identifier={issue.identifier ?? issue.id.slice(0, 8)} identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title} title={issue.title}
trailing={<StatusBadge status={issue.status} />} trailing={<StatusBadge status={issue.status} />}
onClick={() => navigate(`/issues/${issue.id}`)}
/> />
))} ))}
</div> </div>