From adca44849a754867128e289301f75a8ea8d82d01 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 10:32:32 -0600 Subject: [PATCH] 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 --- ui/src/api/activity.ts | 2 + ui/src/api/heartbeats.ts | 2 + ui/src/components/ActiveAgentsPanel.tsx | 402 ++++++++++++++++++ ui/src/components/AgentConfigForm.tsx | 127 +++++- ui/src/components/ApprovalPayload.tsx | 8 +- ui/src/components/BreadcrumbBar.tsx | 21 +- ui/src/components/GoalTree.tsx | 2 +- ui/src/components/IssueProperties.tsx | 148 +++++-- ui/src/components/Layout.tsx | 46 +- ui/src/components/NewIssueDialog.tsx | 21 +- ui/src/components/PriorityIcon.tsx | 16 +- ui/src/components/ProjectProperties.tsx | 3 +- ui/src/components/PropertiesPanel.tsx | 2 +- ui/src/components/Sidebar.tsx | 2 + ui/src/components/SidebarNavItem.tsx | 24 +- ui/src/components/StatusBadge.tsx | 1 + ui/src/components/StatusIcon.tsx | 16 +- ui/src/components/agent-config-primitives.tsx | 1 + ui/src/context/LiveUpdatesProvider.tsx | 1 + ui/src/context/SidebarContext.tsx | 43 ++ ui/src/lib/queryKeys.ts | 2 + ui/src/main.tsx | 3 + ui/src/pages/AgentDetail.tsx | 140 ++++-- ui/src/pages/Agents.tsx | 83 +++- ui/src/pages/Dashboard.tsx | 7 +- ui/src/pages/Inbox.tsx | 225 ++++++++-- ui/src/pages/IssueDetail.tsx | 212 ++++++++- ui/src/pages/Org.tsx | 2 +- ui/src/pages/ProjectDetail.tsx | 45 +- 29 files changed, 1461 insertions(+), 146 deletions(-) create mode 100644 ui/src/components/ActiveAgentsPanel.tsx create mode 100644 ui/src/context/SidebarContext.tsx diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index 50530e7d..98ef9190 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -9,6 +9,8 @@ export interface RunForIssue { finishedAt: string | null; createdAt: string; invocationSource: string; + usageJson: Record | null; + resultJson: Record | null; } export interface IssueForRun { diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 28195251..9acf0d8b 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -38,4 +38,6 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/live-runs`), activeRunForIssue: (issueId: string) => api.get(`/issues/${issueId}/active-run`), + liveRunsForCompany: (companyId: string) => + api.get(`/companies/${companyId}/live-runs`), }; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx new file mode 100644 index 00000000..11a1a9f8 --- /dev/null +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -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, + nextIdRef: MutableRefObject, +): 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, + nextIdRef: MutableRefObject, +): 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>(new Map()); + const seenKeysRef = useRef(new Set()); + const pendingByRunRef = useRef(new Map()); + 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(); + 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(); + 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 ( +
+

+ Active Agents +

+
+ {agentGroups.map((group) => ( + + ))} +
+
+ ); +} + +function AgentRunCard({ group, feed }: { group: AgentRunGroup; feed: FeedItem[] }) { + const bodyRef = useRef(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 ( +
+
+
+ + + + + + Live + {group.runs.length > 1 && ( + + ({group.runs.length} runs) + + )} +
+ {primaryRun && ( + + Open run + + + )} +
+ +
+ {recent.length === 0 && ( +
Waiting for output...
+ )} + {recent.map((item, index) => ( +
+ {relativeTime(item.ts)} + + {item.text} + +
+ ))} +
+ + {group.runs.length > 1 && ( +
+ {group.runs.map((run) => ( + + {run.id.slice(0, 8)} + + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index a31620b4..a6640b3f 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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 (
{/* ---- 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} /> + + + isCreate + ? set!({ thinkingEffort: v }) + : mark("adapterConfig", thinkingEffortKey, v || undefined) + } + open={thinkingEffortOpen} + onOpenChange={setThinkingEffortOpen} + /> + {adapterType === "codex_local" && + codexSearchEnabled && + currentThinkingEffort === "minimal" && ( +

+ Codex may reject `minimal` thinking when search is enabled. +

+ )} {isCreate ? ( 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 ( - + { + onOpenChange(nextOpen); + if (!nextOpen) setModelSearch(""); + }} + > + setModelSearch(e.target.value)} + autoFocus + /> - {models.map((m) => ( + {filteredModels.map((m) => ( ))} + {filteredModels.length === 0 && ( +

No models found.

+ )} +
+
+
+ ); +} + +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 ( + + + + + + + {options.map((option) => ( + + ))} diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index 7283cd1e..d2e10e6a 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -16,7 +16,7 @@ function PayloadField({ label, value }: { label: string; value: unknown }) { if (!value) return null; return (
- {label} + {label} {String(value)}
); @@ -26,20 +26,20 @@ export function HireAgentPayload({ payload }: { payload: Record return (
- Name + Name {String(payload.name ?? "—")}
{!!payload.capabilities && (
- Capabilities + Capabilities {String(payload.capabilities)}
)} {!!payload.adapterType && (
- Adapter + Adapter {String(payload.adapterType)} diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index cf1bb683..7f837678 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -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 && ( + + ); + // Single breadcrumb = page title (uppercase) if (breadcrumbs.length === 1) { return ( -
+
+ {menuButton}

{breadcrumbs[0].label}

@@ -28,7 +44,8 @@ export function BreadcrumbBar() { // Multiple breadcrumbs = breadcrumb trail return ( -
+
+ {menuButton} {breadcrumbs.map((crumb, i) => { diff --git a/ui/src/components/GoalTree.tsx b/ui/src/components/GoalTree.tsx index 96cd449b..920ccf75 100644 --- a/ui/src/components/GoalTree.tsx +++ b/ui/src/components/GoalTree.tsx @@ -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 ? ( diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 9dc2f4df..ec595e5b 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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) { onUpdate({ status })} + showLabel /> - {statusLabel(issue.status)} onUpdate({ priority })} + showLabel /> - {priorityLabel(issue.priority)} - {assignee ? ( + { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}> + + + + + setAssigneeSearch(e.target.value)} + autoFocus + /> + + {(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) => ( + + ))} + + + {issue.assigneeAgentId && ( e.stopPropagation()} > - + - ) : ( - Unassigned )} - {issue.projectId && ( - + + { setProjectOpen(open); if (!open) setProjectSearch(""); }}> + + + + + setProjectSearch(e.target.value)} + autoFocus + /> + + {(projects ?? []) + .filter((p) => { + if (!projectSearch.trim()) return true; + const q = projectSearch.toLowerCase(); + return p.name.toLowerCase().includes(q); + }) + .map((p) => ( + + ))} + + + {issue.projectId && ( e.stopPropagation()} > - {projectName(issue.projectId)} + - - )} + )} + {issue.parentId && ( diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index fbaafb45..a3ab08d6 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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 (
-
- -
+ {/* Mobile backdrop */} + {isMobile && sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + {isMobile ? ( +
+ +
+ ) : ( +
+ +
+ )} + + {/* Main content */}
-
+
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 87059789..00d8623d 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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() { {/* Assignee chip */} - + { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}> - + + setAssigneeSearch(e.target.value)} + autoFocus + /> - {(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) => ( + ) : icon; return ( - {icon} + {trigger} {allPriorities.map((p) => { const c = priorityConfig[p]!; diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 2128329d..4b6b12ba 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -5,6 +5,7 @@ import { Separator } from "@/components/ui/separator"; interface ProjectPropertiesProps { project: Project; + onUpdate?: (data: Record) => 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 (
diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx index 9a4e9cce..bc0e9346 100644 --- a/ui/src/components/PropertiesPanel.tsx +++ b/ui/src/components/PropertiesPanel.tsx @@ -9,7 +9,7 @@ export function PropertiesPanel() { if (!panelContent) return null; return ( -
{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 ( -
+
{/* Cumulative totals */} {runtimeState && (
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 5f4bb416..f9afb73b 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -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(); + 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(); for (const a of agents ?? []) map.set(a.id, a); @@ -97,14 +116,18 @@ export function Agents() { return (
-
+
navigate(`/agents/${v}`)}> - + navigate(`/agents/${v}`)} + />
{/* Filters */} @@ -217,6 +240,13 @@ export function Agents() { } trailing={
+ {liveRunByAgent.has(agent.id) && ( + + )} {adapterLabels[agent.adapterType] ?? agent.adapterType} @@ -261,7 +291,7 @@ export function Agents() { {view === "org" && filteredOrg.length > 0 && (
{filteredOrg.map((node) => ( - + ))}
)} @@ -286,11 +316,13 @@ function OrgTreeNode({ depth, navigate, agentMap, + liveRunByAgent, }: { node: OrgNode; depth: number; navigate: (path: string) => void; agentMap: Map; + liveRunByAgent: Map; }) { const agent = agentMap.get(node.id); @@ -329,6 +361,13 @@ function OrgTreeNode({
+ {liveRunByAgent.has(node.id) && ( + + )} {agent && ( <> @@ -364,10 +403,36 @@ function OrgTreeNode({ {node.reports && node.reports.length > 0 && (
{node.reports.map((child) => ( - + ))}
)}
); } + +function LiveRunIndicator({ + agentId, + runId, + navigate, +}: { + agentId: string; + runId: string; + navigate: (path: string) => void; +}) { + return ( + + ); +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index e2c44a00..24b7d4ec 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -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 - ? - : {issue.assigneeAgentId.slice(0, 8)}; + ? + : {issue.assigneeAgentId.slice(0, 8)}; })()} {timeAgo(issue.updatedAt)} @@ -283,6 +284,8 @@ export function Dashboard() { )}
+ + )}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 1d912bcb..0277b9ab 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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 = { + 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(); + + 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(); + for (const agent of agents ?? []) map.set(agent.id, agent.name); + return map; + }, [agents]); + + const issueById = useMemo(() => { + const map = new Map(); + 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 (
- {isLoading &&

Loading...

} + {isApprovalsLoading &&

Loading...

} {error &&

{error.message}

} {actionError &&

{actionError}

} - {!isLoading && !hasContent && ( + {!isApprovalsLoading && !hasContent && ( )} {/* Pending Approvals */} {hasActionableApprovals && (
-
-

+
+

Approvals

@@ -159,21 +239,100 @@ export function Inbox() {
)} - {/* Alerts */} - {hasAlerts && ( + {/* Failed Runs */} + {hasRunFailures && ( <> {hasActionableApprovals && }
-

+

+ Failed Runs +

+
+ {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 ( +
+
+
+
+
+
+ + + + {linkedAgentName + ? + : Agent {run.agentId.slice(0, 8)}} + +
+

+ {sourceLabel} run failed {timeAgo(run.createdAt)} +

+
+ +
+ +
+ {displayError} +
+ +
+ run {run.id.slice(0, 8)} + {issue ? ( + + ) : ( + + {run.errorCode ? `code: ${run.errorCode}` : "No linked issue"} + + )} +
+
+
+ ); + })} +
+
+ + )} + + {/* Alerts */} + {hasAlerts && ( + <> + {(hasActionableApprovals || hasRunFailures) && } +
+

Alerts

-
- {dashboard!.agents.error > 0 && ( +
+ {showAggregateAgentError && (
navigate("/agents")} > - + {dashboard!.agents.error}{" "} {dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors @@ -182,10 +341,10 @@ export function Inbox() { )} {dashboard!.costs.monthUtilizationPercent >= 80 && (
navigate("/costs")} > - + Budget at{" "} @@ -203,32 +362,32 @@ export function Inbox() { {/* Stale Work */} {hasStale && ( <> - {(hasActionableApprovals || hasAlerts) && } + {(hasActionableApprovals || hasRunFailures || hasAlerts) && }
-

+

Stale Work

-
+
{staleIssues.map((issue) => (
navigate(`/issues/${issue.id}`)} > - + {issue.identifier ?? issue.id.slice(0, 8)} - {issue.title} + {issue.title} {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name ? - : {issue.assigneeAgentId.slice(0, 8)}; + : {issue.assigneeAgentId.slice(0, 8)}; })()} - + updated {timeAgo(issue.updatedAt)}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 37af1c4b..2a11e8f3 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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 = { "issue.created": "created the issue", @@ -49,6 +49,20 @@ function humanizeValue(value: unknown): string { return value.replace(/_/g, " "); } +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function usageNumber(usage: Record | 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 | null): string { if (action === "issue.updated" && details) { const previous = (details._previous ?? {}) as Record; @@ -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(null); + const fileInputRef = useRef(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) => { + 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 (
{/* Parent chain breadcrumb */} @@ -357,6 +459,80 @@ export function IssueDetail() { +
+
+

Attachments

+
+ + +
+
+ + {attachmentError && ( +

{attachmentError}

+ )} + + {(!attachments || attachments.length === 0) ? ( +

No attachments yet.

+ ) : ( +
+ {attachments.map((attachment) => ( +
+
+ + {attachment.originalFilename ?? attachment.id} + + +
+

+ {attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB +

+ {isImageAttachment(attachment) && ( + + {attachment.originalFilename + + )} +
+ ))} +
+ )} +
+ + + )} + + {(linkedRuns && linkedRuns.length > 0) && ( + <> + +
+

Cost

+ {!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? ( +
No cost data yet.
+ ) : ( +
+ {issueCostSummary.hasCost && ( + + ${issueCostSummary.cost.toFixed(4)} + + )} + {issueCostSummary.hasTokens && ( + + 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)})`} + + )} +
+ )} +
+ + )}
); } diff --git a/ui/src/pages/Org.tsx b/ui/src/pages/Org.tsx index 48cca8f9..b03e736d 100644 --- a/ui/src/pages/Org.tsx +++ b/ui/src/pages/Org.tsx @@ -44,7 +44,7 @@ function OrgTreeNode({
onSelect(node.id)} > {hasChildren ? ( diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index e24e9e85..c4bbe831 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -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) => projectsApi.update(projectId!, data), + onSuccess: invalidateProject, + }); + useEffect(() => { setBreadcrumbs([ { label: "Projects", href: "/projects" }, @@ -42,7 +57,7 @@ export function ProjectDetail() { useEffect(() => { if (project) { - openPanel(); + openPanel( updateProject.mutate(data)} />); } return () => closePanel(); }, [project]); // eslint-disable-line react-hooks/exhaustive-deps @@ -53,11 +68,22 @@ export function ProjectDetail() { return (
-
-

{project.name}

- {project.description && ( -

{project.description}

- )} +
+ updateProject.mutate({ name })} + as="h2" + className="text-xl font-bold" + /> + + updateProject.mutate({ description })} + as="p" + className="text-sm text-muted-foreground" + placeholder="Add a description..." + multiline + />
@@ -67,7 +93,7 @@ export function ProjectDetail() { -
+
Status
@@ -94,6 +120,7 @@ export function ProjectDetail() { identifier={issue.identifier ?? issue.id.slice(0, 8)} title={issue.title} trailing={} + onClick={() => navigate(`/issues/${issue.id}`)} /> ))}