UI: Identity component, LiveRunWidget, issue identifiers, and UX improvements

Add Identity component (avatar + name) used across agent/issue displays. Add
LiveRunWidget for real-time streaming of active heartbeat runs on issue detail
pages via WebSocket. Display issue identifiers (PAP-42) instead of UUID
fragments throughout Issues, Inbox, CommandPalette, and detail pages.
Enhance CommentThread with re-open checkbox, Cmd+Enter submit, sorted display,
and run linking. Improve Activity page with richer formatting and filtering.
Update Dashboard with live metrics. Add reports-to agent link in AgentProperties.
Various small fixes: StatusIcon centering, CopyText ref init, agent detail
run-issue cross-links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 09:10:07 -06:00
parent 224d150d86
commit ef7a7ecee8
24 changed files with 1066 additions and 196 deletions

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, useBeforeUnload, useSearchParams } from "
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
@@ -248,6 +249,7 @@ export function AgentDetail() {
);
const setActiveTab = useCallback((nextTab: string) => {
if (configDirty && !window.confirm("You have unsaved changes. Discard them?")) return;
const next = parseAgentDetailTab(nextTab);
// If we're on a /runs/:runId URL and switching tabs, navigate back to base agent URL
if (urlRunId) {
@@ -259,7 +261,7 @@ export function AgentDetail() {
if (next === "overview") params.delete("tab");
else params.set("tab", next);
setSearchParams(params);
}, [searchParams, setSearchParams, urlRunId, agentId, navigate]);
}, [searchParams, setSearchParams, urlRunId, agentId, navigate, configDirty]);
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
@@ -362,43 +364,45 @@ export function AgentDetail() {
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-background/90 backdrop-blur-sm flex items-center justify-between">
<PageTabBar
items={[
{ value: "overview", label: "Overview" },
{ value: "configuration", label: "Configuration" },
{ value: "runs", label: `Runs${heartbeats ? ` (${heartbeats.length})` : ""}` },
{ value: "issues", label: `Issues (${assignedIssues.length})` },
{ value: "costs", label: "Costs" },
{ value: "keys", label: "API Keys" },
]}
/>
<div
className={cn(
"flex items-center gap-2 transition-opacity duration-150",
activeTab === "configuration" && configDirty
? "opacity-100"
: "opacity-0 pointer-events-none"
)}
{/* Floating Save/Cancel — sticky so it's always reachable when scrolled */}
<div
className={cn(
"sticky top-6 z-10 float-right transition-opacity duration-150",
activeTab === "configuration" && configDirty
? "opacity-100"
: "opacity-0 pointer-events-none"
)}
>
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
<Button
variant="ghost"
size="sm"
onClick={() => cancelConfigActionRef.current?.()}
disabled={configSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
{configSaving ? "Saving..." : "Save"}
</Button>
</div>
Cancel
</Button>
<Button
size="sm"
onClick={() => saveConfigActionRef.current?.()}
disabled={configSaving}
>
{configSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<PageTabBar
items={[
{ value: "overview", label: "Overview" },
{ value: "configuration", label: "Configuration" },
{ value: "runs", label: `Runs${heartbeats ? ` (${heartbeats.length})` : ""}` },
{ value: "issues", label: `Issues (${assignedIssues.length})` },
{ value: "costs", label: "Costs" },
{ value: "keys", label: "API Keys" },
]}
/>
{/* OVERVIEW TAB */}
<TabsContent value="overview" className="space-y-6 mt-4">
@@ -520,7 +524,7 @@ export function AgentDetail() {
{assignedIssues.map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.id.slice(0, 8)}
identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title}
onClick={() => navigate(`/issues/${issue.id}`)}
trailing={<StatusBadge status={issue.status} />}
@@ -698,6 +702,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const metrics = runMetrics(run);
const [sessionOpen, setSessionOpen] = useState(false);
@@ -708,6 +713,11 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
},
});
const { data: touchedIssues } = useQuery({
queryKey: queryKeys.runIssues(run.id),
queryFn: () => activityApi.issuesForRun(run.id),
});
const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null;
const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null;
@@ -827,6 +837,28 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)}
</div>
{/* Issues touched by this run */}
{touchedIssues && touchedIssues.length > 0 && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground">Issues Touched ({touchedIssues.length})</span>
<div className="border border-border rounded-lg divide-y divide-border">
{touchedIssues.map((issue) => (
<button
key={issue.issueId}
className="flex items-center justify-between w-full px-3 py-2 text-xs hover:bg-accent/20 transition-colors text-left"
onClick={() => navigate(`/issues/${issue.issueId}`)}
>
<div className="flex items-center gap-2 min-w-0">
<StatusBadge status={issue.status} />
<span className="truncate">{issue.title}</span>
</div>
<span className="font-mono text-muted-foreground shrink-0 ml-2">{issue.issueId.slice(0, 8)}</span>
</button>
))}
</div>
</div>
)}
{/* stderr excerpt for failed runs */}
{run.stderrExcerpt && (
<div className="space-y-1">