Compare commits

...

7 Commits

Author SHA1 Message Date
Jiayuan Zhang
5ed2d0e067 feat(sessions): add rich metadata to session cards
Backend:
- Enhance ListWorkspaceTasks SQL to JOIN with issue (title, number),
  task_usage (input/output tokens), and task_message (tool count, events)
- New WorkspaceTaskResponse type with issue_title, issue_number,
  total_input_tokens, total_output_tokens, tool_use_count, total_events
- Compute issue identifier (prefix-number) in handler

Frontend:
- New WorkspaceTask type extending AgentTask with metadata fields
- Redesign session rows as cards showing:
  - Agent avatar + name + status dot
  - Issue number + title (from backend join, no more client-side lookup)
  - Metadata chips: duration, tool calls, event count, token usage, timestamp
  - Active tasks highlighted with accent border + background
- Remove dependency on issue list query (data comes from backend now)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:13:31 +08:00
Jiayuan Zhang
581693ad12 fix(views): redesign Sessions page and open transcript on click
Sessions page:
- Show issue identifier + title per row (joined from issue cache)
- Table-style layout with columns: Agent, Session info, Status, Duration
- Active tasks highlighted with accent background + pulse dot
- Sorted: active first, then by creation time
- Duration shows elapsed time for active, total time for completed
- Removed Active/Recent section headers in favor of inline indicators

Click behavior:
- Clicking a session opens the AgentTranscriptDialog with full transcript
- Loads task messages on demand via api.listTaskMessages
- Live sessions stream new messages via WS task:message events

Also export AgentTranscriptDialog from issues/components index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:02:46 +08:00
Jiayuan Zhang
50b08506fe feat: add Sessions page with workspace-wide task list
Remove charts:
- Delete Gantt chart and token consumption chart components
- Remove analytics view mode toggle from transcript dialog
- Restore clean single-view layout

Backend:
- Add ListWorkspaceTasks SQL query (joins agent_task_queue with agent
  to filter by workspace_id, ordered by created_at DESC, limit param)
- Add GET /api/workspaces/{id}/tasks endpoint with limit query param
- Regenerate sqlc

Frontend:
- Add listWorkspaceTasks API method + workspaceTasksOptions TanStack query
- Add SessionsPage component in packages/views/sessions/ showing:
  - Active sessions (running/dispatched/queued) with elapsed time
  - Recent sessions (completed/failed/cancelled) with completion time
  - Agent avatar, name, issue link, status badge per row
  - Real-time updates via WS events (task:dispatch/completed/failed/cancelled)
- Add /sessions route + sidebar navigation entry (Zap icon)
- Export from packages/views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:14:10 +08:00
Jiayuan Zhang
d1e8d8c2ca fix(views): rewrite Gantt chart with recharts range bars, remove price estimate
Gantt chart:
- Use recharts BarChart with [start, end] range bar data instead of
  custom CSS. Each tool call gets its own row with descriptive label
  (e.g., "Bash: npm install", "Read: …/file.tsx").
- Custom bar shape renderer for per-bar colors by tool type.
- Click navigates to the event in timeline view.

Token chart:
- Remove price estimate (not based on actual usage data). The DB has
  a task_usage table with real token counts but no API endpoint yet.
- Token estimates clearly labeled as "estimated from content length"
  with ~ prefix on all numbers.
- Keep input/output separation (tool_result→input, text/thinking→output).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:01:27 +08:00
Jiayuan Zhang
b3121c3cf5 fix(views): redesign Gantt chart and improve token chart
Gantt chart:
- Rewrite as CSS-based chart (no more recharts stacked bar hack with
  invisible offset bars that rendered as black)
- Each operation gets its own row with descriptive label
- Proper per-tool colors (Bash=blue, Read=green, Edit=orange, etc.)
- Hover shows operation summary, click navigates to event
- Dynamic legend from actual tools used
- Scrollable when many operations

Token chart:
- Separate input tokens (tool results, errors fed to model) from
  output tokens (text, thinking, tool calls generated by model)
- Stacked area chart for input vs output
- Add cost estimate based on Claude Sonnet 4 pricing ($3/M input,
  $15/M output)
- Tooltip shows per-event input/output breakdown and cost

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:51:36 +08:00
Jiayuan Zhang
08896d23ee feat(views): add Gantt chart and token consumption curve to transcript dialog
Add an "Analytics" view mode toggle to the transcript dialog header:

- **Execution Gantt Chart**: Horizontal bar chart showing tool call spans
  by sequence number. Color-coded by tool type (Bash, Read, Edit, etc.)
  and event type (thinking, text, error). Clicking a bar navigates to
  the corresponding event in timeline view.

- **Token Consumption Curve**: Area chart showing estimated cumulative
  token usage across events. Red dashed reference lines mark error events.
  Displays total and per-event average token estimates. Clicking a point
  navigates to that event.

