@atercates/claude-deck 0.2.2 → 0.2.4

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.
@@ -0,0 +1,279 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Switch } from "@/components/ui/switch";
8
+ import { Loader2, Shield, Eye, EyeOff } from "lucide-react";
9
+
10
+ export default function SetupPage() {
11
+ const router = useRouter();
12
+ const [username, setUsername] = useState("");
13
+ const [password, setPassword] = useState("");
14
+ const [confirmPassword, setConfirmPassword] = useState("");
15
+ const [showPassword, setShowPassword] = useState(false);
16
+ const [enableTotp, setEnableTotp] = useState(false);
17
+ const [totpSecret, setTotpSecret] = useState("");
18
+ const [totpCode, setTotpCode] = useState("");
19
+ const [qrDataUrl, setQrDataUrl] = useState("");
20
+ const [error, setError] = useState("");
21
+ const [loading, setLoading] = useState(false);
22
+
23
+ useEffect(() => {
24
+ fetch("/api/auth/session").then(async (res) => {
25
+ const data = await res.json();
26
+ if (!data.needsSetup) {
27
+ router.push(data.authenticated ? "/" : "/login");
28
+ }
29
+ });
30
+ }, [router]);
31
+
32
+ useEffect(() => {
33
+ if (enableTotp && !totpSecret && username.length >= 3) {
34
+ generateTotp();
35
+ }
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally omit generateTotp and totpSecret to avoid regenerating secret on every render
37
+ }, [enableTotp, username]);
38
+
39
+ const generateTotp = async () => {
40
+ try {
41
+ const { TOTP, Secret } = await import("otpauth");
42
+ const secret = new Secret({ size: 20 });
43
+ const totp = new TOTP({
44
+ issuer: "ClaudeDeck",
45
+ label: username,
46
+ algorithm: "SHA1",
47
+ digits: 6,
48
+ period: 30,
49
+ secret,
50
+ });
51
+
52
+ const uri = totp.toString();
53
+ setTotpSecret(secret.base32);
54
+
55
+ const QRCode = await import("qrcode");
56
+ const dataUrl = await QRCode.toDataURL(uri, {
57
+ width: 200,
58
+ margin: 2,
59
+ color: { dark: "#ffffff", light: "#00000000" },
60
+ });
61
+ setQrDataUrl(dataUrl);
62
+ } catch (err) {
63
+ console.error("Failed to generate TOTP:", err);
64
+ }
65
+ };
66
+
67
+ const handleSubmit = async (e: React.FormEvent) => {
68
+ e.preventDefault();
69
+ setError("");
70
+
71
+ if (password !== confirmPassword) {
72
+ setError("Passwords do not match");
73
+ return;
74
+ }
75
+
76
+ if (password.length < 8) {
77
+ setError("Password must be at least 8 characters");
78
+ return;
79
+ }
80
+
81
+ if (enableTotp && totpCode.length !== 6) {
82
+ setError("Enter the 6-digit code from your authenticator app");
83
+ return;
84
+ }
85
+
86
+ setLoading(true);
87
+
88
+ try {
89
+ const res = await fetch("/api/auth/setup", {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({
93
+ username,
94
+ password,
95
+ ...(enableTotp ? { totpSecret, totpCode } : {}),
96
+ }),
97
+ });
98
+
99
+ const data = await res.json();
100
+
101
+ if (!res.ok) {
102
+ setError(data.error || "Setup failed");
103
+ return;
104
+ }
105
+
106
+ router.push("/");
107
+ router.refresh();
108
+ } catch {
109
+ setError("Connection error");
110
+ } finally {
111
+ setLoading(false);
112
+ }
113
+ };
114
+
115
+ return (
116
+ <div className="bg-background flex min-h-screen items-center justify-center p-4">
117
+ <div className="border-border bg-card w-full max-w-md rounded-xl border p-8 shadow-lg">
118
+ <div className="mb-8 text-center">
119
+ <div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
120
+ <Shield className="h-6 w-6" />
121
+ </div>
122
+ <h1 className="text-foreground text-2xl font-semibold">
123
+ Welcome to ClaudeDeck
124
+ </h1>
125
+ <p className="text-muted-foreground mt-1 text-sm">
126
+ Create your account to get started
127
+ </p>
128
+ </div>
129
+
130
+ <form onSubmit={handleSubmit} className="space-y-4">
131
+ <div className="space-y-2">
132
+ <label
133
+ htmlFor="username"
134
+ className="text-foreground text-sm font-medium"
135
+ >
136
+ Username
137
+ </label>
138
+ <Input
139
+ id="username"
140
+ type="text"
141
+ value={username}
142
+ onChange={(e) => setUsername(e.target.value)}
143
+ placeholder="admin"
144
+ autoComplete="username"
145
+ autoFocus
146
+ required
147
+ minLength={3}
148
+ maxLength={32}
149
+ pattern="[a-zA-Z0-9_]+"
150
+ />
151
+ </div>
152
+
153
+ <div className="space-y-2">
154
+ <label
155
+ htmlFor="password"
156
+ className="text-foreground text-sm font-medium"
157
+ >
158
+ Password
159
+ </label>
160
+ <div className="relative">
161
+ <Input
162
+ id="password"
163
+ type={showPassword ? "text" : "password"}
164
+ value={password}
165
+ onChange={(e) => setPassword(e.target.value)}
166
+ autoComplete="new-password"
167
+ required
168
+ minLength={8}
169
+ />
170
+ <button
171
+ type="button"
172
+ onClick={() => setShowPassword(!showPassword)}
173
+ className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
174
+ tabIndex={-1}
175
+ >
176
+ {showPassword ? (
177
+ <EyeOff className="h-4 w-4" />
178
+ ) : (
179
+ <Eye className="h-4 w-4" />
180
+ )}
181
+ </button>
182
+ </div>
183
+ </div>
184
+
185
+ <div className="space-y-2">
186
+ <label
187
+ htmlFor="confirmPassword"
188
+ className="text-foreground text-sm font-medium"
189
+ >
190
+ Confirm password
191
+ </label>
192
+ <Input
193
+ id="confirmPassword"
194
+ type={showPassword ? "text" : "password"}
195
+ value={confirmPassword}
196
+ onChange={(e) => setConfirmPassword(e.target.value)}
197
+ autoComplete="new-password"
198
+ required
199
+ minLength={8}
200
+ />
201
+ </div>
202
+
203
+ <div className="border-border flex items-center justify-between rounded-lg border p-3">
204
+ <div>
205
+ <p className="text-foreground text-sm font-medium">
206
+ Two-factor authentication
207
+ </p>
208
+ <p className="text-muted-foreground text-xs">
209
+ Secure your account with TOTP
210
+ </p>
211
+ </div>
212
+ <Switch checked={enableTotp} onCheckedChange={setEnableTotp} />
213
+ </div>
214
+
215
+ {enableTotp && qrDataUrl && (
216
+ <div className="border-border space-y-3 rounded-lg border p-4">
217
+ <p className="text-foreground text-center text-sm font-medium">
218
+ Scan with your authenticator app
219
+ </p>
220
+ <div className="flex justify-center">
221
+ {/* eslint-disable-next-line @next/next/no-img-element -- base64 data URL, next/image not applicable */}
222
+ <img
223
+ src={qrDataUrl}
224
+ alt="TOTP QR Code"
225
+ className="h-[200px] w-[200px]"
226
+ />
227
+ </div>
228
+ <div className="space-y-1">
229
+ <p className="text-muted-foreground text-center text-xs">
230
+ Or enter manually:
231
+ </p>
232
+ <code className="bg-muted text-foreground block rounded p-2 text-center font-mono text-xs break-all">
233
+ {totpSecret}
234
+ </code>
235
+ </div>
236
+ <div className="space-y-2">
237
+ <label
238
+ htmlFor="totpVerify"
239
+ className="text-foreground text-sm font-medium"
240
+ >
241
+ Verification code
242
+ </label>
243
+ <Input
244
+ id="totpVerify"
245
+ type="text"
246
+ inputMode="numeric"
247
+ pattern="[0-9]*"
248
+ maxLength={6}
249
+ value={totpCode}
250
+ onChange={(e) =>
251
+ setTotpCode(e.target.value.replace(/\D/g, ""))
252
+ }
253
+ placeholder="000000"
254
+ className="text-center text-lg tracking-[0.3em]"
255
+ autoComplete="one-time-code"
256
+ />
257
+ </div>
258
+ </div>
259
+ )}
260
+
261
+ {enableTotp && !qrDataUrl && username.length < 3 && (
262
+ <p className="text-muted-foreground text-center text-sm">
263
+ Enter a username (3+ characters) to generate the QR code
264
+ </p>
265
+ )}
266
+
267
+ {error && (
268
+ <p className="text-destructive text-center text-sm">{error}</p>
269
+ )}
270
+
271
+ <Button type="submit" className="w-full" disabled={loading}>
272
+ {loading && <Loader2 className="h-4 w-4 animate-spin" />}
273
+ Create account
274
+ </Button>
275
+ </form>
276
+ </div>
277
+ </div>
278
+ );
279
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from "@/components/ui/tooltip";
22
22
  import { cn } from "@/lib/utils";
23
23
  import type { Session } from "@/lib/db";
24
+ import type { SessionStatus } from "@/components/views/types";
24
25
 
25
26
  type ViewMode = "terminal" | "files" | "git" | "workers";
26
27
 
@@ -35,6 +36,7 @@ interface DesktopTabBarProps {
35
36
  activeTabId: string;
36
37
  session: Session | null | undefined;
37
38
  sessions: Session[];
39
+ sessionStatuses?: Record<string, SessionStatus>;
38
40
  viewMode: ViewMode;
39
41
  isFocused: boolean;
40
42
  isConductor: boolean;
@@ -63,6 +65,7 @@ export function DesktopTabBar({
63
65
  activeTabId,
64
66
  session,
65
67
  sessions,
68
+ sessionStatuses,
66
69
  viewMode,
67
70
  isFocused,
68
71
  isConductor,
@@ -103,34 +106,65 @@ export function DesktopTabBar({
103
106
  >
104
107
  {/* Tabs */}
105
108
  <div className="flex min-w-0 flex-1 items-center gap-0.5">
106
- {tabs.map((tab) => (
107
- <div
108
- key={tab.id}
109
- onClick={(e) => {
110
- e.stopPropagation();
111
- onTabSwitch(tab.id);
112
- }}
113
- className={cn(
114
- "group flex cursor-pointer items-center gap-1.5 rounded-t-md px-3 py-1.5 text-xs transition-colors",
115
- tab.id === activeTabId
116
- ? "bg-background text-foreground"
117
- : "text-muted-foreground hover:text-foreground/80 hover:bg-accent/50"
118
- )}
119
- >
120
- <span className="max-w-[120px] truncate">{getTabName(tab)}</span>
121
- {tabs.length > 1 && (
122
- <button
123
- onClick={(e) => {
124
- e.stopPropagation();
125
- onTabClose(tab.id);
126
- }}
127
- className="hover:text-foreground ml-1 opacity-0 group-hover:opacity-100"
128
- >
129
- <X className="h-3 w-3" />
130
- </button>
131
- )}
132
- </div>
133
- ))}
109
+ {tabs.map((tab) => {
110
+ const tabStatus = tab.sessionId
111
+ ? sessionStatuses?.[tab.sessionId]
112
+ : undefined;
113
+ return (
114
+ <Tooltip key={tab.id}>
115
+ <TooltipTrigger asChild>
116
+ <div
117
+ onClick={(e) => {
118
+ e.stopPropagation();
119
+ onTabSwitch(tab.id);
120
+ }}
121
+ className={cn(
122
+ "group relative flex cursor-pointer items-center gap-1.5 rounded-t-md px-3 py-1.5 text-xs transition-colors",
123
+ tab.id === activeTabId
124
+ ? "bg-background text-foreground"
125
+ : "text-muted-foreground hover:text-foreground/80 hover:bg-accent/50"
126
+ )}
127
+ >
128
+ {tabStatus &&
129
+ tab.id !== activeTabId &&
130
+ (tabStatus.status === "running" ||
131
+ tabStatus.status === "waiting") && (
132
+ <span
133
+ className={cn(
134
+ "absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full",
135
+ tabStatus.status === "running" &&
136
+ "animate-pulse bg-green-500",
137
+ tabStatus.status === "waiting" &&
138
+ "animate-pulse bg-amber-500"
139
+ )}
140
+ />
141
+ )}
142
+ <span className="max-w-[120px] truncate">
143
+ {getTabName(tab)}
144
+ </span>
145
+ {tabs.length > 1 && (
146
+ <button
147
+ onClick={(e) => {
148
+ e.stopPropagation();
149
+ onTabClose(tab.id);
150
+ }}
151
+ className="hover:text-foreground ml-1 opacity-0 group-hover:opacity-100"
152
+ >
153
+ <X className="h-3 w-3" />
154
+ </button>
155
+ )}
156
+ </div>
157
+ </TooltipTrigger>
158
+ {tabStatus?.lastLine && tab.id !== activeTabId && (
159
+ <TooltipContent side="bottom" className="max-w-xs">
160
+ <p className="truncate font-mono text-xs">
161
+ {tabStatus.lastLine}
162
+ </p>
163
+ </TooltipContent>
164
+ )}
165
+ </Tooltip>
166
+ );
167
+ })}
134
168
  <Tooltip>
135
169
  <TooltipTrigger asChild>
136
170
  <Button
@@ -43,10 +43,13 @@ const GitPanel = dynamic(
43
43
  { ssr: false, loading: () => <GitPanelSkeleton /> }
44
44
  );
45
45
 
46
+ import type { SessionStatus } from "@/components/views/types";
47
+
46
48
  interface PaneProps {
47
49
  paneId: string;
48
50
  sessions: Session[];
49
51
  projects: Project[];
52
+ sessionStatuses?: Record<string, SessionStatus>;
50
53
  onRegisterTerminal: (
51
54
  paneId: string,
52
55
  tabId: string,
@@ -68,6 +71,7 @@ export const Pane = memo(function Pane({
68
71
  paneId,
69
72
  sessions,
70
73
  projects,
74
+ sessionStatuses,
71
75
  onRegisterTerminal,
72
76
  onMenuClick,
73
77
  onSelectSession,
@@ -318,6 +322,7 @@ export const Pane = memo(function Pane({
318
322
  activeTabId={paneData.activeTabId}
319
323
  session={session}
320
324
  sessions={sessions}
325
+ sessionStatuses={sessionStatuses}
321
326
  viewMode={viewMode}
322
327
  isFocused={isFocused}
323
328
  isConductor={isConductor}
@@ -14,6 +14,7 @@ import { CodeSearchResults } from "@/components/CodeSearch/CodeSearchResults";
14
14
  import { useRipgrepAvailable } from "@/data/code-search";
15
15
  import { useClaudeProjectsQuery, useClaudeSessionsQuery } from "@/data/claude";
16
16
  import type { ClaudeProject } from "@/data/claude";
17
+ import type { SessionStatus } from "@/components/views/types";
17
18
 
18
19
  interface QuickSwitcherProps {
19
20
  open: boolean;
@@ -27,6 +28,7 @@ interface QuickSwitcherProps {
27
28
  onSelectFile?: (file: string, line: number) => void;
28
29
  currentSessionId?: string;
29
30
  activeSessionWorkingDir?: string;
31
+ sessionStatuses?: Record<string, SessionStatus>;
30
32
  }
31
33
 
32
34
  interface FlatSession {
@@ -45,6 +47,7 @@ export function QuickSwitcher({
45
47
  onSelectFile,
46
48
  currentSessionId,
47
49
  activeSessionWorkingDir,
50
+ sessionStatuses,
48
51
  }: QuickSwitcherProps) {
49
52
  const [mode, setMode] = useState<"sessions" | "code">("sessions");
50
53
  const [query, setQuery] = useState("");
@@ -103,16 +106,46 @@ export function QuickSwitcher({
103
106
  // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when .data changes, not entire query objects
104
107
  }, [s0.data, s1.data, s2.data, s3.data, topProjects]);
105
108
 
109
+ // Build a map of claudeSessionId -> status for quick lookup
110
+ const statusByClaudeId = useMemo(() => {
111
+ if (!sessionStatuses) return new Map<string, SessionStatus>();
112
+ const map = new Map<string, SessionStatus>();
113
+ for (const s of Object.values(sessionStatuses)) {
114
+ if (s.claudeSessionId) {
115
+ map.set(s.claudeSessionId, s);
116
+ }
117
+ }
118
+ return map;
119
+ }, [sessionStatuses]);
120
+
106
121
  const filteredSessions = useMemo(() => {
107
- if (!query) return allSessions;
108
- const q = query.toLowerCase();
109
- return allSessions.filter(
110
- (s) =>
111
- s.summary.toLowerCase().includes(q) ||
112
- s.projectDisplayName.toLowerCase().includes(q) ||
113
- s.cwd.toLowerCase().includes(q)
114
- );
115
- }, [allSessions, query]);
122
+ let sessions = allSessions;
123
+ if (query) {
124
+ const q = query.toLowerCase();
125
+ sessions = sessions.filter(
126
+ (s) =>
127
+ s.summary.toLowerCase().includes(q) ||
128
+ s.projectDisplayName.toLowerCase().includes(q) ||
129
+ s.cwd.toLowerCase().includes(q)
130
+ );
131
+ }
132
+
133
+ // Sort: waiting first, then running, then by time
134
+ return [...sessions].sort((a, b) => {
135
+ const statusA = statusByClaudeId.get(a.sessionId)?.status;
136
+ const statusB = statusByClaudeId.get(b.sessionId)?.status;
137
+ const orderMap: Record<string, number> = {
138
+ waiting: 0,
139
+ running: 1,
140
+ };
141
+ const orderA = statusA && statusA in orderMap ? orderMap[statusA] : 2;
142
+ const orderB = statusB && statusB in orderMap ? orderMap[statusB] : 2;
143
+ if (orderA !== orderB) return orderA - orderB;
144
+ return (
145
+ new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
146
+ );
147
+ });
148
+ }, [allSessions, query, statusByClaudeId]);
116
149
 
117
150
  useEffect(() => {
118
151
  if (open) {
@@ -242,6 +275,7 @@ export function QuickSwitcher({
242
275
  ) : (
243
276
  filteredSessions.map((session, index) => {
244
277
  const isCurrent = session.sessionId === currentSessionId;
278
+ const status = statusByClaudeId.get(session.sessionId);
245
279
  return (
246
280
  <button
247
281
  key={session.sessionId}
@@ -259,11 +293,24 @@ export function QuickSwitcher({
259
293
  index === selectedIndex
260
294
  ? "bg-accent"
261
295
  : "hover:bg-accent/50",
262
- isCurrent && "bg-primary/10"
296
+ isCurrent && "bg-primary/10",
297
+ status?.status === "waiting" && "bg-amber-500/5"
263
298
  )}
264
299
  >
265
- <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-emerald-500/20 text-emerald-400">
300
+ <div className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-emerald-500/20 text-emerald-400">
266
301
  <Terminal className="h-4 w-4" />
302
+ {status && (
303
+ <span
304
+ className={cn(
305
+ "border-background absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2",
306
+ status.status === "running" &&
307
+ "animate-pulse bg-green-500",
308
+ status.status === "waiting" &&
309
+ "animate-pulse bg-amber-500",
310
+ status.status === "idle" && "bg-gray-400"
311
+ )}
312
+ />
313
+ )}
267
314
  </div>
268
315
  <div className="min-w-0 flex-1">
269
316
  <span className="block truncate text-sm font-medium">
@@ -272,6 +319,11 @@ export function QuickSwitcher({
272
319
  <span className="text-muted-foreground block truncate text-xs">
273
320
  {session.projectDisplayName}
274
321
  </span>
322
+ {status?.lastLine && (
323
+ <span className="text-muted-foreground block truncate font-mono text-[10px]">
324
+ {status.lastLine}
325
+ </span>
326
+ )}
275
327
  </div>
276
328
  <div className="text-muted-foreground flex flex-shrink-0 items-center gap-1 text-xs">
277
329
  <Clock className="h-3 w-3" />
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState, useEffect } from "react";
4
+ import { cn } from "@/lib/utils";
5
+ import { ChevronRight, Activity, AlertCircle, Moon } from "lucide-react";
6
+ import type { SessionStatus } from "@/components/views/types";
7
+
8
+ interface ActiveSessionsSectionProps {
9
+ sessionStatuses: Record<string, SessionStatus>;
10
+ onSelect: (sessionId: string) => void;
11
+ }
12
+
13
+ const STATUS_ORDER: Record<string, number> = {
14
+ waiting: 0,
15
+ running: 1,
16
+ idle: 2,
17
+ };
18
+
19
+ export function ActiveSessionsSection({
20
+ sessionStatuses,
21
+ onSelect,
22
+ }: ActiveSessionsSectionProps) {
23
+ const activeSessions = useMemo(() => {
24
+ return Object.entries(sessionStatuses)
25
+ .filter(
26
+ ([, s]) =>
27
+ s.status === "running" ||
28
+ s.status === "waiting" ||
29
+ s.status === "idle"
30
+ )
31
+ .map(([id, s]) => ({ id, ...s }))
32
+ .sort(
33
+ (a, b) => (STATUS_ORDER[a.status] ?? 3) - (STATUS_ORDER[b.status] ?? 3)
34
+ );
35
+ }, [sessionStatuses]);
36
+
37
+ const hasWaiting = activeSessions.some((s) => s.status === "waiting");
38
+ const [expanded, setExpanded] = useState(hasWaiting);
39
+
40
+ // Auto-expand when a session starts waiting
41
+ useEffect(() => {
42
+ if (hasWaiting) setExpanded(true);
43
+ }, [hasWaiting]);
44
+
45
+ if (activeSessions.length === 0) return null;
46
+
47
+ return (
48
+ <div className="mb-1">
49
+ <button
50
+ onClick={() => setExpanded((prev) => !prev)}
51
+ className={cn(
52
+ "flex w-full items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors",
53
+ hasWaiting
54
+ ? "text-amber-500"
55
+ : "text-muted-foreground hover:text-foreground"
56
+ )}
57
+ >
58
+ <ChevronRight
59
+ className={cn(
60
+ "h-3 w-3 transition-transform",
61
+ expanded && "rotate-90"
62
+ )}
63
+ />
64
+ <span>Active Sessions</span>
65
+ <span
66
+ className={cn(
67
+ "ml-auto rounded-full px-1.5 py-0.5 text-[10px]",
68
+ hasWaiting
69
+ ? "bg-amber-500/20 text-amber-500"
70
+ : "bg-muted text-muted-foreground"
71
+ )}
72
+ >
73
+ {activeSessions.length}
74
+ </span>
75
+ </button>
76
+
77
+ {expanded && (
78
+ <div className="space-y-0.5 px-1.5">
79
+ {activeSessions.map((session) => (
80
+ <button
81
+ key={session.id}
82
+ onClick={() => onSelect(session.id)}
83
+ className="hover:bg-accent group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors"
84
+ >
85
+ <StatusIcon status={session.status} />
86
+ <div className="min-w-0 flex-1">
87
+ <span className="block truncate text-xs font-medium">
88
+ {session.sessionName}
89
+ </span>
90
+ {session.lastLine && (
91
+ <span className="text-muted-foreground block truncate font-mono text-[10px]">
92
+ {session.lastLine}
93
+ </span>
94
+ )}
95
+ </div>
96
+ </button>
97
+ ))}
98
+ </div>
99
+ )}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ function StatusIcon({ status }: { status: string }) {
105
+ if (status === "running") {
106
+ return (
107
+ <Activity className="h-3 w-3 flex-shrink-0 animate-pulse text-green-500" />
108
+ );
109
+ }
110
+ if (status === "waiting") {
111
+ return (
112
+ <AlertCircle className="h-3 w-3 flex-shrink-0 animate-pulse text-amber-500" />
113
+ );
114
+ }
115
+ return <Moon className="h-3 w-3 flex-shrink-0 text-gray-400" />;
116
+ }