From 581693ad126b37c2e044fcbcc981d85bbca935d6 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Fri, 10 Apr 2026 13:02:46 +0800 Subject: [PATCH] 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) --- packages/views/issues/components/index.ts | 1 + packages/views/sessions/sessions-page.tsx | 330 ++++++++++++++-------- 2 files changed, 216 insertions(+), 115 deletions(-) diff --git a/packages/views/issues/components/index.ts b/packages/views/issues/components/index.ts index 6df362910..045d77f03 100644 --- a/packages/views/issues/components/index.ts +++ b/packages/views/issues/components/index.ts @@ -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"; diff --git a/packages/views/sessions/sessions-page.tsx b/packages/views/sessions/sessions-page.tsx index 88cc80151..cde8f6058 100644 --- a/packages/views/sessions/sessions-page.tsx +++ b/packages/views/sessions/sessions-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Bot, @@ -10,19 +10,24 @@ import { Clock, Ban, Zap, + ExternalLink, } 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 { issueListOptions } from "@multica/core/issues/queries"; import { useWSEvent } from "@multica/core/realtime"; +import { api } from "@multica/core/api"; import { ActorAvatar } from "../common/actor-avatar"; -import { AppLink } from "../navigation"; +import { AgentTranscriptDialog } from "../issues/components"; import type { AgentTask } from "@multica/core/types/agent"; +import type { TaskMessagePayload } from "@multica/core/types/events"; // ─── Helpers ──────────────────────────────────────────────────────────────── -function formatElapsed(startTime: string): string { - const ms = Date.now() - new Date(startTime).getTime(); +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); @@ -32,26 +37,39 @@ function formatElapsed(startTime: string): string { return `${hours}h ${minutes % 60}m`; } -function formatTime(iso: string): string { - return new Date(iso).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; } type TaskStatus = AgentTask["status"]; -const statusConfig: Record = { - queued: { label: "Queued", icon: Clock, className: "text-muted-foreground" }, - dispatched: { label: "Dispatched", icon: Loader2, className: "text-info" }, - running: { label: "Running", icon: Loader2, className: "text-info" }, - completed: { label: "Completed", icon: CheckCircle2, className: "text-success" }, - failed: { label: "Failed", icon: XCircle, className: "text-destructive" }, - cancelled: { label: "Cancelled", icon: Ban, className: "text-muted-foreground" }, +const statusConfig: Record = { + 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" }, }; +// ─── Timeline item type (matching transcript dialog) ──────────────────────── + +interface TimelineItem { + seq: number; + type: "tool_use" | "tool_result" | "thinking" | "text" | "error"; + tool?: string; + content?: string; + input?: Record; + output?: string; +} + // ─── Sessions page ────────────────────────────────────────────────────────── export function SessionsPage() { @@ -60,6 +78,12 @@ export function SessionsPage() { const { data: tasks = [], isLoading } = useQuery(workspaceTasksOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: issues = [] } = useQuery(issueListOptions(wsId)); + + // Transcript dialog state + const [selectedTask, setSelectedTask] = useState(null); + const [transcriptItems, setTranscriptItems] = useState([]); + const [loadingTranscript, setLoadingTranscript] = useState(false); // Real-time: invalidate task list on task state changes const invalidate = useCallback(() => { @@ -71,18 +95,84 @@ export function SessionsPage() { useWSEvent("task:failed", invalidate); useWSEvent("task:cancelled", invalidate); - const getAgentName = (agentId: string) => { - return agents.find((a) => a.id === agentId)?.name ?? "Agent"; - }; + // Lookup helpers + const getAgentName = useCallback( + (agentId: string) => agents.find((a) => a.id === agentId)?.name ?? "Agent", + [agents], + ); + const getIssue = useCallback( + (issueId: string) => issues.find((issue) => issue.id === issueId), + [issues], + ); - // Separate active vs completed tasks - const activeTasks = tasks.filter( - (t) => t.status === "running" || t.status === "dispatched" || t.status === "queued", + // 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); + }, + [], ); - const pastTasks = tasks.filter( - (t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled", + + // 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]); + return (
{/* Header */} @@ -90,113 +180,123 @@ export function SessionsPage() {

Sessions

- {activeTasks.length > 0 && ( + {hasActiveTasks && ( - {activeTasks.length} active + {tasks.filter((t) => t.status === "running" || t.status === "dispatched").length} active )}
+

+ Agent execution sessions across this workspace +

{isLoading ? (
- ) : tasks.length === 0 ? ( + ) : sortedTasks.length === 0 ? (
-

No task sessions yet.

-

Sessions will appear here when agents start working on issues.

+

No sessions yet

+

Sessions appear when agents start working on issues.

) : ( -
- {/* Active sessions */} - {activeTasks.length > 0 && ( -
-
- - Active - -
- {activeTasks.map((task) => ( - - ))} -
- )} +
+ {/* Table header */} +
+
+
Session
+
Status
+
Duration
+
- {/* Past sessions */} - {pastTasks.length > 0 && ( -
-
- - Recent - -
- {pastTasks.map((task) => ( - + {sortedTasks.map((task) => { + const isActive = task.status === "running" || task.status === "dispatched" || task.status === "queued"; + const issue = getIssue(task.issue_id); + const agentName = getAgentName(task.agent_id); + const config = statusConfig[task.status]; + + return ( +
- )} + onClick={() => openTranscript(task)} + className={cn( + "w-full grid grid-cols-[auto_1fr_auto_auto] gap-3 items-center px-3 py-2.5 rounded-lg text-left transition-colors", + "hover:bg-accent/50 cursor-pointer", + isActive && "bg-accent/20", + )} + > + {/* Agent avatar */} + + + {/* Session info */} +
+
+ {issue ? ( + + {issue.identifier} + {" "} + {issue.title} + + ) : ( + + {task.issue_id.slice(0, 8)} + + )} +
+
+ {agentName} + {task.error && ( + <> + · + {task.error} + + )} +
+
+ + {/* Status */} +
+ + {config.label} +
+ + {/* Duration / time */} +
+ {isActive && task.started_at ? ( + formatDuration(task.started_at) + ) : task.started_at && task.completed_at ? ( + formatDuration(task.started_at, task.completed_at) + ) : ( + formatRelativeTime(task.created_at) + )} +
+ + ); + })} +
)} + + {/* Transcript dialog */} + {selectedTask && ( + { + if (!open) { + setSelectedTask(null); + setTranscriptItems([]); + } + }} + task={selectedTask} + items={loadingTranscript ? [] : transcriptItems} + agentName={getAgentName(selectedTask.agent_id)} + isLive={!!isSelectedLive} + /> + )}
); } - -// ─── Task row ─────────────────────────────────────────────────────────────── - -function TaskRow({ task, agentName }: { task: AgentTask; agentName: string }) { - const config = statusConfig[task.status]; - const StatusIcon = config.icon; - const isActive = task.status === "running" || task.status === "dispatched"; - - return ( - -
- {/* Agent avatar */} - - - {/* Main info */} -
-
- {agentName} - - {task.issue_id.slice(0, 8)} - -
- {task.error && ( -

{task.error}

- )} -
- - {/* Status */} -
- - {config.label} -
- - {/* Time */} -
- {isActive && task.started_at - ? formatElapsed(task.started_at) - : task.completed_at - ? formatTime(task.completed_at) - : formatTime(task.created_at)} -
-
-
- ); -}