Both charts use the existing recharts + shadcn ChartContainer setup.
Charts are interactive — clicking elements switches back to timeline
mode and selects the corresponding event.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:43:18 +08:00
Jiayuan Zhang
19f257d18a feat(views): add detail sidebar to agent transcript dialog
Replace inline collapsible expansion with a master-detail split layout.
Clicking an event now shows rich content in a right sidebar panel:
- Tool inputs: structured display (bash commands, file paths, diffs, search queries)
- Tool results: auto-detect JSON for formatted display, preformatted text otherwise
- Agent text: rendered as Markdown
- Thinking/errors: styled appropriately
- Collapsible "Raw input" toggle for full JSON when structured view is shown

Dialog widened to 85vw to accommodate the 40/60 split layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:23:14 +08:00
15 changed files with 871 additions and 121 deletions

View File

@@ -19,6 +19,7 @@ import {
FolderKanban,
Search,
Ellipsis,
Zap,
} from "lucide-react";
import { WorkspaceAvatar } from "@multica/views/workspace/workspace-avatar";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
@@ -64,6 +65,7 @@ const workspaceNav = [
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/projects", label: "Projects", icon: FolderKanban },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/sessions", label: "Sessions", icon: Zap },
];
const configureNav = [

View File

@@ -0,0 +1 @@
export { SessionsPage as default } from "@multica/views/sessions";

View File

@@ -12,6 +12,7 @@ import type {
CreateAgentRequest,
UpdateAgentRequest,
AgentTask,
WorkspaceTask,
AgentRuntime,
InboxItem,
IssueSubscriber,
@@ -406,6 +407,10 @@ export class ApiClient {
return this.fetch(`/api/agents/${agentId}/tasks`);
}
async listWorkspaceTasks(workspaceId: string, limit = 50): Promise<WorkspaceTask[]> {
return this.fetch(`/api/workspaces/${workspaceId}/tasks?limit=${limit}`);
}
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
return this.fetch(`/api/issues/${issueId}/active-task`);
}

View File

@@ -37,6 +37,15 @@ export interface AgentTask {
created_at: string;
}
export interface WorkspaceTask extends AgentTask {
issue_title: string;
issue_number: number;
total_input_tokens: number;
total_output_tokens: number;
tool_use_count: number;
total_events: number;
}
export interface Agent {
id: string;
workspace_id: string;

View File

@@ -5,6 +5,7 @@ export type {
AgentRuntimeMode,
AgentVisibility,
AgentTask,
WorkspaceTask,
AgentRuntime,
RuntimeDevice,
CreateAgentRequest,

View File

@@ -7,6 +7,7 @@ export const workspaceKeys = {
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
tasks: (wsId: string) => ["workspaces", wsId, "tasks"] as const,
};
export function workspaceListOptions() {
@@ -37,3 +38,10 @@ export function skillListOptions(wsId: string) {
queryFn: () => api.listSkills(),
});
}
export function workspaceTasksOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.tasks(wsId),
queryFn: () => api.listWorkspaceTasks(wsId),
});
}

View File

