feat: Add Details tab to agent right-panel with metadata, input files, and prompt sections
Adds an Output/Details tab bar to the agents page right-panel. The Details tab renders AgentDetailsPanel, which surfaces agent metadata (status, mode, provider, initiative link, task name, exit code), input files with a file-picker UI, and the effective prompt text — all streamed via the new getAgent/getAgentInputFiles/getAgentPrompt tRPC procedures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal file
230
apps/web/src/components/AgentDetailsPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Skeleton } from "@/components/Skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { StatusDot } from "@/components/StatusDot";
|
||||||
|
import { formatRelativeTime } from "@/lib/utils";
|
||||||
|
import { modeLabel } from "@/lib/labels";
|
||||||
|
|
||||||
|
export function AgentDetailsPanel({ agentId }: { agentId: string }) {
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-4 space-y-6">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Metadata</h3>
|
||||||
|
<MetadataSection agentId={agentId} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Input Files</h3>
|
||||||
|
<InputFilesSection agentId={agentId} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Effective Prompt</h3>
|
||||||
|
<EffectivePromptSection agentId={agentId} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataSection({ agentId }: { agentId: string }) {
|
||||||
|
const query = trpc.getAgent.useQuery({ id: agentId });
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} variant="line" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = query.data;
|
||||||
|
if (!agent) return null;
|
||||||
|
|
||||||
|
const showExitCode = !['idle', 'running', 'waiting_for_input'].includes(agent.status);
|
||||||
|
|
||||||
|
const rows: Array<{ label: string; value: React.ReactNode }> = [
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
value: (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<StatusDot status={agent.status} size="sm" />
|
||||||
|
{agent.status}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mode',
|
||||||
|
value: modeLabel(agent.mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Provider',
|
||||||
|
value: agent.provider,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Initiative',
|
||||||
|
value: agent.initiativeId ? (
|
||||||
|
<Link
|
||||||
|
to="/initiatives/$initiativeId"
|
||||||
|
params={{ initiativeId: agent.initiativeId }}
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{(agent as { initiativeName?: string | null }).initiativeName ?? agent.initiativeId}
|
||||||
|
</Link>
|
||||||
|
) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Task',
|
||||||
|
value: (agent as { taskName?: string | null }).taskName ?? (agent.taskId ? agent.taskId : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Created',
|
||||||
|
value: formatRelativeTime(String(agent.createdAt)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (showExitCode) {
|
||||||
|
rows.push({
|
||||||
|
label: 'Exit Code',
|
||||||
|
value: (
|
||||||
|
<span className={agent.exitCode === 1 ? 'text-destructive' : ''}>
|
||||||
|
{agent.exitCode ?? '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{rows.map(({ label, value }) => (
|
||||||
|
<div key={label} className="flex items-center gap-4 py-1.5 border-b border-border/30 last:border-0">
|
||||||
|
<span className="w-28 shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-sm">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputFilesSection({ agentId }: { agentId: string }) {
|
||||||
|
const query = trpc.getAgentInputFiles.useQuery({ id: agentId });
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.data?.files) return;
|
||||||
|
if (selectedFile !== null) return;
|
||||||
|
const manifest = query.data.files.find(f => f.name === 'manifest.json');
|
||||||
|
setSelectedFile(manifest?.name ?? query.data.files[0]?.name ?? null);
|
||||||
|
}, [query.data?.files]);
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton variant="line" />
|
||||||
|
<Skeleton variant="line" />
|
||||||
|
<Skeleton variant="line" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.reason === 'worktree_missing') {
|
||||||
|
return <p className="text-sm text-muted-foreground">Worktree no longer exists — input files unavailable</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.reason === 'input_dir_missing') {
|
||||||
|
return <p className="text-sm text-muted-foreground">Input directory not found — this agent may not have received input files</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { files } = data;
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No input files found</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row gap-2 min-h-0">
|
||||||
|
{/* File list */}
|
||||||
|
<div className="md:w-48 shrink-0 overflow-y-auto space-y-0.5">
|
||||||
|
{files.map(file => (
|
||||||
|
<button
|
||||||
|
key={file.name}
|
||||||
|
onClick={() => setSelectedFile(file.name)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-2 py-1 text-xs rounded truncate",
|
||||||
|
selectedFile === file.name
|
||||||
|
? "bg-muted font-medium"
|
||||||
|
: "hover:bg-muted/50 text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Content pane */}
|
||||||
|
<pre className="flex-1 text-xs font-mono overflow-auto bg-terminal rounded p-3 min-h-0">
|
||||||
|
{files.find(f => f.name === selectedFile)?.content ?? ''}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EffectivePromptSection({ agentId }: { agentId: string }) {
|
||||||
|
const query = trpc.getAgentPrompt.useQuery({ id: agentId });
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return <Skeleton variant="rect" className="h-32 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-destructive">{query.error.message}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void query.refetch()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.reason === 'prompt_not_written') {
|
||||||
|
return <p className="text-sm text-muted-foreground">Prompt file not available — agent may have been spawned before this feature was added</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.content) {
|
||||||
|
return (
|
||||||
|
<pre className="text-xs font-mono overflow-y-auto max-h-[400px] bg-terminal rounded p-3 whitespace-pre-wrap">
|
||||||
|
{data.content}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react";
|
import { AlertCircle, RefreshCw, Terminal, Users } from "lucide-react";
|
||||||
@@ -9,8 +9,9 @@ import { Skeleton } from "@/components/Skeleton";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||||
|
import { AgentDetailsPanel } from "@/components/AgentDetailsPanel";
|
||||||
import { AgentActions } from "@/components/AgentActions";
|
import { AgentActions } from "@/components/AgentActions";
|
||||||
import { formatRelativeTime } from "@/lib/utils";
|
import { formatRelativeTime, cn } from "@/lib/utils";
|
||||||
import { modeLabel } from "@/lib/labels";
|
import { modeLabel } from "@/lib/labels";
|
||||||
import { StatusDot } from "@/components/StatusDot";
|
import { StatusDot } from "@/components/StatusDot";
|
||||||
import { useLiveUpdates } from "@/hooks";
|
import { useLiveUpdates } from "@/hooks";
|
||||||
@@ -29,7 +30,12 @@ export const Route = createFileRoute("/agents")({
|
|||||||
|
|
||||||
function AgentsPage() {
|
function AgentsPage() {
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'output' | 'details'>('output');
|
||||||
const { filter } = useSearch({ from: "/agents" });
|
const { filter } = useSearch({ from: "/agents" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab('output');
|
||||||
|
}, [selectedAgentId]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Live updates
|
// Live updates
|
||||||
@@ -308,15 +314,49 @@ function AgentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Output Viewer */}
|
{/* Right: Output/Details Viewer */}
|
||||||
<div className="min-h-0 overflow-hidden">
|
<div className="min-h-0 overflow-hidden">
|
||||||
{selectedAgent ? (
|
{selectedAgent ? (
|
||||||
<AgentOutputViewer
|
<div className="flex flex-col min-h-0 h-full">
|
||||||
agentId={selectedAgent.id}
|
{/* Tab bar */}
|
||||||
agentName={selectedAgent.name}
|
<div className="flex shrink-0 border-b border-terminal-border">
|
||||||
status={selectedAgent.status}
|
<button
|
||||||
onStop={handleStop}
|
className={cn(
|
||||||
/>
|
"px-4 py-2 text-sm font-medium",
|
||||||
|
activeTab === 'output'
|
||||||
|
? "border-b-2 border-primary text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('output')}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium",
|
||||||
|
activeTab === 'details'
|
||||||
|
? "border-b-2 border-primary text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab('details')}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Panel content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{activeTab === 'output' ? (
|
||||||
|
<AgentOutputViewer
|
||||||
|
agentId={selectedAgent.id}
|
||||||
|
agentName={selectedAgent.name}
|
||||||
|
status={selectedAgent.status}
|
||||||
|
onStop={handleStop}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AgentDetailsPanel agentId={selectedAgent.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed">
|
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed">
|
||||||
<Terminal className="h-10 w-10 text-muted-foreground/30" />
|
<Terminal className="h-10 w-10 text-muted-foreground/30" />
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ Use `mapEntityStatus(rawStatus)` from `StatusDot.tsx` to convert raw entity stat
|
|||||||
|-------|-----------|---------|
|
|-------|-----------|---------|
|
||||||
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
| `/` | `routes/index.tsx` | Dashboard / initiative list |
|
||||||
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
|
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
|
||||||
|
| `/agents` | `routes/agents.tsx` | Agent list with Output / Details tab panel |
|
||||||
| `/settings` | `routes/settings/index.tsx` | Settings page |
|
| `/settings` | `routes/settings/index.tsx` | Settings page |
|
||||||
|
|
||||||
## Initiative Detail Tabs
|
## Initiative Detail Tabs
|
||||||
@@ -54,7 +55,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|
|||||||
2. **Execution Tab** — Pipeline visualization, phase management, task dispatch
|
2. **Execution Tab** — Pipeline visualization, phase management, task dispatch
|
||||||
3. **Review Tab** — Pending proposals from agents
|
3. **Review Tab** — Pending proposals from agents
|
||||||
|
|
||||||
## Component Inventory (73 components)
|
## Component Inventory (74 components)
|
||||||
|
|
||||||
### Core Components (`src/components/`)
|
### Core Components (`src/components/`)
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
@@ -66,6 +67,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|
|||||||
| `StatusBadge` | Colored badge using status tokens |
|
| `StatusBadge` | Colored badge using status tokens |
|
||||||
| `TaskRow` | Task list item with status, priority, category |
|
| `TaskRow` | Task list item with status, priority, category |
|
||||||
| `QuestionForm` | Agent question form with options |
|
| `QuestionForm` | Agent question form with options |
|
||||||
|
| `AgentDetailsPanel` | Details tab for agent right-panel: metadata, input files, effective prompt |
|
||||||
| `InboxDetailPanel` | Agent message detail + response form |
|
| `InboxDetailPanel` | Agent message detail + response form |
|
||||||
| `ProjectPicker` | Checkbox list for project selection |
|
| `ProjectPicker` | Checkbox list for project selection |
|
||||||
| `RegisterProjectDialog` | Dialog to register new git project |
|
| `RegisterProjectDialog` | Dialog to register new git project |
|
||||||
|
|||||||
Reference in New Issue
Block a user