stagent 0.9.3 → 0.9.5
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/dist/cli.js +36 -1
- package/docs/superpowers/specs/2026-04-06-workflow-intelligence-stack-design.md +388 -0
- package/package.json +1 -1
- package/src/app/api/license/route.ts +3 -2
- package/src/app/api/workflows/[id]/debug/route.ts +18 -0
- package/src/app/api/workflows/[id]/execute/route.ts +39 -8
- package/src/app/api/workflows/optimize/route.ts +30 -0
- package/src/app/layout.tsx +4 -2
- package/src/components/chat/chat-message-markdown.tsx +78 -3
- package/src/components/chat/chat-message.tsx +12 -4
- package/src/components/settings/cloud-account-section.tsx +14 -12
- package/src/components/workflows/error-timeline.tsx +83 -0
- package/src/components/workflows/step-live-metrics.tsx +182 -0
- package/src/components/workflows/step-progress-bar.tsx +77 -0
- package/src/components/workflows/workflow-debug-panel.tsx +192 -0
- package/src/components/workflows/workflow-optimizer-panel.tsx +227 -0
- package/src/lib/agents/claude-agent.ts +4 -4
- package/src/lib/agents/runtime/anthropic-direct.ts +3 -3
- package/src/lib/agents/runtime/catalog.ts +30 -1
- package/src/lib/agents/runtime/openai-direct.ts +3 -3
- package/src/lib/book/chapter-mapping.ts +6 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/book/reading-paths.ts +1 -1
- package/src/lib/chat/__tests__/engine-stream-helpers.test.ts +57 -0
- package/src/lib/chat/engine.ts +68 -7
- package/src/lib/chat/stagent-tools.ts +2 -0
- package/src/lib/chat/tools/runtime-tools.ts +28 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -1
- package/src/lib/chat/tools/settings-tools.ts +40 -10
- package/src/lib/chat/tools/workflow-tools.ts +93 -4
- package/src/lib/chat/types.ts +21 -0
- package/src/lib/data/clear.ts +3 -0
- package/src/lib/db/bootstrap.ts +38 -0
- package/src/lib/db/migrations/0022_workflow_intelligence_phase1.sql +5 -0
- package/src/lib/db/migrations/0023_add_execution_stats.sql +15 -0
- package/src/lib/db/schema.ts +41 -1
- package/src/lib/license/__tests__/manager.test.ts +64 -0
- package/src/lib/license/manager.ts +80 -25
- package/src/lib/schedules/__tests__/interval-parser.test.ts +87 -0
- package/src/lib/schedules/__tests__/prompt-analyzer.test.ts +51 -0
- package/src/lib/schedules/interval-parser.ts +187 -0
- package/src/lib/schedules/prompt-analyzer.ts +87 -0
- package/src/lib/schedules/scheduler.ts +179 -9
- package/src/lib/workflows/cost-estimator.ts +141 -0
- package/src/lib/workflows/engine.ts +245 -45
- package/src/lib/workflows/error-analysis.ts +249 -0
- package/src/lib/workflows/execution-stats.ts +252 -0
- package/src/lib/workflows/optimizer.ts +193 -0
- package/src/lib/workflows/types.ts +6 -0
|
@@ -1,14 +1,62 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo, useState, useCallback } from "react";
|
|
3
|
+
import { memo, useMemo, useState, useCallback } from "react";
|
|
4
4
|
import ReactMarkdown from "react-markdown";
|
|
5
5
|
import remarkGfm from "remark-gfm";
|
|
6
|
-
import { Check, Copy } from "lucide-react";
|
|
6
|
+
import { Check, Copy, ImageIcon } from "lucide-react";
|
|
7
7
|
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { ScreenshotLightbox } from "@/components/shared/screenshot-lightbox";
|
|
9
|
+
import type { ScreenshotAttachment } from "@/lib/chat/types";
|
|
8
10
|
import type { Components } from "react-markdown";
|
|
9
11
|
|
|
10
12
|
interface ChatMessageMarkdownProps {
|
|
11
13
|
content: string;
|
|
14
|
+
attachments?: ScreenshotAttachment[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function InlineScreenshot({ attachment }: { attachment: ScreenshotAttachment }) {
|
|
18
|
+
const [open, setOpen] = useState(false);
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
className="relative rounded-lg overflow-hidden border border-border hover:border-primary transition-colors cursor-pointer group my-3 block w-full"
|
|
24
|
+
onClick={() => setOpen(true)}
|
|
25
|
+
>
|
|
26
|
+
<img
|
|
27
|
+
src={attachment.thumbnailUrl}
|
|
28
|
+
alt={`Screenshot ${attachment.width}×${attachment.height}`}
|
|
29
|
+
className="object-contain w-full"
|
|
30
|
+
style={{ maxHeight: 400 }}
|
|
31
|
+
loading="lazy"
|
|
32
|
+
onError={(e) => {
|
|
33
|
+
const img = e.currentTarget;
|
|
34
|
+
if (!img.src.includes(attachment.originalUrl)) {
|
|
35
|
+
img.src = attachment.originalUrl;
|
|
36
|
+
} else {
|
|
37
|
+
img.style.display = "none";
|
|
38
|
+
img.parentElement?.classList.add("bg-muted");
|
|
39
|
+
}
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
|
43
|
+
<ImageIcon className="h-5 w-5 text-white opacity-0 group-hover:opacity-70 transition-opacity" />
|
|
44
|
+
</div>
|
|
45
|
+
<span className="absolute bottom-1 right-1 text-[9px] bg-black/50 text-white px-1.5 py-0.5 rounded">
|
|
46
|
+
{attachment.width}×{attachment.height}
|
|
47
|
+
</span>
|
|
48
|
+
</button>
|
|
49
|
+
{open && (
|
|
50
|
+
<ScreenshotLightbox
|
|
51
|
+
open={open}
|
|
52
|
+
onClose={() => setOpen(false)}
|
|
53
|
+
imageUrl={attachment.originalUrl}
|
|
54
|
+
width={attachment.width}
|
|
55
|
+
height={attachment.height}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
12
60
|
}
|
|
13
61
|
|
|
14
62
|
function CodeBlock({
|
|
@@ -119,10 +167,37 @@ const components: Components = {
|
|
|
119
167
|
|
|
120
168
|
export const ChatMessageMarkdown = memo(function ChatMessageMarkdown({
|
|
121
169
|
content,
|
|
170
|
+
attachments,
|
|
122
171
|
}: ChatMessageMarkdownProps) {
|
|
172
|
+
// Resolve markdown img refs back to their full attachment record so the
|
|
173
|
+
// inline thumbnail can open the lightbox at the original resolution.
|
|
174
|
+
const attachmentBySrc = useMemo(() => {
|
|
175
|
+
const map = new Map<string, ScreenshotAttachment>();
|
|
176
|
+
for (const att of attachments ?? []) {
|
|
177
|
+
map.set(att.thumbnailUrl, att);
|
|
178
|
+
map.set(att.originalUrl, att);
|
|
179
|
+
}
|
|
180
|
+
return map;
|
|
181
|
+
}, [attachments]);
|
|
182
|
+
|
|
183
|
+
const componentsWithImg: Components = useMemo(
|
|
184
|
+
() => ({
|
|
185
|
+
...components,
|
|
186
|
+
img: ({ src, alt }) => {
|
|
187
|
+
const key = typeof src === "string" ? src : "";
|
|
188
|
+
const att = attachmentBySrc.get(key);
|
|
189
|
+
if (att) {
|
|
190
|
+
return <InlineScreenshot attachment={att} />;
|
|
191
|
+
}
|
|
192
|
+
return <img src={key} alt={alt ?? ""} className="max-w-full rounded my-2" />;
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
[attachmentBySrc]
|
|
196
|
+
);
|
|
197
|
+
|
|
123
198
|
return (
|
|
124
199
|
<div className="prose-chat">
|
|
125
|
-
<ReactMarkdown remarkPlugins={[remarkGfm]} components={
|
|
200
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={componentsWithImg}>
|
|
126
201
|
{content}
|
|
127
202
|
</ReactMarkdown>
|
|
128
203
|
</div>
|
|
@@ -117,7 +117,10 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
|
|
|
117
117
|
) : (
|
|
118
118
|
<div className="text-sm">
|
|
119
119
|
{message.content ? (
|
|
120
|
-
<ChatMessageMarkdown
|
|
120
|
+
<ChatMessageMarkdown
|
|
121
|
+
content={message.content}
|
|
122
|
+
attachments={attachments}
|
|
123
|
+
/>
|
|
121
124
|
) : isStreaming ? (
|
|
122
125
|
<span className="text-muted-foreground text-xs animate-pulse">
|
|
123
126
|
{(() => {
|
|
@@ -128,9 +131,14 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
|
|
|
128
131
|
})()}
|
|
129
132
|
</span>
|
|
130
133
|
) : null}
|
|
131
|
-
{
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
{/* Legacy fallback: messages saved before inline screenshot rendering
|
|
135
|
+
stored attachments in metadata but never embedded markdown image
|
|
136
|
+
refs in `content`. Detect that case and show the trailing gallery
|
|
137
|
+
so historical conversations don't lose their visuals. */}
|
|
138
|
+
{attachments.length > 0 &&
|
|
139
|
+
!attachments.some((att) =>
|
|
140
|
+
message.content?.includes(`](${att.thumbnailUrl})`)
|
|
141
|
+
) && <ScreenshotGallery attachments={attachments} />}
|
|
134
142
|
{isStreaming && message.content && (
|
|
135
143
|
<span className="inline-block w-0.5 h-4 bg-foreground animate-pulse ml-0.5 align-text-bottom" />
|
|
136
144
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
4
|
import { User, LogOut, Mail, CheckCircle2 } from "lucide-react";
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import {
|
|
@@ -47,21 +47,23 @@ export function CloudAccountSection() {
|
|
|
47
47
|
toast.success("Signed out");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// Handle auth callback URL params
|
|
51
|
-
|
|
50
|
+
// Handle auth callback URL params — must be in useEffect, not during render
|
|
51
|
+
useEffect(() => {
|
|
52
52
|
const params = new URLSearchParams(window.location.search);
|
|
53
|
-
|
|
53
|
+
const authParam = params.get("auth");
|
|
54
|
+
if (!authParam || loading) return;
|
|
55
|
+
|
|
56
|
+
if (authParam === "success") {
|
|
54
57
|
toast.success("Signed in successfully");
|
|
55
|
-
|
|
56
|
-
url.searchParams.delete("auth");
|
|
57
|
-
window.history.replaceState({}, "", url.toString());
|
|
58
|
-
} else if (params.get("auth") === "error") {
|
|
58
|
+
} else if (authParam === "error") {
|
|
59
59
|
toast.error("Sign-in failed — try again");
|
|
60
|
-
const url = new URL(window.location.href);
|
|
61
|
-
url.searchParams.delete("auth");
|
|
62
|
-
window.history.replaceState({}, "", url.toString());
|
|
63
60
|
}
|
|
64
|
-
|
|
61
|
+
|
|
62
|
+
// Clean up URL
|
|
63
|
+
const url = new URL(window.location.href);
|
|
64
|
+
url.searchParams.delete("auth");
|
|
65
|
+
window.history.replaceState({}, "", url.toString());
|
|
66
|
+
}, [loading]);
|
|
65
67
|
|
|
66
68
|
if (loading) {
|
|
67
69
|
return (
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
interface TimelineEvent {
|
|
4
|
+
timestamp: string;
|
|
5
|
+
event: string;
|
|
6
|
+
severity: "success" | "warning" | "error";
|
|
7
|
+
details: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ErrorTimelineProps {
|
|
11
|
+
events: TimelineEvent[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const severityColor: Record<TimelineEvent["severity"], string> = {
|
|
15
|
+
success: "bg-green-500",
|
|
16
|
+
warning: "bg-amber-500",
|
|
17
|
+
error: "bg-red-500",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatRelativeTime(firstMs: number, currentMs: number): string {
|
|
21
|
+
const diffMs = currentMs - firstMs;
|
|
22
|
+
if (diffMs < 1000) return "+0s";
|
|
23
|
+
const totalSeconds = Math.floor(diffMs / 1000);
|
|
24
|
+
if (totalSeconds < 60) return `+${totalSeconds}s`;
|
|
25
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
26
|
+
const seconds = totalSeconds % 60;
|
|
27
|
+
return `+${minutes}m ${seconds}s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatEventLabel(event: string): string {
|
|
31
|
+
return event
|
|
32
|
+
.replace(/_/g, " ")
|
|
33
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ErrorTimeline({ events }: ErrorTimelineProps) {
|
|
37
|
+
if (events.length === 0) {
|
|
38
|
+
return (
|
|
39
|
+
<p className="text-sm text-muted-foreground py-4">
|
|
40
|
+
No timeline events found.
|
|
41
|
+
</p>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const firstTimestamp = new Date(events[0].timestamp).getTime();
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="relative pl-6">
|
|
49
|
+
{/* Vertical line */}
|
|
50
|
+
<div className="absolute left-[5px] top-1 bottom-1 w-0.5 bg-border" />
|
|
51
|
+
|
|
52
|
+
<div className="space-y-4">
|
|
53
|
+
{events.map((event, index) => {
|
|
54
|
+
const currentMs = new Date(event.timestamp).getTime();
|
|
55
|
+
const relTime = formatRelativeTime(firstTimestamp, currentMs);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div key={index} className="relative flex items-start gap-3">
|
|
59
|
+
{/* Dot */}
|
|
60
|
+
<div
|
|
61
|
+
className={`absolute -left-6 top-1 h-3 w-3 rounded-full border-2 border-background ${severityColor[event.severity]}`}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<div className="min-w-0 flex-1">
|
|
65
|
+
<div className="flex items-center gap-2">
|
|
66
|
+
<span className="text-sm font-medium text-foreground">
|
|
67
|
+
{formatEventLabel(event.event)}
|
|
68
|
+
</span>
|
|
69
|
+
<span className="text-xs text-muted-foreground font-mono">
|
|
70
|
+
{relTime}
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
<p className="text-xs text-muted-foreground mt-0.5 break-words">
|
|
74
|
+
{event.details}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Wrench, Clock, Coins, Hash } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface StepLiveMetricsProps {
|
|
7
|
+
taskId: string;
|
|
8
|
+
budgetCapUsd: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface MetricsState {
|
|
12
|
+
tokens: number;
|
|
13
|
+
costUsd: number;
|
|
14
|
+
currentTool: string | null;
|
|
15
|
+
turnNumber: number;
|
|
16
|
+
startedAt: number;
|
|
17
|
+
elapsed: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatElapsed(ms: number): string {
|
|
21
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
22
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
23
|
+
const seconds = totalSeconds % 60;
|
|
24
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
25
|
+
return `${seconds}s`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function StepLiveMetrics({ taskId, budgetCapUsd }: StepLiveMetricsProps) {
|
|
29
|
+
const [metrics, setMetrics] = useState<MetricsState>({
|
|
30
|
+
tokens: 0,
|
|
31
|
+
costUsd: 0,
|
|
32
|
+
currentTool: null,
|
|
33
|
+
turnNumber: 0,
|
|
34
|
+
startedAt: Date.now(),
|
|
35
|
+
elapsed: "0s",
|
|
36
|
+
});
|
|
37
|
+
const [connected, setConnected] = useState(false);
|
|
38
|
+
const eventSourceRef = useRef<EventSource | null>(null);
|
|
39
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
40
|
+
|
|
41
|
+
// Elapsed time ticker
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
timerRef.current = setInterval(() => {
|
|
44
|
+
setMetrics((prev) => ({
|
|
45
|
+
...prev,
|
|
46
|
+
elapsed: formatElapsed(Date.now() - prev.startedAt),
|
|
47
|
+
}));
|
|
48
|
+
}, 1000);
|
|
49
|
+
return () => {
|
|
50
|
+
if (timerRef.current) clearInterval(timerRef.current);
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
// SSE subscription
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const params = new URLSearchParams({ taskId });
|
|
57
|
+
const es = new EventSource(`/api/logs/stream?${params}`);
|
|
58
|
+
eventSourceRef.current = es;
|
|
59
|
+
|
|
60
|
+
es.onopen = () => setConnected(true);
|
|
61
|
+
es.onerror = () => setConnected(false);
|
|
62
|
+
|
|
63
|
+
es.onmessage = (event) => {
|
|
64
|
+
try {
|
|
65
|
+
const data = JSON.parse(event.data);
|
|
66
|
+
const eventType: string = data.event ?? "";
|
|
67
|
+
const payload = data.payload ? JSON.parse(data.payload) : {};
|
|
68
|
+
|
|
69
|
+
if (eventType === "tool_start" || eventType === "tool_use") {
|
|
70
|
+
setMetrics((prev) => ({
|
|
71
|
+
...prev,
|
|
72
|
+
currentTool: payload.toolName ?? payload.tool ?? prev.currentTool,
|
|
73
|
+
turnNumber: prev.turnNumber + 1,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (eventType === "content_block_delta" || eventType === "token_usage") {
|
|
78
|
+
const tokenDelta = payload.tokens ?? payload.inputTokens ?? 0;
|
|
79
|
+
const costDelta = payload.costUsd ?? 0;
|
|
80
|
+
setMetrics((prev) => ({
|
|
81
|
+
...prev,
|
|
82
|
+
tokens: prev.tokens + tokenDelta,
|
|
83
|
+
costUsd: prev.costUsd + costDelta,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (eventType === "completed" || eventType === "step_completed") {
|
|
88
|
+
setMetrics((prev) => ({
|
|
89
|
+
...prev,
|
|
90
|
+
currentTool: null,
|
|
91
|
+
}));
|
|
92
|
+
if (timerRef.current) clearInterval(timerRef.current);
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Ignore malformed events
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
es.close();
|
|
101
|
+
eventSourceRef.current = null;
|
|
102
|
+
};
|
|
103
|
+
}, [taskId]);
|
|
104
|
+
|
|
105
|
+
const costPercent = budgetCapUsd > 0 ? Math.min((metrics.costUsd / budgetCapUsd) * 100, 100) : 0;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="grid grid-cols-2 gap-3">
|
|
109
|
+
{/* Tokens tile */}
|
|
110
|
+
<div className="rounded-lg border bg-white p-3 shadow-sm">
|
|
111
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
112
|
+
<Hash className="h-3.5 w-3.5" />
|
|
113
|
+
Tokens
|
|
114
|
+
</div>
|
|
115
|
+
<div className="mt-1 font-mono text-lg font-semibold">
|
|
116
|
+
{metrics.tokens.toLocaleString()}
|
|
117
|
+
</div>
|
|
118
|
+
<div className="text-xs text-muted-foreground">tokens</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Cost tile */}
|
|
122
|
+
<div className="rounded-lg border bg-white p-3 shadow-sm">
|
|
123
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
124
|
+
<Coins className="h-3.5 w-3.5" />
|
|
125
|
+
Cost
|
|
126
|
+
</div>
|
|
127
|
+
<div className="mt-1 font-mono text-lg font-semibold">
|
|
128
|
+
${metrics.costUsd.toFixed(2)}
|
|
129
|
+
</div>
|
|
130
|
+
<div className="mt-1">
|
|
131
|
+
<div className="h-1.5 w-full rounded-full bg-[oklch(0.92_0_0)]">
|
|
132
|
+
<div
|
|
133
|
+
className="h-1.5 rounded-full bg-[oklch(0.6_0.15_250)] transition-all duration-300"
|
|
134
|
+
style={{ width: `${costPercent}%` }}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
138
|
+
of ${budgetCapUsd.toFixed(2)} cap
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Current Tool tile */}
|
|
144
|
+
<div className="rounded-lg border bg-white p-3 shadow-sm">
|
|
145
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
146
|
+
<Wrench className="h-3.5 w-3.5" />
|
|
147
|
+
Current Tool
|
|
148
|
+
</div>
|
|
149
|
+
<div className="mt-1 truncate font-mono text-lg font-semibold">
|
|
150
|
+
{metrics.currentTool ?? "\u2014"}
|
|
151
|
+
</div>
|
|
152
|
+
<div className="text-xs text-muted-foreground">
|
|
153
|
+
turn {metrics.turnNumber}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Elapsed tile */}
|
|
158
|
+
<div className="rounded-lg border bg-white p-3 shadow-sm">
|
|
159
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
160
|
+
<Clock className="h-3.5 w-3.5" />
|
|
161
|
+
Elapsed
|
|
162
|
+
</div>
|
|
163
|
+
<div className="mt-1 font-mono text-lg font-semibold">
|
|
164
|
+
{metrics.elapsed}
|
|
165
|
+
</div>
|
|
166
|
+
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
167
|
+
{connected ? (
|
|
168
|
+
<>
|
|
169
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[oklch(0.65_0.15_145)]" />
|
|
170
|
+
live
|
|
171
|
+
</>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[oklch(0.8_0_0)]" />
|
|
175
|
+
disconnected
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
type StepStatus =
|
|
6
|
+
| "pending"
|
|
7
|
+
| "running"
|
|
8
|
+
| "completed"
|
|
9
|
+
| "failed"
|
|
10
|
+
| "waiting_approval"
|
|
11
|
+
| "waiting_dependencies";
|
|
12
|
+
|
|
13
|
+
interface StepProgressBarProps {
|
|
14
|
+
steps: Array<{
|
|
15
|
+
stepId: string;
|
|
16
|
+
name: string;
|
|
17
|
+
status: StepStatus;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const statusClasses: Record<StepStatus, string> = {
|
|
22
|
+
completed: "bg-[oklch(0.65_0.15_145)] text-white",
|
|
23
|
+
running: "bg-[oklch(0.6_0.15_250)] text-white animate-pulse",
|
|
24
|
+
failed: "bg-[oklch(0.6_0.2_25)] text-white",
|
|
25
|
+
pending: "bg-[oklch(0.8_0_0)] text-[oklch(0.4_0_0)]",
|
|
26
|
+
waiting_approval: "bg-[oklch(0.7_0.15_80)] text-white",
|
|
27
|
+
waiting_dependencies: "bg-[oklch(0.7_0.15_80)] text-white",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const lineColor = (status: StepStatus): string => {
|
|
31
|
+
if (status === "completed") return "bg-[oklch(0.65_0.15_145)]";
|
|
32
|
+
return "bg-[oklch(0.85_0_0)]";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function StepProgressBar({ steps }: StepProgressBarProps) {
|
|
36
|
+
if (steps.length === 0) return null;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="w-full overflow-x-auto">
|
|
40
|
+
<div className="flex items-start min-w-max px-2 py-3">
|
|
41
|
+
{steps.map((step, index) => (
|
|
42
|
+
<div key={step.stepId} className="flex items-start">
|
|
43
|
+
{/* Step circle + label */}
|
|
44
|
+
<div className="flex flex-col items-center gap-1.5">
|
|
45
|
+
<div
|
|
46
|
+
className={cn(
|
|
47
|
+
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold",
|
|
48
|
+
statusClasses[step.status]
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{index + 1}
|
|
52
|
+
</div>
|
|
53
|
+
<span
|
|
54
|
+
className="max-w-[72px] truncate text-center text-xs text-muted-foreground"
|
|
55
|
+
title={step.name}
|
|
56
|
+
>
|
|
57
|
+
{step.name}
|
|
58
|
+
</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{/* Connecting line */}
|
|
62
|
+
{index < steps.length - 1 && (
|
|
63
|
+
<div className="flex items-center pt-4">
|
|
64
|
+
<div
|
|
65
|
+
className={cn(
|
|
66
|
+
"mx-1 h-0.5 w-10",
|
|
67
|
+
lineColor(step.status)
|
|
68
|
+
)}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|