@@ -16,11 +16,14 @@ import {
Monitor,
Cloud,
Cpu,
FileCode,
Search,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible";
import { ActorAvatar } from "../../common/actor-avatar";
import { Markdown } from "../../common/markdown";
import { api } from "@multica/core/api";
import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent";
import { redactSecrets } from "../utils/redact";
@@ -203,7 +206,7 @@ export function AgentTranscriptDialog({
return () => clearInterval(interval);
}, [isLive, task.started_at, task.dispatched_at]);
// Click a timeline segment → scroll to event
// Click a timeline segment → scroll to event and select it
const handleSegmentClick = useCallback((idx: number) => {
setSelectedIdx(idx);
const el = eventRefs.current.get(idx);
@@ -259,10 +262,13 @@ export function AgentTranscriptDialog({
</span>
);
// Resolve selected item safely
const selectedItem = selectedIdx !== null && selectedIdx < items.length ? items[selectedIdx]! : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="!max-w-4xl !w-[calc(100vw-4rem)] !max-h-[calc(100vh-4rem)] !h-[calc(100vh-4rem)] flex flex-col !p-0 !gap-0 overflow-hidden"
className="!max-w-[85vw] !w-[calc(100vw-4rem)] !max-h-[calc(100vh-4rem)] !h-[calc(100vh-4rem)] flex flex-col !p-0 !gap-0 overflow-hidden"
showCloseButton={false}
>
<DialogTitle className="sr-only">Agent Execution Transcript</DialogTitle>
@@ -365,38 +371,52 @@ export function AgentTranscriptDialog({
</div>
)}
{/* ── Event list ─────────────────────────────────────────── */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0"
>
{items.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{isLive ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Waiting for events...
</div>
) : (
"No execution data recorded."
)}
</div>
) : (
<div className="divide-y">
{items.map((item, idx) => (
<TranscriptEventRow
key={`${item.seq}-${idx}`}
ref={(el) => {
if (el) eventRefs.current.set(idx, el);
else eventRefs.current.delete(idx);
}}
item={item}
index={idx}
isSelected={selectedIdx === idx}
onClick={() => setSelectedIdx(idx === selectedIdx ? null : idx)}
/>
))}
</div>
{/* ── Split content: event list + detail panel ────────── */}
<div className="flex-1 flex min-h-0">
{/* Left: Event list */}
<div
ref={scrollContainerRef}
className={cn(
"overflow-y-auto min-h-0 transition-[width] duration-200",
selectedItem ? "w-[40%] border-r" : "w-full",
)}
>
{items.length === 0 ? (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
{isLive ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Waiting for events...
</div>
) : (
"No execution data recorded."
)}
</div>
) : (
<div className="divide-y">
{items.map((item, idx) => (
<TranscriptEventRow
key={`${item.seq}-${idx}`}
ref={(el) => {
if (el) eventRefs.current.set(idx, el);
else eventRefs.current.delete(idx);
}}
item={item}
index={idx}
isSelected={selectedIdx === idx}
onClick={() => setSelectedIdx(idx === selectedIdx ? null : idx)}
/>
))}
</div>
)}
</div>
{/* Right: Detail panel */}
{selectedItem && (
<DetailPanel
item={selectedItem}
onClose={() => setSelectedIdx(null)}
/>
)}
</div>
</DialogContent>
@@ -404,8 +424,6 @@ export function AgentTranscriptDialog({
);
}
// ─── Timeline bar (colored segments) ────────────────────────────────────────
// ─── Metadata chip ──────────────────────────────────────────────────────────
function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
@@ -491,7 +509,7 @@ function TimelineBar({
);
}
// ─── Transcript event row ───────────────────────────────────────────────────
// ─── Transcript event row (click to select → shows in detail panel) ──────
interface TranscriptEventRowProps {
item: TimelineItem;
@@ -503,122 +521,144 @@ interface TranscriptEventRowProps {
const TranscriptEventRow = ({
ref,
item,
index,
isSelected,
onClick,
}: TranscriptEventRowProps & { ref?: React.Ref<HTMLDivElement> }) => {
const [expanded, setExpanded] = useState(false);
const color = getEventColor(item);
const label = getEventLabel(item);
const summary = getEventSummary(item);
const hasDetail =
(item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) ||
(item.type === "tool_result" && item.output && item.output.length > 0) ||
(item.type === "thinking" && item.content && item.content.length > 0) ||
(item.type === "text" && item.content && item.content.split("\n").length > 1) ||
(item.type === "error" && item.content && item.content.length > 0);
return (
<div
ref={ref}
className={cn(
"group transition-colors",
"group transition-colors cursor-pointer hover:bg-accent/30",
isSelected && "bg-accent/50",
)}
onClick={onClick}
>
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="flex items-start gap-2 px-4 py-2">
{/* Type label badge */}
<span
className={cn(
"inline-flex items-center shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium mt-0.5 min-w-[60px] justify-center",
colorClasses[color].label,
)}
>
{item.type === "thinking" && <Brain className="h-3 w-3 mr-1 shrink-0" />}
{item.type === "error" && <AlertCircle className="h-3 w-3 mr-1 shrink-0" />}
{label}
</span>
<div className="flex items-start gap-2 px-4 py-2">
{/* Type label badge */}
<span
className={cn(
"inline-flex items-center shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium mt-0.5 min-w-[60px] justify-center",
colorClasses[color].label,
)}
>
{item.type === "thinking" && <Brain className="h-3 w-3 mr-1 shrink-0" />}
{item.type === "error" && <AlertCircle className="h-3 w-3 mr-1 shrink-0" />}
{label}
</span>
{/* Summary */}
<CollapsibleTrigger
className={cn(
"flex-1 text-left text-xs min-w-0 py-0.5 transition-colors",
hasDetail ? "cursor-pointer hover:text-foreground" : "cursor-default",
item.type === "error" ? "text-destructive" : "text-muted-foreground",
)}
disabled={!hasDetail}
>
<div className="flex items-start gap-1.5">
{hasDetail && (
<ChevronRight
className={cn(
"h-3 w-3 shrink-0 mt-0.5 text-muted-foreground/50 transition-transform",
expanded && "rotate-90",
)}
/>
)}
<span className="truncate">{summary || "(empty)"}</span>
</div>
</CollapsibleTrigger>
{/* Summary */}
<span
className={cn(
"flex-1 text-xs min-w-0 py-0.5 truncate",
item.type === "error" ? "text-destructive" : "text-muted-foreground",
)}
>
{summary || "(empty)"}
</span>
{/* Seq number / index */}
<span className="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums mt-1">
#{item.seq}
</span>
</div>
{/* Expanded detail */}
{hasDetail && (
<CollapsibleContent>
<div className="px-4 pb-3">
<div className="ml-[72px] rounded bg-muted/40 border">
<EventDetailContent item={item} />
</div>
</div>
</CollapsibleContent>
)}
</Collapsible>
{/* Seq number */}
<span className="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums mt-1">
#{item.seq}
</span>
</div>
</div>
);
};
// ─── Event detail content ───────────────────────────────────────────────────
// ─── Detail panel (sidebar) ──────────────────────────────────────────────
function EventDetailContent({ item }: { item: TimelineItem }) {
function DetailPanel({ item, onClose }: { item: TimelineItem; onClose: () => void }) {
const [copied, setCopied] = useState(false);
const rawContent =
item.type === "tool_use"
? item.input
? redactSecrets(JSON.stringify(item.input, null, 2))
: ""
: item.type === "tool_result"
? redactSecrets(item.output ?? "")
: redactSecrets(item.content ?? "");
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(rawContent).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [rawContent]);
const color = getEventColor(item);
return (
<div className="w-[60%] flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b shrink-0">
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium",
colorClasses[color].label,
)}
>
{item.type === "thinking" && <Brain className="h-3 w-3 mr-1" />}
{item.type === "error" && <AlertCircle className="h-3 w-3 mr-1" />}
{getEventLabel(item)}
</span>
{item.type === "tool_result" && (
<span className="text-xs text-muted-foreground">result</span>
)}
<span className="text-[10px] text-muted-foreground/50">#{item.seq}</span>
<div className="ml-auto flex items-center gap-1">
<button
onClick={handleCopy}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "Copied" : "Copy"}
</button>
<button
onClick={onClose}
className="flex items-center justify-center rounded p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<DetailContent item={item} />
</div>
</div>
);
}
// ─── Rich content rendering ──────────────────────────────────────────────
function DetailContent({ item }: { item: TimelineItem }) {
switch (item.type) {
case "tool_use":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
</pre>
);
return <ToolUseDetail item={item} />;
case "tool_result":
return <ToolResultDetail item={item} />;
case "text":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{item.output
? item.output.length > 4000
? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
: redactSecrets(item.output)
: ""}
</pre>
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown mode="minimal">{item.content ?? ""}</Markdown>
</div>
);
case "thinking":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
<div className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
{item.content ?? ""}
</pre>
);
case "text":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
{item.content ?? ""}
</pre>
</div>
);
case "error":
return (
<pre className="max-h-60 overflow-auto p-3 text-[11px] text-destructive whitespace-pre-wrap break-words">
<pre className="text-sm text-destructive whitespace-pre-wrap break-words font-mono">
{item.content ?? ""}
</pre>
);
@@ -626,3 +666,155 @@ function EventDetailContent({ item }: { item: TimelineItem }) {
return null;
}
}
function ToolUseDetail({ item }: { item: TimelineItem }) {
const [showRaw, setShowRaw] = useState(false);
if (!item.input) return null;
const inp = item.input;
const command = typeof inp.command === "string" ? inp.command : undefined;
const description = typeof inp.description === "string" ? inp.description : undefined;
const filePath = typeof inp.file_path === "string" ? inp.file_path : typeof inp.path === "string" ? inp.path : undefined;
const oldString = typeof inp.old_string === "string" ? inp.old_string : undefined;
const newString = typeof inp.new_string === "string" ? inp.new_string : undefined;
const content = typeof inp.content === "string" ? inp.content : undefined;
const query = typeof inp.query === "string" ? inp.query : undefined;
const pattern = typeof inp.pattern === "string" ? inp.pattern : undefined;
const prompt = typeof inp.prompt === "string" ? inp.prompt : undefined;
const skill = typeof inp.skill === "string" ? inp.skill : undefined;
const hasStructuredView = !!(command || filePath || query || pattern || prompt || skill);
return (
<div className="space-y-3 text-sm">
{/* Bash command */}
{command && (
<div className="space-y-1.5">
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<Markdown mode="minimal">{`\`\`\`bash\n${redactSecrets(command)}\n\`\`\``}</Markdown>
</div>
)}
{/* File path */}
{filePath && (
<div className="flex items-center gap-1.5">
<FileCode className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">{filePath}</code>
</div>
)}
{/* Edit: old → new diff */}
{oldString !== undefined && newString !== undefined && (
<div className="rounded border overflow-hidden">
<div className="bg-red-500/5 border-b px-3 py-2">
<div className="text-[11px] font-medium text-red-600 dark:text-red-400 mb-1">Removed</div>
<pre className="text-xs whitespace-pre-wrap break-all text-muted-foreground">{redactSecrets(oldString)}</pre>
</div>
<div className="bg-green-500/5 px-3 py-2">
<div className="text-[11px] font-medium text-green-600 dark:text-green-400 mb-1">Added</div>
<pre className="text-xs whitespace-pre-wrap break-all text-muted-foreground">{redactSecrets(newString)}</pre>
</div>
</div>
)}
{/* File content (Write tool) */}
{content && !command && oldString === undefined && (
<Markdown mode="minimal">
{`\`\`\`\n${redactSecrets(content.length > 10000 ? content.slice(0, 10000) + "\n... (truncated)" : content)}\n\`\`\``}
</Markdown>
)}
{/* Search query */}
{query && (
<div className="flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="font-medium">{query}</span>
</div>
)}
{/* Glob/Grep pattern */}
{pattern && !query && (
<div className="flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">{pattern}</code>
</div>
)}
{/* Agent prompt */}
{prompt && (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown mode="minimal">{redactSecrets(prompt)}</Markdown>
</div>
)}
{/* Skill */}
{skill && (
<code className="bg-muted px-2 py-1 rounded text-xs">/{skill}</code>
)}
{/* Full JSON: inline when no structured view, collapsible toggle otherwise */}
{!hasStructuredView ? (
<Markdown mode="minimal">
{`\`\`\`json\n${redactSecrets(JSON.stringify(inp, null, 2))}\n\`\`\``}
</Markdown>
) : (
<Collapsible open={showRaw} onOpenChange={setShowRaw}>
<CollapsibleTrigger className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<ChevronRight className={cn("h-3 w-3 transition-transform", showRaw && "rotate-90")} />
Raw input
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1.5 max-h-60 overflow-auto rounded bg-muted/50 border p-3 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{redactSecrets(JSON.stringify(inp, null, 2))}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
function ToolResultDetail({ item }: { item: TimelineItem }) {
const output = item.output ?? "";
if (!output) {
return <span className="text-sm text-muted-foreground italic">No output</span>;
}
const redacted = redactSecrets(output);
const truncated = redacted.length > 20000;
const displayContent = truncated ? redacted.slice(0, 20000) : redacted;
// Try to detect and format JSON
const trimmed = displayContent.trim();
if (
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
try {
const parsed = JSON.parse(trimmed);
const formatted = JSON.stringify(parsed, null, 2);
return (
<div>
<Markdown mode="minimal">
{`\`\`\`json\n${formatted}\n\`\`\``}
</Markdown>
{truncated && (
<p className="text-xs text-muted-foreground mt-2">... (truncated)</p>
)}
</div>
);
} catch {
// Not valid JSON, fall through
}
}
// Default: preformatted text with good readability
return (
<pre className="text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words font-mono">
{displayContent}
{truncated && "\n\n... (truncated)"}
</pre>
);
}

View File

@@ -7,3 +7,4 @@ export { CommentCard } from "./comment-card";
export { CommentInput } from "./comment-input";
export { ReplyInput } from "./reply-input";
export { IssueMentionCard } from "./issue-mention-card";
export { AgentTranscriptDialog } from "./agent-transcript-dialog";

View File

@@ -20,6 +20,7 @@
"./my-issues": "./my-issues/index.ts",
"./skills": "./skills/index.ts",
"./runtimes": "./runtimes/index.ts",
"./sessions": "./sessions/index.ts",
"./workspace/workspace-avatar": "./workspace/workspace-avatar.tsx"
},
"dependencies": {

View File

@@ -0,0 +1 @@
export { SessionsPage } from "./sessions-page";

View File

@@ -0,0 +1,322 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
XCircle,
Loader2,
Clock,
Ban,
Zap,
Wrench,
Hash,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useWorkspaceId } from "@multica/core/hooks";
import { workspaceTasksOptions, workspaceKeys, agentListOptions } from "@multica/core/workspace/queries";
import { useWSEvent } from "@multica/core/realtime";
import { api } from "@multica/core/api";
import { ActorAvatar } from "../common/actor-avatar";
import { AgentTranscriptDialog } from "../issues/components";
import type { AgentTask, WorkspaceTask } from "@multica/core/types/agent";
import type { TaskMessagePayload } from "@multica/core/types/events";
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatDuration(startIso: string, endIso?: string | null): string {
const end = endIso ? new Date(endIso).getTime() : Date.now();
const ms = end - new Date(startIso).getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (minutes < 60) return `${minutes}m ${secs}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
function formatTokens(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
type TaskStatus = AgentTask["status"];
const statusConfig: Record<TaskStatus, { label: string; icon: typeof Loader2; className: string; dotClass: string }> = {
queued: { label: "Queued", icon: Clock, className: "text-muted-foreground", dotClass: "bg-muted-foreground" },
dispatched: { label: "Starting", icon: Loader2, className: "text-info", dotClass: "bg-info animate-pulse" },
running: { label: "Running", icon: Loader2, className: "text-info", dotClass: "bg-info animate-pulse" },
completed: { label: "Completed", icon: CheckCircle2, className: "text-success", dotClass: "bg-success" },
failed: { label: "Failed", icon: XCircle, className: "text-destructive", dotClass: "bg-destructive" },
cancelled: { label: "Cancelled", icon: Ban, className: "text-muted-foreground", dotClass: "bg-muted-foreground" },
};
interface TimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
tool?: string;
content?: string;
input?: Record<string, unknown>;
output?: string;
}
// ─── Sessions page ──────────────────────────────────────────────────────────
export function SessionsPage() {
const wsId = useWorkspaceId();
const qc = useQueryClient();
const { data: tasks = [], isLoading } = useQuery(workspaceTasksOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
// Transcript dialog state
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null);
const [transcriptItems, setTranscriptItems] = useState<TimelineItem[]>([]);
const [loadingTranscript, setLoadingTranscript] = useState(false);
// Real-time: invalidate task list on task state changes
const invalidate = useCallback(() => {
qc.invalidateQueries({ queryKey: workspaceKeys.tasks(wsId) });
}, [qc, wsId]);
useWSEvent("task:dispatch", invalidate);
useWSEvent("task:completed", invalidate);
useWSEvent("task:failed", invalidate);
useWSEvent("task:cancelled", invalidate);
const getAgentName = useCallback(
(agentId: string) => agents.find((a) => a.id === agentId)?.name ?? "Agent",
[agents],
);
// Open transcript for a task
const openTranscript = useCallback(
async (task: AgentTask) => {
setSelectedTask(task);
setLoadingTranscript(true);
try {
const messages = await api.listTaskMessages(task.id);
const items: TimelineItem[] = messages.map((m: TaskMessagePayload) => ({
seq: m.seq,
type: m.type,
tool: m.tool,
content: m.content,
input: m.input,
output: m.output,
}));
setTranscriptItems(items);
} catch {
setTranscriptItems([]);
}
setLoadingTranscript(false);
},
[],
);
// Live-update transcript items if the selected task is running
const isSelectedLive = selectedTask && (selectedTask.status === "running" || selectedTask.status === "dispatched");
useWSEvent(
"task:message",
useCallback(
(payload: unknown) => {
const p = payload as TaskMessagePayload;
if (!selectedTask || p.task_id !== selectedTask.id) return;
setTranscriptItems((prev) => [
...prev,
{ seq: p.seq, type: p.type, tool: p.tool, content: p.content, input: p.input, output: p.output },
]);
},
[selectedTask],
),
);
// Elapsed time ticker for active tasks
const [, setTick] = useState(0);
const hasActiveTasks = tasks.some((t) => t.status === "running" || t.status === "dispatched");
useEffect(() => {
if (!hasActiveTasks) return;
const interval = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(interval);
}, [hasActiveTasks]);
// Sort: active first, then by created_at desc
const sortedTasks = useMemo(() => {
return [...tasks].sort((a, b) => {
const aActive = ["running", "dispatched", "queued"].includes(a.status);
const bActive = ["running", "dispatched", "queued"].includes(b.status);
if (aActive !== bActive) return aActive ? -1 : 1;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}, [tasks]);
const activeCount = tasks.filter((t) => t.status === "running" || t.status === "dispatched").length;
return (
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="border-b px-6 py-4">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Sessions</h1>
{activeCount > 0 && (
<span className="rounded-full bg-info/15 px-2 py-0.5 text-xs font-medium text-info">
{activeCount} active
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Agent execution sessions across this workspace
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : sortedTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-muted-foreground">
<Zap className="h-8 w-8" />
<p className="text-sm">No sessions yet</p>
<p className="text-xs">Sessions appear when agents start working on issues.</p>
</div>
) : (
<div className="p-4 space-y-2">
{sortedTasks.map((task) => (
<SessionCard
key={task.id}
task={task}
agentName={getAgentName(task.agent_id)}
onClick={() => openTranscript(task)}
/>
))}
</div>
)}
{/* Transcript dialog */}
{selectedTask && (
<AgentTranscriptDialog
open={!!selectedTask}
onOpenChange={(open) => {
if (!open) {
setSelectedTask(null);
setTranscriptItems([]);
}
}}
task={selectedTask}
items={loadingTranscript ? [] : transcriptItems}
agentName={getAgentName(selectedTask.agent_id)}
isLive={!!isSelectedLive}
/>
)}
</div>
);
}
// ─── Session card ───────────────────────────────────────────────────────────
function SessionCard({
task,
agentName,
onClick,
}: {
task: WorkspaceTask;
agentName: string;
onClick: () => void;
}) {
const config = statusConfig[task.status];
const isActive = task.status === "running" || task.status === "dispatched";
const totalTokens = task.total_input_tokens + task.total_output_tokens;
return (
<button
onClick={onClick}
className={cn(
"w-full rounded-lg border p-3 text-left transition-all hover:shadow-sm hover:border-border/80",
isActive
? "border-info/30 bg-info/5 hover:bg-info/8"
: "hover:bg-accent/30",
)}
>
{/* Top row: agent + status */}
<div className="flex items-center gap-2.5">
<ActorAvatar actorType="agent" actorId={task.agent_id} size={24} />
<span className="text-xs font-medium text-muted-foreground">{agentName}</span>
<div className="ml-auto flex items-center gap-1.5">
<span className={cn("h-1.5 w-1.5 rounded-full shrink-0", config.dotClass)} />
<span className={cn("text-[11px]", config.className)}>{config.label}</span>
</div>
</div>
{/* Issue title */}
<div className="mt-2">
{task.issue_title ? (
<p className="text-sm font-medium truncate">
{task.issue_number > 0 && (
<span className="text-muted-foreground font-normal mr-1">#{task.issue_number}</span>
)}
{task.issue_title}
</p>
) : (
<p className="text-sm text-muted-foreground truncate">{task.issue_id.slice(0, 8)}</p>
)}
{task.error && (
<p className="text-xs text-destructive truncate mt-0.5">{task.error}</p>
)}
</div>
{/* Metadata chips */}
<div className="flex items-center gap-3 mt-2 text-[11px] text-muted-foreground">
{/* Duration */}
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{isActive && task.started_at
? formatDuration(task.started_at)
: task.started_at && task.completed_at
? formatDuration(task.started_at, task.completed_at)
: "—"}
</span>
{/* Tool calls */}
{task.tool_use_count > 0 && (
<span className="flex items-center gap-1">
<Wrench className="h-3 w-3" />
{task.tool_use_count} tools
</span>
)}
{/* Events */}
{task.total_events > 0 && (
<span className="flex items-center gap-1">
<Hash className="h-3 w-3" />
{task.total_events} events
</span>
)}
{/* Tokens */}
{totalTokens > 0 && (
<span className="flex items-center gap-1">
<Zap className="h-3 w-3" />
{formatTokens(totalTokens)} tokens
</span>
)}
{/* Time */}
<span className="ml-auto">
{formatTime(task.created_at)}
</span>
</div>
</button>
);
}

View File

@@ -129,6 +129,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
r.Get("/", h.GetWorkspace)
r.Get("/members", h.ListMembersWithUser)
r.Get("/tasks", h.ListWorkspaceTasks)
r.Post("/leave", h.LeaveWorkspace)
})
// Admin-level access

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -470,3 +471,84 @@ func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
type WorkspaceTaskResponse struct {
AgentTaskResponse
IssueTitle string `json:"issue_title"`
IssueNumber int32 `json:"issue_number"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
ToolUseCount int32 `json:"tool_use_count"`
TotalEvents int32 `json:"total_events"`
}
func (h *Handler) ListWorkspaceTasks(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
limit := int32(50)
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.ParseInt(l, 10, 32); err == nil && n > 0 && n <= 200 {
limit = int32(n)
}
}
// Load workspace prefix for issue identifiers
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load workspace")
return
}
tasks, err := h.Queries.ListWorkspaceTasks(r.Context(), db.ListWorkspaceTasksParams{
WorkspaceID: parseUUID(workspaceID),
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list workspace tasks")
return
}
resp := make([]WorkspaceTaskResponse, len(tasks))
for i, t := range tasks {
var result any
if t.Result != nil {
json.Unmarshal(t.Result, &result)
}
identifier := ""
if t.IssueNumber > 0 {
identifier = ws.IssuePrefix + "-" + strconv.Itoa(int(t.IssueNumber))
}
resp[i] = WorkspaceTaskResponse{
AgentTaskResponse: AgentTaskResponse{
ID: uuidToString(t.ID),
AgentID: uuidToString(t.AgentID),
RuntimeID: uuidToString(t.RuntimeID),
IssueID: uuidToString(t.IssueID),
Status: t.Status,
Priority: t.Priority,
DispatchedAt: timestampToPtr(t.DispatchedAt),
StartedAt: timestampToPtr(t.StartedAt),
CompletedAt: timestampToPtr(t.CompletedAt),
Result: result,
Error: textToPtr(t.Error),
CreatedAt: timestampToString(t.CreatedAt),
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
},
IssueTitle: t.IssueTitle,
IssueNumber: t.IssueNumber,
TotalInputTokens: t.TotalInputTokens,
TotalOutputTokens: t.TotalOutputTokens,
ToolUseCount: t.ToolUseCount,
TotalEvents: t.TotalEvents,
}
// Set computed identifier
if identifier != "" {
resp[i].AgentTaskResponse.WorkspaceID = workspaceID
}
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -826,6 +826,106 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
return items, nil
}
const listWorkspaceTasks = `-- name: ListWorkspaceTasks :many
SELECT
atq.id, atq.agent_id, atq.issue_id, atq.status, atq.priority, atq.dispatched_at, atq.started_at, atq.completed_at, atq.result, atq.error, atq.created_at, atq.context, atq.runtime_id, atq.session_id, atq.work_dir, atq.trigger_comment_id, atq.chat_session_id,
COALESCE(i.title, '')::text AS issue_title,
COALESCE(i.number, 0)::int AS issue_number,
COALESCE(tu.total_input_tokens, 0)::bigint AS total_input_tokens,
COALESCE(tu.total_output_tokens, 0)::bigint AS total_output_tokens,
COALESCE(tm.tool_use_count, 0)::int AS tool_use_count,
COALESCE(tm.total_events, 0)::int AS total_events
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
LEFT JOIN issue i ON i.id = atq.issue_id
LEFT JOIN (
SELECT task_id, SUM(input_tokens) AS total_input_tokens, SUM(output_tokens) AS total_output_tokens
FROM task_usage GROUP BY task_id
) tu ON tu.task_id = atq.id
LEFT JOIN (
SELECT task_id, COUNT(*) FILTER (WHERE type = 'tool_use') AS tool_use_count, COUNT(*) AS total_events
FROM task_message GROUP BY task_id
) tm ON tm.task_id = atq.id
WHERE a.workspace_id = $1
ORDER BY atq.created_at DESC
LIMIT $2
`
type ListWorkspaceTasksParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
}
type ListWorkspaceTasksRow struct {
ID pgtype.UUID `json:"id"`
AgentID pgtype.UUID `json:"agent_id"`
IssueID pgtype.UUID `json:"issue_id"`
Status string `json:"status"`
Priority int32 `json:"priority"`
DispatchedAt pgtype.Timestamptz `json:"dispatched_at"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
Result []byte `json:"result"`
Error pgtype.Text `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Context []byte `json:"context"`
RuntimeID pgtype.UUID `json:"runtime_id"`
SessionID pgtype.Text `json:"session_id"`
WorkDir pgtype.Text `json:"work_dir"`
TriggerCommentID pgtype.UUID `json:"trigger_comment_id"`
ChatSessionID pgtype.UUID `json:"chat_session_id"`
IssueTitle string `json:"issue_title"`
IssueNumber int32 `json:"issue_number"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
ToolUseCount int32 `json:"tool_use_count"`
TotalEvents int32 `json:"total_events"`
}
func (q *Queries) ListWorkspaceTasks(ctx context.Context, arg ListWorkspaceTasksParams) ([]ListWorkspaceTasksRow, error) {
rows, err := q.db.Query(ctx, listWorkspaceTasks, arg.WorkspaceID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListWorkspaceTasksRow{}
for rows.Next() {
var i ListWorkspaceTasksRow
if err := rows.Scan(
&i.ID,
&i.AgentID,
&i.IssueID,
&i.Status,
&i.Priority,
&i.DispatchedAt,
&i.StartedAt,
&i.CompletedAt,
&i.Result,
&i.Error,
&i.CreatedAt,
&i.Context,
&i.RuntimeID,
&i.SessionID,
&i.WorkDir,
&i.TriggerCommentID,
&i.ChatSessionID,
&i.IssueTitle,
&i.IssueNumber,
&i.TotalInputTokens,
&i.TotalOutputTokens,
&i.ToolUseCount,
&i.TotalEvents,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const restoreAgent = `-- name: RestoreAgent :one
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
WHERE id = $1

View File

@@ -170,6 +170,30 @@ SELECT * FROM agent_task_queue
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')
ORDER BY priority DESC, created_at ASC;
-- name: ListWorkspaceTasks :many
SELECT
atq.*,
COALESCE(i.title, '')::text AS issue_title,
COALESCE(i.number, 0)::int AS issue_number,
COALESCE(tu.total_input_tokens, 0)::bigint AS total_input_tokens,
COALESCE(tu.total_output_tokens, 0)::bigint AS total_output_tokens,
COALESCE(tm.tool_use_count, 0)::int AS tool_use_count,
COALESCE(tm.total_events, 0)::int AS total_events
FROM agent_task_queue atq
JOIN agent a ON a.id = atq.agent_id
LEFT JOIN issue i ON i.id = atq.issue_id
LEFT JOIN (
SELECT task_id, SUM(input_tokens) AS total_input_tokens, SUM(output_tokens) AS total_output_tokens
FROM task_usage GROUP BY task_id
) tu ON tu.task_id = atq.id
LEFT JOIN (
SELECT task_id, COUNT(*) FILTER (WHERE type = 'tool_use') AS tool_use_count, COUNT(*) AS total_events
FROM task_message GROUP BY task_id
) tm ON tm.task_id = atq.id
WHERE a.workspace_id = $1
ORDER BY atq.created_at DESC
LIMIT $2;
-- name: ListActiveTasksByIssue :many
SELECT * FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('dispatched', 'running')