@multiclawcli/dashboard 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @multiclawcli/dashboard
2
+
3
+ [![npm](https://img.shields.io/npm/v/@multiclawcli/dashboard)](https://www.npmjs.com/package/@multiclawcli/dashboard)
4
+ [![GitHub](https://img.shields.io/badge/GitHub-shipengqi%2Fmulticlaw-blue?logo=github)](https://github.com/shipengqi/multiclaw)
5
+
6
+ Real-time dashboard for [multiclaw](https://github.com/shipengqi/multiclaw) — the multi-agent orchestration CLI.
7
+
8
+ This package provides the web UI and WebSocket server used internally by the `multiclaw` CLI. It is not intended for direct use; install [`multiclaw`](https://www.npmjs.com/package/multiclaw) instead.
9
+
10
+ ## Documentation
11
+
12
+ Full documentation is available in the [main repository](https://github.com/shipengqi/multiclaw).
13
+
14
+ ## License
15
+
16
+ [MIT](https://github.com/shipengqi/multiclaw/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiclawcli/dashboard",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./server": {
@@ -8,10 +8,20 @@
8
8
  "default": "./dist/server/index.js"
9
9
  }
10
10
  },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/shipengqi/multiclaw.git",
14
+ "directory": "packages/dashboard"
15
+ },
16
+ "homepage": "https://github.com/shipengqi/multiclaw#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/shipengqi/multiclaw/issues"
19
+ },
11
20
  "publishConfig": {
12
21
  "access": "public"
13
22
  },
14
23
  "dependencies": {
24
+ "@multiclawcli/core": "0.3.0",
15
25
  "ws": "^8.16.0",
16
26
  "zustand": "^4.5.0",
17
27
  "react": "^18.2.0",
@@ -22,8 +32,7 @@
22
32
  "lucide-react": "^0.400.0",
23
33
  "@radix-ui/react-scroll-area": "^1.0.5",
24
34
  "@radix-ui/react-slot": "^1.0.2",
25
- "@radix-ui/react-separator": "^1.0.3",
26
- "@multiclawcli/core": "0.1.0"
35
+ "@radix-ui/react-separator": "^1.0.3"
27
36
  },
28
37
  "devDependencies": {
29
38
  "@vitejs/plugin-react": "^4.2.0",
@@ -1,16 +1,17 @@
1
- import React, { useState, useEffect } from "react"
1
+ import React, { useState, useEffect, useRef } from "react"
2
2
  import { Badge } from "@/components/ui/badge"
3
- import { Separator } from "@/components/ui/separator"
4
3
  import { useWebSocket } from "./hooks/useWebSocket"
5
4
  import { useOrchestrationStore } from "./store/useOrchestrationStore"
6
- import { StageList } from "./components/StageList"
5
+ import { PipelineBar } from "./components/PipelineBar"
7
6
  import { LogPanel } from "./components/LogPanel"
8
7
 
9
8
  export function App() {
10
9
  const apply = useOrchestrationStore((s) => s.apply)
11
- const { name, running, success, totalDuration } = useOrchestrationStore()
10
+ const { name, requirement, running, success, totalDuration, stages, agents, preflightAgentIds, preflightBuffer } =
11
+ useOrchestrationStore()
12
12
  const [selected, setSelected] = useState<string | null>(null)
13
13
  const [elapsed, setElapsed] = useState(0)
14
+ const autoSelectedRef = useRef(false)
14
15
 
15
16
  useWebSocket(apply)
16
17
 
@@ -21,42 +22,75 @@ export function App() {
21
22
  return () => clearInterval(id)
22
23
  }, [running])
23
24
 
25
+ // Auto-select leader the moment it appears as running
26
+ useEffect(() => {
27
+ if (autoSelectedRef.current) return
28
+ for (const [id, agent] of Object.entries(preflightBuffer)) {
29
+ if (agent.status === "running") {
30
+ setSelected(id)
31
+ autoSelectedRef.current = true
32
+ return
33
+ }
34
+ }
35
+ }, [preflightBuffer])
36
+
37
+ // Reset auto-select flag at the start of each new run
38
+ useEffect(() => {
39
+ if (running) autoSelectedRef.current = false
40
+ }, [running])
41
+
42
+ const leaderIds = preflightAgentIds.length > 0 ? preflightAgentIds : Object.keys(preflightBuffer)
43
+ const leaderAgents = preflightAgentIds.length > 0 ? agents : preflightBuffer
44
+
45
+ const hasPipelineBar = leaderIds.length > 0 || stages.length > 0
46
+
24
47
  const statusBadge = running
25
- ? <Badge className="bg-green-500 hover:bg-green-500/80">运行中</Badge>
48
+ ? <Badge className="bg-green-500 hover:bg-green-500/80">Running</Badge>
26
49
  : success === undefined
27
- ? <Badge variant="secondary">等待中</Badge>
50
+ ? <Badge variant="secondary">Waiting</Badge>
28
51
  : success
29
- ? <Badge className="bg-green-500 hover:bg-green-500/80">完成</Badge>
30
- : <Badge variant="destructive">存在失败</Badge>
52
+ ? <Badge className="bg-green-500 hover:bg-green-500/80">Done</Badge>
53
+ : <Badge variant="destructive">Failed</Badge>
31
54
 
32
- const displayDuration = totalDuration != null
33
- ? totalDuration
34
- : running ? elapsed : null
55
+ const displayDuration = totalDuration != null ? totalDuration : running ? elapsed : null
35
56
 
36
57
  return (
37
58
  <div className="h-screen flex flex-col bg-background">
38
- <header className="flex items-center justify-between px-6 py-3 border-b">
39
- <h1 className="text-base font-semibold">
40
- MultiClaw{name && <span className="text-muted-foreground font-normal"> · {name}</span>}
41
- </h1>
42
- <div className="flex items-center gap-3 text-sm">
43
- {displayDuration != null && (
44
- <span className="text-muted-foreground">
45
- ⏱ {(displayDuration / 1000).toFixed(1)}s
46
- </span>
47
- )}
48
- {statusBadge}
59
+ <header className="flex flex-col items-start justify-between px-6 py-3 border-b shrink-0 gap-2">
60
+ <div className="flex w-full items-center justify-between">
61
+ <h1 className="text-base font-semibold">
62
+ MultiClaw{name && <span className="text-muted-foreground font-normal"> · {name}</span>}
63
+ </h1>
64
+ <div className="flex items-center gap-3 text-sm">
65
+ {displayDuration != null && (
66
+ <span className="text-muted-foreground">{(displayDuration / 1000).toFixed(1)}s</span>
67
+ )}
68
+ {statusBadge}
69
+ </div>
49
70
  </div>
71
+ {requirement && (
72
+ <p className="text-sm text-muted-foreground line-clamp-2">
73
+ {requirement}
74
+ </p>
75
+ )}
50
76
  </header>
51
- <main className="flex-1 flex gap-4 p-4 overflow-hidden">
52
- <div className="w-80 overflow-auto">
53
- <StageList onSelect={setSelected} selectedId={selected} />
54
- </div>
55
- <Separator orientation="vertical" />
56
- <div className="flex-1 overflow-hidden">
57
- <LogPanel agentId={selected} />
77
+
78
+ {hasPipelineBar && (
79
+ <div className="border-b px-4 py-3 shrink-0 overflow-x-auto">
80
+ <PipelineBar
81
+ leaderIds={leaderIds}
82
+ leaderAgents={leaderAgents}
83
+ stages={stages}
84
+ agents={agents}
85
+ onSelect={setSelected}
86
+ selectedId={selected}
87
+ />
58
88
  </div>
59
- </main>
89
+ )}
90
+
91
+ <div className="flex-1 overflow-hidden p-4">
92
+ <LogPanel agentId={selected} />
93
+ </div>
60
94
  </div>
61
95
  )
62
96
  }
@@ -10,16 +10,16 @@ const STATUS_CONFIG: Record<AgentStatus, {
10
10
  variant: "secondary" | "default" | "destructive" | "outline"
11
11
  className?: string
12
12
  }> = {
13
- pending: { label: "等待中", variant: "secondary" },
14
- running: { label: "运行中", variant: "default", className: "animate-pulse" },
15
- retrying: { label: "重试中", variant: "default", className: "animate-pulse bg-yellow-500 hover:bg-yellow-500/80" },
16
- success: { label: "完成", variant: "default", className: "bg-green-500 hover:bg-green-500/80" },
17
- failed: { label: "失败", variant: "destructive" },
18
- skipped: { label: "跳过", variant: "outline" },
13
+ pending: { label: "pending", variant: "secondary" },
14
+ running: { label: "running", variant: "default", className: "animate-pulse" },
15
+ retrying: { label: "retrying", variant: "default", className: "animate-pulse bg-yellow-500 hover:bg-yellow-500/80" },
16
+ success: { label: "done", variant: "default", className: "bg-green-500 hover:bg-green-500/80" },
17
+ failed: { label: "failed", variant: "destructive" },
18
+ skipped: { label: "skipped", variant: "outline" },
19
19
  }
20
20
 
21
21
  export function AgentCard({ agent, onClick, selected }: {
22
- agent: { name: string; icon?: string; status: string; duration?: number }
22
+ agent: { name: string; icon?: string; status: string; duration?: number; taskTitle?: string; model?: string }
23
23
  onClick: () => void
24
24
  selected?: boolean
25
25
  }) {
@@ -29,20 +29,30 @@ export function AgentCard({ agent, onClick, selected }: {
29
29
  onClick={onClick}
30
30
  className={cn("cursor-pointer hover:bg-accent transition-colors", selected && "bg-accent ring-1 ring-ring")}
31
31
  >
32
- <CardContent className="flex items-center justify-between p-3">
33
- <div className="flex items-center gap-2">
34
- <span>{agent.icon ?? "▶"}</span>
35
- <span className="font-medium text-sm">{agent.name}</span>
32
+ <CardContent className="flex flex-col gap-2 p-3">
33
+ <div className="flex items-center justify-between">
34
+ <div className="flex items-center gap-2">
35
+ <span>{agent.icon ?? "-"}</span>
36
+ <div className="flex flex-col gap-0.5">
37
+ <span className="font-medium text-sm">{agent.name}</span>
38
+ {agent.taskTitle && (
39
+ <span className="text-xs text-muted-foreground">{agent.taskTitle}</span>
40
+ )}
41
+ </div>
42
+ </div>
43
+ <Badge variant={cfg.variant} className={cn(cfg.className)}>
44
+ {cfg.label}
45
+ </Badge>
36
46
  </div>
37
- <div className="flex items-center gap-2">
47
+ <div className="flex items-center justify-between text-xs">
38
48
  {agent.duration != null && (
39
- <span className="text-xs text-muted-foreground">
49
+ <span className="text-muted-foreground">
40
50
  {(agent.duration / 1000).toFixed(1)}s
41
51
  </span>
42
52
  )}
43
- <Badge variant={cfg.variant} className={cn(cfg.className)}>
44
- {cfg.label}
45
- </Badge>
53
+ {agent.model && (
54
+ <span className="text-muted-foreground">{agent.model}</span>
55
+ )}
46
56
  </div>
47
57
  </CardContent>
48
58
  </Card>
@@ -3,10 +3,12 @@ import { useOrchestrationStore } from "../store/useOrchestrationStore"
3
3
 
4
4
  export function LogPanel({ agentId }: { agentId: string | null }) {
5
5
  const agents = useOrchestrationStore((s) => s.agents)
6
+ const preflightBuffer = useOrchestrationStore((s) => s.preflightBuffer)
6
7
  const scrollRef = useRef<HTMLDivElement>(null)
7
8
  const userScrolled = useRef(false)
8
- const logs = agentId ? agents[agentId]?.logs ?? "" : ""
9
- const agentName = agentId ? agents[agentId]?.name : null
9
+ const agentState = agentId ? (agents[agentId] ?? preflightBuffer[agentId]) : undefined
10
+ const logs = agentState?.logs ?? ""
11
+ const agentName = agentState?.name ?? null
10
12
 
11
13
  const handleScroll = () => {
12
14
  const el = scrollRef.current
@@ -29,11 +31,11 @@ export function LogPanel({ agentId }: { agentId: string | null }) {
29
31
  <div className="p-4 font-mono text-sm">
30
32
  {agentId ? (
31
33
  <>
32
- <p className="text-zinc-400 mb-3 text-xs">📄 {agentName} 日志</p>
34
+ <p className="text-zinc-400 mb-3 text-xs">{agentName} logs</p>
33
35
  <pre className="whitespace-pre-wrap leading-relaxed">{logs}</pre>
34
36
  </>
35
37
  ) : (
36
- <p className="text-zinc-500">← 选择一个 Agent 查看实时日志</p>
38
+ <p className="text-zinc-500">Select an agent to view live logs</p>
37
39
  )}
38
40
  </div>
39
41
  </div>
@@ -0,0 +1,234 @@
1
+ import React from "react"
2
+ import { cn } from "@/lib/utils"
3
+ import type { StageInfo } from "@multiclawcli/core"
4
+
5
+ type Status = "pending" | "running" | "retrying" | "success" | "failed" | "skipped"
6
+
7
+ const LEADER_BAR: Record<Status, string> = {
8
+ pending: "bg-muted-foreground/20",
9
+ running: "bg-violet-500 animate-pulse",
10
+ retrying: "bg-yellow-500 animate-pulse",
11
+ success: "bg-violet-400",
12
+ failed: "bg-destructive",
13
+ skipped: "bg-muted-foreground/20",
14
+ }
15
+
16
+ const STAGE_BAR: Record<Status, string> = {
17
+ pending: "bg-muted-foreground/20",
18
+ running: "bg-blue-500 animate-pulse",
19
+ retrying: "bg-yellow-500 animate-pulse",
20
+ success: "bg-green-500",
21
+ failed: "bg-destructive",
22
+ skipped: "bg-muted-foreground/20",
23
+ }
24
+
25
+ const NAME_COLOR: Record<Status, string> = {
26
+ pending: "text-muted-foreground",
27
+ running: "text-foreground",
28
+ retrying: "text-yellow-600",
29
+ success: "text-foreground",
30
+ failed: "text-destructive",
31
+ skipped: "text-muted-foreground/50",
32
+ }
33
+
34
+ const STATUS_LABEL: Record<Status, string> = {
35
+ pending: "pending",
36
+ running: "running",
37
+ retrying: "retrying",
38
+ success: "",
39
+ failed: "failed",
40
+ skipped: "skipped",
41
+ }
42
+
43
+ const STATUS_LABEL_COLOR: Record<Status, string> = {
44
+ pending: "text-muted-foreground/50",
45
+ running: "text-blue-500",
46
+ retrying: "text-yellow-500",
47
+ success: "text-green-500",
48
+ failed: "text-destructive",
49
+ skipped: "text-muted-foreground/40",
50
+ }
51
+
52
+ const LEADER_LABEL_COLOR: Record<Status, string> = {
53
+ ...STATUS_LABEL_COLOR,
54
+ running: "text-violet-500",
55
+ success: "text-violet-400",
56
+ }
57
+
58
+ function stageAggregate(stage: StageInfo, agents: Record<string, { status: string }>): Status {
59
+ const ss = stage.agents.map((a) => (agents[a.id]?.status ?? "pending") as Status)
60
+ if (ss.some((s) => s === "failed")) return "failed"
61
+ if (ss.some((s) => s === "running" || s === "retrying")) return "running"
62
+ if (ss.every((s) => s === "success")) return "success"
63
+ if (ss.every((s) => s === "skipped")) return "skipped"
64
+ return "pending"
65
+ }
66
+
67
+ interface AgentInfo {
68
+ id: string
69
+ name: string
70
+ icon?: string
71
+ model?: string
72
+ taskTitle?: string
73
+ status: string
74
+ duration?: number
75
+ }
76
+
77
+ function AgentRow({
78
+ agent,
79
+ isSelected,
80
+ onClick,
81
+ labelColor,
82
+ }: {
83
+ agent: AgentInfo
84
+ isSelected: boolean
85
+ onClick: () => void
86
+ labelColor: Record<Status, string>
87
+ }) {
88
+ const status = agent.status as Status
89
+ return (
90
+ <button
91
+ onClick={onClick}
92
+ className={cn(
93
+ "flex items-center gap-1.5 w-full px-2 py-1.5 text-left transition-colors rounded",
94
+ "hover:bg-accent",
95
+ isSelected && "bg-accent",
96
+ )}
97
+ >
98
+ <span className="text-sm leading-none shrink-0">{agent.icon ?? "-"}</span>
99
+ <div className="flex-1 min-w-0">
100
+ <div className={cn("text-[11px] font-medium truncate", NAME_COLOR[status])}>
101
+ {agent.name}
102
+ </div>
103
+ {agent.taskTitle && (
104
+ <div className="text-[8px] text-muted-foreground truncate">
105
+ {agent.taskTitle}
106
+ </div>
107
+ )}
108
+ <div className="flex items-center justify-between gap-2 text-[9px]">
109
+ <div className={cn(labelColor[status])}>
110
+ {agent.duration != null
111
+ ? `${(agent.duration / 1000).toFixed(1)}s`
112
+ : STATUS_LABEL[status]}
113
+ </div>
114
+ {agent.model && (
115
+ <span className="text-muted-foreground text-[8px] truncate">
116
+ {agent.model}
117
+ </span>
118
+ )}
119
+ </div>
120
+ </div>
121
+ </button>
122
+ )
123
+ }
124
+
125
+ export function PipelineBar({
126
+ leaderIds,
127
+ leaderAgents,
128
+ stages,
129
+ agents,
130
+ onSelect,
131
+ selectedId,
132
+ }: {
133
+ leaderIds: string[]
134
+ leaderAgents: Record<string, AgentInfo>
135
+ stages: StageInfo[]
136
+ agents: Record<string, AgentInfo>
137
+ onSelect: (id: string) => void
138
+ selectedId?: string | null
139
+ }) {
140
+ const hasLeader = leaderIds.length > 0
141
+ const hasPipeline = stages.length > 0
142
+ const leaderRunning = leaderIds.some((id) => leaderAgents[id]?.status === "running")
143
+ if (!hasLeader && !hasPipeline) return null
144
+
145
+ return (
146
+ <div className="flex items-stretch gap-0 overflow-x-auto">
147
+ {/* Leader card */}
148
+ {hasLeader && leaderIds.map((id) => {
149
+ const agent = leaderAgents[id] ?? { id, name: id, status: "pending" }
150
+ const status = agent.status as Status
151
+ return (
152
+ <div key={id} className="shrink-0 w-[130px] rounded-md border bg-card overflow-hidden">
153
+ <div className={cn("h-1 w-full", LEADER_BAR[status])} />
154
+ <div className="px-2 pt-1 pb-0.5">
155
+ <span className="text-[9px] uppercase tracking-wide text-muted-foreground/70 font-semibold">
156
+ Leader
157
+ </span>
158
+ </div>
159
+ <AgentRow
160
+ agent={agent}
161
+ isSelected={selectedId === id}
162
+ onClick={() => onSelect(id)}
163
+ labelColor={LEADER_LABEL_COLOR}
164
+ />
165
+ </div>
166
+ )
167
+ })}
168
+
169
+ {/* Connector: "generating" dots while leader is running, "generated" arrow once pipeline is ready */}
170
+ {hasLeader && (
171
+ <div className="flex flex-col items-center justify-center shrink-0 px-2 gap-0.5 self-center">
172
+ {hasPipeline ? (
173
+ <>
174
+ <span className="text-[8px] uppercase tracking-wide text-muted-foreground/50">generated</span>
175
+ <div className="flex items-center">
176
+ <div className="w-5 h-px border-t border-dashed border-muted-foreground/30" />
177
+ <svg width="5" height="7" viewBox="0 0 5 7" className="text-muted-foreground/40 shrink-0">
178
+ <path d="M0 0 L5 3.5 L0 7 Z" fill="currentColor" />
179
+ </svg>
180
+ </div>
181
+ </>
182
+ ) : leaderRunning ? (
183
+ <>
184
+ <span className="text-[8px] text-violet-400 animate-pulse">generating</span>
185
+ <div className="flex items-center gap-0.5">
186
+ {[0, 1, 2].map((i) => (
187
+ <span
188
+ key={i}
189
+ className="w-1 h-1 rounded-full bg-violet-400 animate-bounce"
190
+ style={{ animationDelay: `${i * 150}ms` }}
191
+ />
192
+ ))}
193
+ </div>
194
+ </>
195
+ ) : null}
196
+ </div>
197
+ )}
198
+
199
+ {/* Pipeline stage cards */}
200
+ {hasPipeline && stages.map((stage, idx) => {
201
+ const ss = stageAggregate(stage, agents)
202
+ return (
203
+ <React.Fragment key={stage.stageIndex}>
204
+ {idx > 0 && (
205
+ <div className="flex items-center self-center shrink-0">
206
+ <div className="w-3 h-px bg-border" />
207
+ <svg width="5" height="7" viewBox="0 0 5 7" className="text-muted-foreground/40 shrink-0">
208
+ <path d="M0 0 L5 3.5 L0 7 Z" fill="currentColor" />
209
+ </svg>
210
+ </div>
211
+ )}
212
+ <div className="shrink-0 w-[130px] rounded-md border bg-card overflow-hidden">
213
+ <div className={cn("h-1 w-full", STAGE_BAR[ss])} />
214
+ <div className="py-1">
215
+ {stage.agents.map((a) => {
216
+ const agent = agents[a.id] ?? { id: a.id, name: a.name, icon: a.icon, status: "pending" }
217
+ return (
218
+ <AgentRow
219
+ key={a.id}
220
+ agent={agent}
221
+ isSelected={selectedId === a.id}
222
+ onClick={() => onSelect(a.id)}
223
+ labelColor={STATUS_LABEL_COLOR}
224
+ />
225
+ )
226
+ })}
227
+ </div>
228
+ </div>
229
+ </React.Fragment>
230
+ )
231
+ })}
232
+ </div>
233
+ )
234
+ }
@@ -4,7 +4,7 @@ import type { MultiClawEvent } from "@multiclawcli/core"
4
4
  export function useWebSocket(onEvent: (e: MultiClawEvent) => void): void {
5
5
  const ref = useRef(onEvent)
6
6
  ref.current = onEvent
7
- // 编排完成后停止重连,避免 server 已关闭时持续刷错误
7
+ // Stop reconnecting after orchestration completes to avoid errors when server has shut down
8
8
  const doneRef = useRef(false)
9
9
 
10
10
  useEffect(() => {