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}`)} /> ))}