mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 04:38:50 +02:00
Compare commits
7 Commits
agent/lamb
...
feat/trans
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ed2d0e067 | ||
|
|
581693ad12 | ||
|
|
50b08506fe | ||
|
|
d1e8d8c2ca | ||
|
|
b3121c3cf5 | ||
|
|
08896d23ee | ||
|
|
19f257d18a |
@@ -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 = [
|
||||
|
||||
1
apps/web/app/(dashboard)/sessions/page.tsx
Normal file
1
apps/web/app/(dashboard)/sessions/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { SessionsPage as default } from "@multica/views/sessions";
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
AgentRuntimeMode,
|
||||
AgentVisibility,
|
||||
AgentTask,
|
||||
WorkspaceTask,
|
||||
AgentRuntime,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
1
packages/views/sessions/index.ts
Normal file
1
packages/views/sessions/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SessionsPage } from "./sessions-page";
|
||||
322
packages/views/sessions/sessions-page.tsx
Normal file
322
packages/views/sessions/sessions-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user