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:
@@ -9,6 +9,8 @@ export interface RunForIssue {
|
||||
finishedAt: string | null;
|
||||
createdAt: string;
|
||||
invocationSource: string;
|
||||
usageJson: Record<string, unknown> | null;
|
||||
resultJson: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface IssueForRun {
|
||||
|
||||
@@ -38,4 +38,6 @@ export const heartbeatsApi = {
|
||||
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
|
||||
activeRunForIssue: (issueId: string) =>
|
||||
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
|
||||
liveRunsForCompany: (companyId: string) =>
|
||||
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs`),
|
||||
};
|
||||
|
||||
402
ui/src/components/ActiveAgentsPanel.tsx
Normal file
402
ui/src/components/ActiveAgentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
||||
cwd: "",
|
||||
promptTemplate: "",
|
||||
model: "",
|
||||
thinkingEffort: "",
|
||||
dangerouslySkipPermissions: false,
|
||||
search: false,
|
||||
dangerouslyBypassSandbox: false,
|
||||
@@ -126,6 +127,21 @@ function formatArgList(value: unknown): string {
|
||||
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 {
|
||||
if (typeof handle !== "object" || handle === null) return null;
|
||||
@@ -269,6 +285,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
// Popover states
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false);
|
||||
|
||||
// Create mode helpers
|
||||
const val = isCreate ? props.values : null;
|
||||
@@ -281,6 +298,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? val!.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 (
|
||||
<div className="relative">
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
@@ -342,7 +375,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
value={adapterType}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
set!({ adapterType: t });
|
||||
set!({ adapterType: t, model: "", thinkingEffort: "" });
|
||||
} else {
|
||||
setOverlay((prev) => ({
|
||||
...prev,
|
||||
@@ -486,6 +519,25 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
open={modelOpen}
|
||||
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}>
|
||||
{isCreate ? (
|
||||
<AutoExpandTextarea
|
||||
@@ -985,11 +1037,23 @@ function ModelDropdown({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
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 (
|
||||
<Field label="Model" hint={help.model}>
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen) setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<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")}>
|
||||
@@ -999,6 +1063,13 @@ function ModelDropdown({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<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
|
||||
className={cn(
|
||||
"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
|
||||
</button>
|
||||
{models.map((m) => (
|
||||
{filteredModels.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
@@ -1027,6 +1098,56 @@ function ModelDropdown({
|
||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||
</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>
|
||||
</Popover>
|
||||
</Field>
|
||||
|
||||
@@ -16,7 +16,7 @@ function PayloadField({ label, value }: { label: string; value: unknown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@@ -26,20 +26,20 @@ export function HireAgentPayload({ payload }: { payload: Record<string, unknown>
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<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>
|
||||
</div>
|
||||
<PayloadField label="Role" value={payload.role} />
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!payload.capabilities && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{!!payload.adapterType && (
|
||||
<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">
|
||||
{String(payload.adapterType)}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -12,13 +15,26 @@ import { Fragment } from "react";
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
const { toggleSidebar, isMobile } = useSidebar();
|
||||
|
||||
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)
|
||||
if (breadcrumbs.length === 1) {
|
||||
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">
|
||||
{breadcrumbs[0].label}
|
||||
</h1>
|
||||
@@ -28,7 +44,8 @@ export function BreadcrumbBar() {
|
||||
|
||||
// Multiple breadcrumbs = breadcrumb trail
|
||||
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>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
|
||||
className={cn(
|
||||
"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)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Issue } from "@paperclip/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -8,9 +9,11 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { formatDate, cn } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight } from "lucide-react";
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
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) {
|
||||
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({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -46,7 +45,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!issue.projectId,
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
@@ -72,41 +71,142 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
showLabel
|
||||
/>
|
||||
<span className="text-sm">{statusLabel(issue.status)}</span>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Priority">
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(priority) => onUpdate({ priority })}
|
||||
showLabel
|
||||
/>
|
||||
<span className="text-sm">{priorityLabel(issue.priority)}</span>
|
||||
</PropertyRow>
|
||||
|
||||
<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
|
||||
to={`/agents/${assignee.id}`}
|
||||
className="hover:underline"
|
||||
to={`/agents/${issue.assigneeAgentId}`}
|
||||
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>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</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
|
||||
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>
|
||||
</PropertyRow>
|
||||
)}
|
||||
)}
|
||||
</PropertyRow>
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
@@ -11,11 +11,12 @@ import { OnboardingWizard } from "./OnboardingWizard";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
const { panelContent, closePanel } = usePanel();
|
||||
const { companies, loading: companiesLoading } = useCompany();
|
||||
@@ -29,7 +30,6 @@ export function Layout() {
|
||||
}
|
||||
}, [companies, companiesLoading, openOnboarding]);
|
||||
|
||||
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
|
||||
const togglePanel = useCallback(() => {
|
||||
if (panelContent) closePanel();
|
||||
}, [panelContent, closePanel]);
|
||||
@@ -42,18 +42,40 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-200 ease-in-out shrink-0 h-full overflow-hidden",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<BreadcrumbBar />
|
||||
<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 />
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
|
||||
@@ -95,6 +95,7 @@ export function NewIssueDialog() {
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
@@ -341,14 +342,21 @@ export function NewIssueDialog() {
|
||||
</Popover>
|
||||
|
||||
{/* Assignee chip */}
|
||||
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
|
||||
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
|
||||
<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">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
{currentAssignee ? currentAssignee.name : "Assignee"}
|
||||
</button>
|
||||
</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
|
||||
className={cn(
|
||||
"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
|
||||
</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
|
||||
key={a.id}
|
||||
className={cn(
|
||||
|
||||
@@ -17,9 +17,10 @@ interface PriorityIconProps {
|
||||
priority: string;
|
||||
onChange?: (priority: string) => void;
|
||||
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 config = priorityConfig[priority] ?? priorityConfig.medium!;
|
||||
const Icon = config.icon;
|
||||
@@ -29,7 +30,7 @@ export function PriorityIcon({ priority, onChange, className }: PriorityIconProp
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center shrink-0",
|
||||
config.color,
|
||||
onChange && "cursor-pointer",
|
||||
onChange && !showLabel && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -37,11 +38,18 @@ export function PriorityIcon({ priority, onChange, className }: PriorityIconProp
|
||||
</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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{icon}</PopoverTrigger>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{allPriorities.map((p) => {
|
||||
const c = priorityConfig[p]!;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface ProjectPropertiesProps {
|
||||
project: Project;
|
||||
onUpdate?: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -9,7 +9,7 @@ export function PropertiesPanel() {
|
||||
if (!panelContent) return null;
|
||||
|
||||
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">
|
||||
<span className="text-sm font-medium">Properties</span>
|
||||
<Button variant="ghost" size="icon-xs" onClick={closePanel}>
|
||||
|
||||
@@ -77,6 +77,8 @@ export function Sidebar() {
|
||||
label="Inbox"
|
||||
icon={Inbox}
|
||||
badge={sidebarBadges?.inbox}
|
||||
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
|
||||
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface SidebarNavItemProps {
|
||||
@@ -8,6 +9,8 @@ interface SidebarNavItemProps {
|
||||
icon: LucideIcon;
|
||||
end?: boolean;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
alert?: boolean;
|
||||
}
|
||||
|
||||
export function SidebarNavItem({
|
||||
@@ -16,11 +19,16 @@ export function SidebarNavItem({
|
||||
icon: Icon,
|
||||
end,
|
||||
badge,
|
||||
badgeTone = "default",
|
||||
alert = false,
|
||||
}: SidebarNavItemProps) {
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"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>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ const statusColors: Record<string, string> = {
|
||||
achieved: "bg-green-900/50 text-green-300",
|
||||
completed: "bg-green-900/50 text-green-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",
|
||||
error: "bg-red-900/50 text-red-300",
|
||||
pending_approval: "bg-amber-900/50 text-amber-300",
|
||||
|
||||
@@ -23,9 +23,10 @@ interface StatusIconProps {
|
||||
status: string;
|
||||
onChange?: (status: string) => void;
|
||||
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 colorClass = statusColors[status] ?? "text-muted-foreground border-muted-foreground";
|
||||
const isDone = status === "done";
|
||||
@@ -35,7 +36,7 @@ export function StatusIcon({ status, onChange, className }: StatusIconProps) {
|
||||
className={cn(
|
||||
"relative inline-flex h-4 w-4 rounded-full border-2 shrink-0",
|
||||
colorClass,
|
||||
onChange && "cursor-pointer",
|
||||
onChange && !showLabel && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -45,11 +46,18 @@ export function StatusIcon({ status, onChange, className }: StatusIconProps) {
|
||||
</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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{circle}</PopoverTrigger>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{allStatuses.map((s) => (
|
||||
<Button
|
||||
|
||||
@@ -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.",
|
||||
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.",
|
||||
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
||||
search: "Enable Codex web search capability during runs.",
|
||||
|
||||
@@ -17,6 +17,7 @@ function invalidateHeartbeatQueries(
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
if (agentId) {
|
||||
|
||||
43
ui/src/context/SidebarContext.tsx
Normal file
43
ui/src/context/SidebarContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
detail: (id: string) => ["issues", "detail", id] 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,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
@@ -48,6 +49,7 @@ export const queryKeys = {
|
||||
["costs", companyId, from, to] as const,
|
||||
heartbeats: (companyId: string, agentId?: string) =>
|
||||
["heartbeats", companyId, agentId] as const,
|
||||
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
||||
runIssues: (runId: string) => ["run-issues", runId] as const,
|
||||
org: (companyId: string) => ["org", companyId] as const,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CompanyProvider } from "./context/CompanyContext";
|
||||
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
||||
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
||||
import { PanelProvider } from "./context/PanelContext";
|
||||
import { SidebarProvider } from "./context/SidebarContext";
|
||||
import { DialogProvider } from "./context/DialogContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import "./index.css";
|
||||
@@ -28,11 +29,13 @@ createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<BreadcrumbProvider>
|
||||
<SidebarProvider>
|
||||
<PanelProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</PanelProvider>
|
||||
</SidebarProvider>
|
||||
</BreadcrumbProvider>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -297,22 +297,22 @@ export function AgentDetail() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{agent.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-bold truncate">{agent.name}</h2>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{roleLabels[agent.role] ?? agent.role}
|
||||
{agent.title ? ` - ${agent.title}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agentId })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Assign Task
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Assign Task</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -320,8 +320,8 @@ export function AgentDetail() {
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
Invoke
|
||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Invoke</span>
|
||||
</Button>
|
||||
{agent.status === "paused" ? (
|
||||
<Button
|
||||
@@ -330,8 +330,8 @@ export function AgentDetail() {
|
||||
onClick={() => agentAction.mutate("resume")}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
Resume
|
||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Resume</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -340,11 +340,11 @@ export function AgentDetail() {
|
||||
onClick={() => agentAction.mutate("pause")}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Pause className="h-3.5 w-3.5 mr-1" />
|
||||
Pause
|
||||
<Pause className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Pause</span>
|
||||
</Button>
|
||||
)}
|
||||
<StatusBadge status={agent.status} />
|
||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
||||
|
||||
{/* Overflow menu */}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
@@ -434,6 +434,8 @@ export function AgentDetail() {
|
||||
{ value: "costs", label: "Costs" },
|
||||
{ value: "keys", label: "API Keys" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* OVERVIEW TAB */}
|
||||
@@ -732,7 +734,7 @@ function ConfigurationTab({
|
||||
}, [onSavingChange, updateAgent.isPending]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<AgentConfigForm
|
||||
mode="edit"
|
||||
@@ -913,6 +915,23 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
queryKey: queryKeys.runIssues(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 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" />
|
||||
</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>
|
||||
@@ -1086,6 +1133,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
const [logLoading, setLogLoading] = useState(!!run.logRef);
|
||||
const [logError, setLogError] = useState<string | null>(null);
|
||||
const [logOffset, setLogOffset] = useState(0);
|
||||
const [isFollowing, setIsFollowing] = useState(true);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const pendingLogLineRef = useRef("");
|
||||
const isLive = run.status === "running" || run.status === "queued";
|
||||
@@ -1135,12 +1183,36 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
}
|
||||
}, [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(() => {
|
||||
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" });
|
||||
}
|
||||
}, [events, logLines, isLive]);
|
||||
}, [events, logLines, isLive, isFollowing]);
|
||||
|
||||
// Fetch persisted shell log
|
||||
useEffect(() => {
|
||||
@@ -1315,15 +1387,29 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Transcript ({transcript.length})
|
||||
</span>
|
||||
{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" />
|
||||
<div className="flex items-center gap-2">
|
||||
{isLive && !isFollowing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
|
||||
{transcript.length === 0 && !run.logRef && (
|
||||
@@ -1536,7 +1622,7 @@ function CostsTab({
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
{/* Cumulative totals */}
|
||||
{runtimeState && (
|
||||
<div className="border border-border rounded-lg p-4">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
@@ -78,6 +79,24 @@ export function Agents() {
|
||||
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 map = new Map<string, Agent>();
|
||||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
@@ -97,14 +116,18 @@ export function Agents() {
|
||||
|
||||
return (
|
||||
<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}`)}>
|
||||
<PageTabBar items={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
]} />
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
]}
|
||||
value={tab}
|
||||
onValueChange={(v) => navigate(`/agents/${v}`)}
|
||||
/>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filters */}
|
||||
@@ -217,6 +240,13 @@ export function Agents() {
|
||||
}
|
||||
trailing={
|
||||
<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">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
@@ -261,7 +291,7 @@ export function Agents() {
|
||||
{view === "org" && filteredOrg.length > 0 && (
|
||||
<div className="border border-border py-1">
|
||||
{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>
|
||||
)}
|
||||
@@ -286,11 +316,13 @@ function OrgTreeNode({
|
||||
depth,
|
||||
navigate,
|
||||
agentMap,
|
||||
liveRunByAgent,
|
||||
}: {
|
||||
node: OrgNode;
|
||||
depth: number;
|
||||
navigate: (path: string) => void;
|
||||
agentMap: Map<string, Agent>;
|
||||
liveRunByAgent: Map<string, { runId: string }>;
|
||||
}) {
|
||||
const agent = agentMap.get(node.id);
|
||||
|
||||
@@ -329,6 +361,13 @@ function OrgTreeNode({
|
||||
</span>
|
||||
</div>
|
||||
<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 && (
|
||||
<>
|
||||
<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 && (
|
||||
<div className="border-l border-border/50 ml-4">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Identity } from "../components/Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import type { Agent, Issue } from "@paperclip/shared";
|
||||
|
||||
function getRecentIssues(issues: Issue[]): Issue[] {
|
||||
@@ -271,8 +272,8 @@ export function Dashboard() {
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <Identity name={name} size="sm" className="shrink-0" />
|
||||
: <span className="text-xs text-muted-foreground font-mono shrink-0">{issue.assigneeAgentId.slice(0, 8)}</span>;
|
||||
? <Identity name={name} size="sm" className="shrink-0 hidden sm:flex" />
|
||||
: <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">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
@@ -283,6 +284,8 @@ export function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActiveAgentsPanel companyId={selectedCompanyId!} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -12,6 +13,7 @@ import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -20,11 +22,21 @@ import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ArrowUpRight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
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 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[] {
|
||||
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() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
@@ -57,7 +113,7 @@ export function Inbox() {
|
||||
setBreadcrumbs([{ label: "Inbox" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: approvals, isLoading, error } = useQuery({
|
||||
const { data: approvals, isLoading: isApprovalsLoading, error } = useQuery({
|
||||
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
||||
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
@@ -75,12 +131,34 @@ export function Inbox() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: heartbeatRuns } = useQuery({
|
||||
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (!id || !agents) return null;
|
||||
const agent = agents.find((a) => a.id === id);
|
||||
return agent?.name ?? null;
|
||||
if (!id) return null;
|
||||
return agentById.get(id) ?? null;
|
||||
};
|
||||
|
||||
const approveMutation = useMutation({
|
||||
@@ -112,35 +190,37 @@ export function Inbox() {
|
||||
(approval) => approval.status === "pending" || approval.status === "revision_requested",
|
||||
);
|
||||
const hasActionableApprovals = actionableApprovals.length > 0;
|
||||
const hasRunFailures = failedRuns.length > 0;
|
||||
const showAggregateAgentError =
|
||||
!!dashboard && dashboard.agents.error > 0 && !hasRunFailures;
|
||||
const hasAlerts =
|
||||
dashboard &&
|
||||
(dashboard.agents.error > 0 ||
|
||||
dashboard.costs.monthUtilizationPercent >= 80);
|
||||
!!dashboard &&
|
||||
(showAggregateAgentError || dashboard.costs.monthUtilizationPercent >= 80);
|
||||
const hasStale = staleIssues.length > 0;
|
||||
const hasContent = hasActionableApprovals || hasAlerts || hasStale;
|
||||
const hasContent = hasActionableApprovals || hasRunFailures || hasAlerts || hasStale;
|
||||
|
||||
return (
|
||||
<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>}
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
|
||||
{!isLoading && !hasContent && (
|
||||
{!isApprovalsLoading && !hasContent && (
|
||||
<EmptyState icon={InboxIcon} message="You're all caught up!" />
|
||||
)}
|
||||
|
||||
{/* Pending Approvals */}
|
||||
{hasActionableApprovals && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Approvals
|
||||
</h3>
|
||||
<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")}
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
@@ -159,21 +239,100 @@ export function Inbox() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerts */}
|
||||
{hasAlerts && (
|
||||
{/* Failed Runs */}
|
||||
{hasRunFailures && (
|
||||
<>
|
||||
{hasActionableApprovals && <Separator />}
|
||||
<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
|
||||
</h3>
|
||||
<div className="border border-border divide-y divide-border">
|
||||
{dashboard!.agents.error > 0 && (
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{showAggregateAgentError && (
|
||||
<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")}
|
||||
>
|
||||
<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="font-medium">{dashboard!.agents.error}</span>{" "}
|
||||
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
|
||||
@@ -182,10 +341,10 @@ export function Inbox() {
|
||||
)}
|
||||
{dashboard!.costs.monthUtilizationPercent >= 80 && (
|
||||
<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")}
|
||||
>
|
||||
<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">
|
||||
Budget at{" "}
|
||||
<span className="font-medium">
|
||||
@@ -203,32 +362,32 @@ export function Inbox() {
|
||||
{/* Stale Work */}
|
||||
{hasStale && (
|
||||
<>
|
||||
{(hasActionableApprovals || hasAlerts) && <Separator />}
|
||||
{(hasActionableApprovals || hasRunFailures || hasAlerts) && <Separator />}
|
||||
<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
|
||||
</h3>
|
||||
<div className="border border-border divide-y divide-border">
|
||||
<div className="divide-y divide-border border border-border">
|
||||
{staleIssues.map((issue) => (
|
||||
<div
|
||||
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}`)}
|
||||
>
|
||||
<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} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1">{issue.title}</span>
|
||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
@@ -9,7 +9,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { relativeTime, cn } from "../lib/utils";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
@@ -21,9 +21,9 @@ import { Identity } from "../components/Identity";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
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 { Agent } from "@paperclip/shared";
|
||||
import type { Agent, IssueAttachment } from "@paperclip/shared";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.created": "created the issue",
|
||||
@@ -49,6 +49,20 @@ function humanizeValue(value: unknown): string {
|
||||
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 {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
@@ -101,6 +115,8 @@ export function IssueDetail() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const { data: issue, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issueId!),
|
||||
@@ -133,6 +149,12 @@ export function IssueDetail() {
|
||||
enabled: !!issueId,
|
||||
});
|
||||
|
||||
const { data: attachments } = useQuery({
|
||||
queryKey: queryKeys.issues.attachments(issueId!),
|
||||
queryFn: () => issuesApi.listAttachments(issueId!),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
@@ -173,11 +195,53 @@ export function IssueDetail() {
|
||||
});
|
||||
}, [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 = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(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.activeRun(issueId!) });
|
||||
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(() => {
|
||||
setBreadcrumbs([
|
||||
{ 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)
|
||||
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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Parent chain breadcrumb */}
|
||||
@@ -357,6 +459,80 @@ export function IssueDetail() {
|
||||
|
||||
<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
|
||||
comments={commentsWithRunMeta}
|
||||
issueStatus={issue.status}
|
||||
@@ -437,6 +613,34 @@ export function IssueDetail() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function OrgTreeNode({
|
||||
<div>
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
@@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -18,6 +19,8 @@ export function ProjectDetail() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openPanel, closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: project, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.projects.detail(projectId!),
|
||||
@@ -33,6 +36,18 @@ export function ProjectDetail() {
|
||||
|
||||
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(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Projects", href: "/projects" },
|
||||
@@ -42,7 +57,7 @@ export function ProjectDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
openPanel(<ProjectProperties project={project} />);
|
||||
openPanel(<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />);
|
||||
}
|
||||
return () => closePanel();
|
||||
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -53,11 +68,22 @@ export function ProjectDetail() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{project.name}</h2>
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{project.description}</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<InlineEditor
|
||||
value={project.name}
|
||||
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>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
@@ -67,7 +93,7 @@ export function ProjectDetail() {
|
||||
</TabsList>
|
||||
|
||||
<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>
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<div className="mt-1">
|
||||
@@ -94,6 +120,7 @@ export function ProjectDetail() {
|
||||
identifier={issue.identifier ?? issue.id.slice(0, 8)}
|
||||
title={issue.title}
|
||||
trailing={<StatusBadge status={issue.status} />}
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user