@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.
- package/app/api/auth/login/route.ts +57 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/api/auth/session/route.ts +29 -0
- package/app/api/auth/setup/route.ts +67 -0
- package/app/api/sessions/status/acknowledge/route.ts +8 -0
- package/app/api/sessions/status/route.ts +2 -233
- package/app/login/page.tsx +192 -0
- package/app/page.tsx +34 -0
- package/app/setup/page.tsx +279 -0
- package/components/Pane/DesktopTabBar.tsx +62 -28
- package/components/Pane/index.tsx +5 -0
- package/components/QuickSwitcher.tsx +63 -11
- package/components/SessionList/ActiveSessionsSection.tsx +116 -0
- package/components/SessionList/index.tsx +9 -1
- package/components/SessionStatusBar.tsx +155 -0
- package/components/WaitingBanner.tsx +122 -0
- package/components/views/DesktopView.tsx +32 -8
- package/components/views/types.ts +2 -0
- package/data/statuses/queries.ts +68 -34
- package/lib/auth/index.ts +15 -0
- package/lib/auth/password.ts +14 -0
- package/lib/auth/rate-limit.ts +40 -0
- package/lib/auth/session.ts +83 -0
- package/lib/auth/totp.ts +36 -0
- package/lib/claude/watcher.ts +28 -5
- package/lib/db/queries.ts +64 -0
- package/lib/db/schema.ts +19 -0
- package/lib/db/types.ts +16 -0
- package/lib/hooks/reporter.ts +116 -0
- package/lib/hooks/setup.ts +164 -0
- package/lib/orchestration.ts +6 -8
- package/lib/providers/registry.ts +1 -1
- package/lib/status-monitor.ts +278 -0
- package/package.json +5 -1
- package/server.ts +23 -0
- package/lib/status-detector.ts +0 -375
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
(
|
|
111
|
-
s
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|