@atercates/claude-deck 0.2.3 → 0.2.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/app/api/sessions/[id]/fork/route.ts +0 -1
- package/app/api/sessions/[id]/route.ts +0 -5
- package/app/api/sessions/[id]/summarize/route.ts +2 -3
- package/app/api/sessions/route.ts +2 -11
- package/app/api/sessions/status/acknowledge/route.ts +8 -0
- package/app/api/sessions/status/route.ts +2 -233
- package/app/page.tsx +6 -13
- package/components/ClaudeProjects/ClaudeProjectCard.tsx +19 -31
- package/components/ClaudeProjects/ClaudeSessionCard.tsx +20 -31
- package/components/NewSessionDialog/AdvancedSettings.tsx +3 -12
- package/components/NewSessionDialog/NewSessionDialog.types.ts +0 -10
- package/components/NewSessionDialog/ProjectSelector.tsx +2 -7
- package/components/NewSessionDialog/hooks/useNewSessionForm.ts +3 -36
- package/components/NewSessionDialog/index.tsx +0 -7
- package/components/Pane/DesktopTabBar.tsx +62 -28
- package/components/Pane/index.tsx +5 -0
- package/components/Projects/index.ts +0 -1
- package/components/QuickSwitcher.tsx +63 -11
- package/components/SessionList/ActiveSessionsSection.tsx +116 -0
- package/components/SessionList/hooks/useSessionListMutations.ts +0 -35
- package/components/SessionList/index.tsx +9 -1
- package/components/SessionStatusBar.tsx +155 -0
- package/components/WaitingBanner.tsx +122 -0
- package/components/views/DesktopView.tsx +27 -8
- package/components/views/MobileView.tsx +6 -1
- package/components/views/types.ts +2 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/queries.ts +1 -27
- package/data/statuses/queries.ts +68 -34
- package/hooks/useSessions.ts +0 -12
- package/lib/claude/watcher.ts +28 -5
- package/lib/db/queries.ts +4 -64
- package/lib/db/types.ts +0 -8
- package/lib/hooks/reporter.ts +116 -0
- package/lib/hooks/setup.ts +164 -0
- package/lib/orchestration.ts +16 -23
- package/lib/providers/registry.ts +3 -57
- package/lib/providers.ts +19 -100
- package/lib/status-monitor.ts +303 -0
- package/package.json +1 -1
- package/server.ts +5 -1
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/components/NewSessionDialog/AgentSelector.tsx +0 -37
- package/components/Projects/ProjectCard.tsx +0 -276
- package/components/TmuxSessions.tsx +0 -132
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
- package/hooks/useKeybarVisibility.ts +0 -42
- package/lib/claude/process-manager.ts +0 -278
- package/lib/status-detector.ts +0 -375
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState, useRef, useEffect, useCallback } from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import type { SessionStatus } from "@/components/views/types";
|
|
6
|
+
import { Activity, AlertCircle, Moon, ChevronUp } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
interface SessionStatusBarProps {
|
|
9
|
+
sessionStatuses: Record<string, SessionStatus>;
|
|
10
|
+
onSelectSession: (sessionId: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type StatusFilter = "running" | "waiting" | "idle" | null;
|
|
14
|
+
|
|
15
|
+
export function SessionStatusBar({
|
|
16
|
+
sessionStatuses,
|
|
17
|
+
onSelectSession,
|
|
18
|
+
}: SessionStatusBarProps) {
|
|
19
|
+
const [expandedFilter, setExpandedFilter] = useState<StatusFilter>(null);
|
|
20
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
const counts = useMemo(() => {
|
|
23
|
+
const values = Object.values(sessionStatuses);
|
|
24
|
+
return {
|
|
25
|
+
running: values.filter((s) => s.status === "running").length,
|
|
26
|
+
waiting: values.filter((s) => s.status === "waiting").length,
|
|
27
|
+
idle: values.filter((s) => s.status === "idle").length,
|
|
28
|
+
};
|
|
29
|
+
}, [sessionStatuses]);
|
|
30
|
+
|
|
31
|
+
const filteredSessions = useMemo(() => {
|
|
32
|
+
if (!expandedFilter) return [];
|
|
33
|
+
return Object.entries(sessionStatuses)
|
|
34
|
+
.filter(([, s]) => s.status === expandedFilter)
|
|
35
|
+
.map(([id, s]) => ({ id, ...s }));
|
|
36
|
+
}, [sessionStatuses, expandedFilter]);
|
|
37
|
+
|
|
38
|
+
const handleToggle = useCallback((filter: StatusFilter) => {
|
|
39
|
+
setExpandedFilter((prev) => (prev === filter ? null : filter));
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
// Close panel on outside click
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!expandedFilter) return;
|
|
45
|
+
const handler = (e: MouseEvent) => {
|
|
46
|
+
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
47
|
+
setExpandedFilter(null);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
document.addEventListener("mousedown", handler);
|
|
51
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
52
|
+
}, [expandedFilter]);
|
|
53
|
+
|
|
54
|
+
const total = counts.running + counts.waiting + counts.idle;
|
|
55
|
+
if (total === 0) return null;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div ref={panelRef} className="relative flex-shrink-0">
|
|
59
|
+
{/* Expanded panel */}
|
|
60
|
+
{expandedFilter && filteredSessions.length > 0 && (
|
|
61
|
+
<div className="border-border bg-background absolute right-0 bottom-full left-0 z-10 max-h-48 overflow-y-auto border-t shadow-lg">
|
|
62
|
+
{filteredSessions.map((session) => (
|
|
63
|
+
<button
|
|
64
|
+
key={session.id}
|
|
65
|
+
onClick={() => {
|
|
66
|
+
onSelectSession(session.id);
|
|
67
|
+
setExpandedFilter(null);
|
|
68
|
+
}}
|
|
69
|
+
className="hover:bg-accent flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
|
70
|
+
>
|
|
71
|
+
<StatusDot status={session.status} />
|
|
72
|
+
<span className="min-w-0 flex-1 truncate font-medium">
|
|
73
|
+
{session.sessionName}
|
|
74
|
+
</span>
|
|
75
|
+
{session.lastLine && (
|
|
76
|
+
<span className="text-muted-foreground max-w-[40%] truncate font-mono text-xs">
|
|
77
|
+
{session.lastLine}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</button>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Status bar */}
|
|
86
|
+
<div
|
|
87
|
+
className={cn(
|
|
88
|
+
"border-border flex h-8 items-center gap-4 border-t px-4 text-xs",
|
|
89
|
+
counts.waiting > 0 && "bg-amber-500/5"
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{counts.running > 0 && (
|
|
93
|
+
<button
|
|
94
|
+
onClick={() => handleToggle("running")}
|
|
95
|
+
className={cn(
|
|
96
|
+
"flex items-center gap-1.5 transition-colors",
|
|
97
|
+
expandedFilter === "running"
|
|
98
|
+
? "text-foreground"
|
|
99
|
+
: "text-muted-foreground hover:text-foreground"
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
<Activity className="h-3 w-3 text-green-500" />
|
|
103
|
+
<span>{counts.running} running</span>
|
|
104
|
+
{expandedFilter === "running" && <ChevronUp className="h-3 w-3" />}
|
|
105
|
+
</button>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{counts.waiting > 0 && (
|
|
109
|
+
<button
|
|
110
|
+
onClick={() => handleToggle("waiting")}
|
|
111
|
+
className={cn(
|
|
112
|
+
"flex items-center gap-1.5 transition-colors",
|
|
113
|
+
expandedFilter === "waiting"
|
|
114
|
+
? "text-foreground"
|
|
115
|
+
: "text-amber-500 hover:text-amber-400"
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<AlertCircle className="h-3 w-3 animate-pulse" />
|
|
119
|
+
<span>{counts.waiting} waiting</span>
|
|
120
|
+
{expandedFilter === "waiting" && <ChevronUp className="h-3 w-3" />}
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{counts.idle > 0 && (
|
|
125
|
+
<button
|
|
126
|
+
onClick={() => handleToggle("idle")}
|
|
127
|
+
className={cn(
|
|
128
|
+
"flex items-center gap-1.5 transition-colors",
|
|
129
|
+
expandedFilter === "idle"
|
|
130
|
+
? "text-foreground"
|
|
131
|
+
: "text-muted-foreground hover:text-foreground"
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
<Moon className="h-3 w-3" />
|
|
135
|
+
<span>{counts.idle} idle</span>
|
|
136
|
+
{expandedFilter === "idle" && <ChevronUp className="h-3 w-3" />}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function StatusDot({ status }: { status: string }) {
|
|
145
|
+
return (
|
|
146
|
+
<span
|
|
147
|
+
className={cn(
|
|
148
|
+
"inline-block h-2 w-2 flex-shrink-0 rounded-full",
|
|
149
|
+
status === "running" && "animate-pulse bg-green-500",
|
|
150
|
+
status === "waiting" && "animate-pulse bg-amber-500",
|
|
151
|
+
status === "idle" && "bg-gray-400"
|
|
152
|
+
)}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useCallback, useState } from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { AlertCircle, ArrowRight, X } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import type { SessionStatus } from "@/components/views/types";
|
|
8
|
+
|
|
9
|
+
interface WaitingBannerProps {
|
|
10
|
+
sessionStatuses: Record<string, SessionStatus>;
|
|
11
|
+
onSelectSession: (sessionId: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_VISIBLE = 3;
|
|
15
|
+
|
|
16
|
+
export function WaitingBanner({
|
|
17
|
+
sessionStatuses,
|
|
18
|
+
onSelectSession,
|
|
19
|
+
}: WaitingBannerProps) {
|
|
20
|
+
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
|
21
|
+
|
|
22
|
+
const waitingSessions = useMemo(() => {
|
|
23
|
+
const sessions = Object.entries(sessionStatuses)
|
|
24
|
+
.filter(([, s]) => s.status === "waiting")
|
|
25
|
+
.map(([id, s]) => ({ id, ...s }));
|
|
26
|
+
|
|
27
|
+
// Clear dismissed entries that are no longer waiting
|
|
28
|
+
const waitingIds = new Set(sessions.map((s) => s.id));
|
|
29
|
+
setDismissed((prev) => {
|
|
30
|
+
const next = new Set([...prev].filter((id) => waitingIds.has(id)));
|
|
31
|
+
return next.size === prev.size ? prev : next;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return sessions;
|
|
35
|
+
}, [sessionStatuses]);
|
|
36
|
+
|
|
37
|
+
const visibleSessions = useMemo(
|
|
38
|
+
() => waitingSessions.filter((s) => !dismissed.has(s.id)),
|
|
39
|
+
[waitingSessions, dismissed]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const handleDismiss = useCallback((sessionId: string) => {
|
|
43
|
+
setDismissed((prev) => new Set([...prev, sessionId]));
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const handleDismissAll = useCallback(() => {
|
|
47
|
+
setDismissed(new Set(waitingSessions.map((s) => s.id)));
|
|
48
|
+
}, [waitingSessions]);
|
|
49
|
+
|
|
50
|
+
if (visibleSessions.length === 0) return null;
|
|
51
|
+
|
|
52
|
+
const visible = visibleSessions.slice(0, MAX_VISIBLE);
|
|
53
|
+
const overflow = visibleSessions.length - MAX_VISIBLE;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex-shrink-0 border-b border-amber-500/30 bg-amber-500/5">
|
|
57
|
+
<div className="space-y-0">
|
|
58
|
+
{visible.map((session) => (
|
|
59
|
+
<div
|
|
60
|
+
key={session.id}
|
|
61
|
+
className={cn(
|
|
62
|
+
"flex items-center gap-3 px-4 py-2",
|
|
63
|
+
"border-l-2 border-amber-500"
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<AlertCircle className="h-4 w-4 flex-shrink-0 animate-pulse text-amber-500" />
|
|
67
|
+
<div className="min-w-0 flex-1">
|
|
68
|
+
<span className="text-sm font-medium">{session.sessionName}</span>
|
|
69
|
+
{session.waitingContext ? (
|
|
70
|
+
<pre className="text-muted-foreground mt-0.5 max-w-full truncate font-mono text-xs">
|
|
71
|
+
{session.waitingContext.split("\n").pop()}
|
|
72
|
+
</pre>
|
|
73
|
+
) : session.lastLine ? (
|
|
74
|
+
<pre className="text-muted-foreground mt-0.5 max-w-full truncate font-mono text-xs">
|
|
75
|
+
{session.lastLine}
|
|
76
|
+
</pre>
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
79
|
+
<div className="flex items-center gap-1">
|
|
80
|
+
<Button
|
|
81
|
+
variant="ghost"
|
|
82
|
+
size="sm"
|
|
83
|
+
className="h-7 gap-1 text-amber-600 hover:text-amber-500"
|
|
84
|
+
onClick={() => onSelectSession(session.id)}
|
|
85
|
+
>
|
|
86
|
+
Switch
|
|
87
|
+
<ArrowRight className="h-3 w-3" />
|
|
88
|
+
</Button>
|
|
89
|
+
<Button
|
|
90
|
+
variant="ghost"
|
|
91
|
+
size="icon-sm"
|
|
92
|
+
className="text-muted-foreground h-6 w-6"
|
|
93
|
+
onClick={() => handleDismiss(session.id)}
|
|
94
|
+
>
|
|
95
|
+
<X className="h-3 w-3" />
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
|
|
101
|
+
{overflow > 0 && (
|
|
102
|
+
<div className="text-muted-foreground px-4 py-1 text-xs">
|
|
103
|
+
+{overflow} more session{overflow > 1 ? "s" : ""} waiting
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{visibleSessions.length > 1 && (
|
|
108
|
+
<div className="flex justify-end px-4 py-1">
|
|
109
|
+
<Button
|
|
110
|
+
variant="ghost"
|
|
111
|
+
size="sm"
|
|
112
|
+
className="text-muted-foreground h-6 text-xs"
|
|
113
|
+
onClick={handleDismissAll}
|
|
114
|
+
>
|
|
115
|
+
Dismiss all
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
Command,
|
|
16
16
|
} from "lucide-react";
|
|
17
17
|
import { PaneLayout } from "@/components/PaneLayout";
|
|
18
|
+
import { SessionStatusBar } from "@/components/SessionStatusBar";
|
|
19
|
+
import { WaitingBanner } from "@/components/WaitingBanner";
|
|
18
20
|
import {
|
|
19
21
|
Tooltip,
|
|
20
22
|
TooltipContent,
|
|
@@ -59,6 +61,16 @@ export function DesktopView({
|
|
|
59
61
|
resumeClaudeSession,
|
|
60
62
|
renderPane,
|
|
61
63
|
}: ViewProps) {
|
|
64
|
+
const selectSessionById = (id: string) => {
|
|
65
|
+
const session = sessions.find((s) => s.id === id);
|
|
66
|
+
if (session) {
|
|
67
|
+
attachToSession(session);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const status = sessionStatuses[id];
|
|
71
|
+
resumeClaudeSession(id, status?.cwd || "~");
|
|
72
|
+
};
|
|
73
|
+
|
|
62
74
|
return (
|
|
63
75
|
<div className="bg-background flex h-screen overflow-hidden">
|
|
64
76
|
{/* Desktop Sidebar */}
|
|
@@ -71,10 +83,7 @@ export function DesktopView({
|
|
|
71
83
|
<SessionList
|
|
72
84
|
activeSessionId={focusedActiveTab?.sessionId || undefined}
|
|
73
85
|
sessionStatuses={sessionStatuses}
|
|
74
|
-
onSelect={
|
|
75
|
-
const session = sessions.find((s) => s.id === id);
|
|
76
|
-
if (session) attachToSession(session);
|
|
77
|
-
}}
|
|
86
|
+
onSelect={selectSessionById}
|
|
78
87
|
onOpenInTab={(id) => {
|
|
79
88
|
const session = sessions.find((s) => s.id === id);
|
|
80
89
|
if (session) openSessionInNewTab(session);
|
|
@@ -191,10 +200,7 @@ export function DesktopView({
|
|
|
191
200
|
.map((s) => ({ id: s.id, name: s.name }))}
|
|
192
201
|
onUpdateSettings={updateSettings}
|
|
193
202
|
onRequestPermission={requestPermission}
|
|
194
|
-
onSelectSession={
|
|
195
|
-
const session = sessions.find((s) => s.id === id);
|
|
196
|
-
if (session) attachToSession(session);
|
|
197
|
-
}}
|
|
203
|
+
onSelectSession={selectSessionById}
|
|
198
204
|
/>
|
|
199
205
|
<Button size="sm" onClick={() => newClaudeSession()}>
|
|
200
206
|
<Plus className="mr-1 h-4 w-4" />
|
|
@@ -203,10 +209,22 @@ export function DesktopView({
|
|
|
203
209
|
</div>
|
|
204
210
|
</header>
|
|
205
211
|
|
|
212
|
+
{/* Waiting Banner */}
|
|
213
|
+
<WaitingBanner
|
|
214
|
+
sessionStatuses={sessionStatuses}
|
|
215
|
+
onSelectSession={selectSessionById}
|
|
216
|
+
/>
|
|
217
|
+
|
|
206
218
|
{/* Pane Layout - full height */}
|
|
207
219
|
<div className="min-h-0 flex-1">
|
|
208
220
|
<PaneLayout renderPane={renderPane} />
|
|
209
221
|
</div>
|
|
222
|
+
|
|
223
|
+
{/* Session Status Bar */}
|
|
224
|
+
<SessionStatusBar
|
|
225
|
+
sessionStatuses={sessionStatuses}
|
|
226
|
+
onSelectSession={selectSessionById}
|
|
227
|
+
/>
|
|
210
228
|
</div>
|
|
211
229
|
|
|
212
230
|
{/* Dialogs */}
|
|
@@ -223,6 +241,7 @@ export function DesktopView({
|
|
|
223
241
|
onOpenChange={setShowQuickSwitcher}
|
|
224
242
|
currentSessionId={focusedActiveTab?.sessionId ?? undefined}
|
|
225
243
|
activeSessionWorkingDir={activeSession?.working_directory ?? undefined}
|
|
244
|
+
sessionStatuses={sessionStatuses}
|
|
226
245
|
onResumeClaudeSession={resumeClaudeSession}
|
|
227
246
|
onSelectFile={(file, line) => {
|
|
228
247
|
const absolutePath = activeSession?.working_directory
|
|
@@ -49,7 +49,12 @@ export function MobileView({
|
|
|
49
49
|
sessionStatuses={sessionStatuses}
|
|
50
50
|
onSelect={(id) => {
|
|
51
51
|
const session = sessions.find((s) => s.id === id);
|
|
52
|
-
if (session)
|
|
52
|
+
if (session) {
|
|
53
|
+
attachToSession(session);
|
|
54
|
+
} else {
|
|
55
|
+
const status = sessionStatuses[id];
|
|
56
|
+
resumeClaudeSession(id, status?.cwd || "~");
|
|
57
|
+
}
|
|
53
58
|
setSidebarOpen(false);
|
|
54
59
|
}}
|
|
55
60
|
onOpenInTab={(id) => {
|
|
@@ -5,8 +5,10 @@ import type { TabData } from "@/lib/panes";
|
|
|
5
5
|
|
|
6
6
|
export interface SessionStatus {
|
|
7
7
|
sessionName: string;
|
|
8
|
+
cwd?: string | null;
|
|
8
9
|
status: "idle" | "running" | "waiting" | "error" | "dead";
|
|
9
10
|
lastLine?: string;
|
|
11
|
+
waitingContext?: string;
|
|
10
12
|
claudeSessionId?: string | null;
|
|
11
13
|
}
|
|
12
14
|
|
package/data/sessions/index.ts
CHANGED
package/data/sessions/queries.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
-
import type { Session
|
|
2
|
+
import type { Session } from "@/lib/db";
|
|
3
3
|
import type { AgentType } from "@/lib/providers";
|
|
4
4
|
import { sessionKeys } from "./keys";
|
|
5
5
|
|
|
6
6
|
interface SessionsResponse {
|
|
7
7
|
sessions: Session[];
|
|
8
|
-
groups: Group[];
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
async function fetchSessions(): Promise<SessionsResponse> {
|
|
@@ -124,31 +123,6 @@ export function useSummarizeSession() {
|
|
|
124
123
|
});
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
export function useMoveSessionToGroup() {
|
|
128
|
-
const queryClient = useQueryClient();
|
|
129
|
-
|
|
130
|
-
return useMutation({
|
|
131
|
-
mutationFn: async ({
|
|
132
|
-
sessionId,
|
|
133
|
-
groupPath,
|
|
134
|
-
}: {
|
|
135
|
-
sessionId: string;
|
|
136
|
-
groupPath: string;
|
|
137
|
-
}) => {
|
|
138
|
-
const res = await fetch(`/api/sessions/${sessionId}`, {
|
|
139
|
-
method: "PATCH",
|
|
140
|
-
headers: { "Content-Type": "application/json" },
|
|
141
|
-
body: JSON.stringify({ groupPath }),
|
|
142
|
-
});
|
|
143
|
-
if (!res.ok) throw new Error("Failed to move session");
|
|
144
|
-
return res.json();
|
|
145
|
-
},
|
|
146
|
-
onSuccess: () => {
|
|
147
|
-
queryClient.invalidateQueries({ queryKey: sessionKeys.list() });
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
126
|
export function useMoveSessionToProject() {
|
|
153
127
|
const queryClient = useQueryClient();
|
|
154
128
|
|
package/data/statuses/queries.ts
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
3
2
|
import type { Session } from "@/lib/db";
|
|
4
3
|
import type { SessionStatus } from "@/components/views/types";
|
|
5
|
-
import { statusKeys } from "../sessions/keys";
|
|
6
|
-
|
|
7
|
-
interface StatusResponse {
|
|
8
|
-
statuses: Record<string, SessionStatus>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async function fetchStatuses(): Promise<StatusResponse> {
|
|
12
|
-
const res = await fetch("/api/sessions/status");
|
|
13
|
-
if (!res.ok) throw new Error("Failed to fetch statuses");
|
|
14
|
-
return res.json();
|
|
15
|
-
}
|
|
16
4
|
|
|
17
5
|
interface UseSessionStatusesOptions {
|
|
18
6
|
sessions: Session[];
|
|
@@ -32,38 +20,84 @@ export function useSessionStatusesQuery({
|
|
|
32
20
|
activeSessionId,
|
|
33
21
|
checkStateChanges,
|
|
34
22
|
}: UseSessionStatusesOptions) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
23
|
+
const [sessionStatuses, setSessionStatuses] = useState<
|
|
24
|
+
Record<string, SessionStatus>
|
|
25
|
+
>({});
|
|
26
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
27
|
+
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
28
|
+
null
|
|
29
|
+
);
|
|
42
30
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
return hasActive ? 5000 : 30000;
|
|
31
|
+
const handleStatuses = useCallback(
|
|
32
|
+
(statuses: Record<string, SessionStatus>) => {
|
|
33
|
+
setSessionStatuses(statuses);
|
|
48
34
|
},
|
|
49
|
-
|
|
35
|
+
[]
|
|
36
|
+
);
|
|
50
37
|
|
|
38
|
+
// Check state changes when statuses or sessions update
|
|
51
39
|
useEffect(() => {
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
const statuses = query.data.statuses;
|
|
40
|
+
if (Object.keys(sessionStatuses).length === 0) return;
|
|
55
41
|
|
|
56
42
|
const sessionStates = sessions.map((s) => ({
|
|
57
43
|
id: s.id,
|
|
58
44
|
name: s.name,
|
|
59
|
-
status: (
|
|
45
|
+
status: (sessionStatuses[s.id]?.status ||
|
|
46
|
+
"dead") as SessionStatus["status"],
|
|
60
47
|
}));
|
|
61
48
|
checkStateChanges(sessionStates, activeSessionId);
|
|
62
|
-
|
|
63
|
-
|
|
49
|
+
}, [sessionStatuses, sessions, activeSessionId, checkStateChanges]);
|
|
50
|
+
|
|
51
|
+
// WebSocket connection for push-based updates
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let disposed = false;
|
|
54
|
+
|
|
55
|
+
function connect() {
|
|
56
|
+
if (disposed) return;
|
|
57
|
+
|
|
58
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
59
|
+
const ws = new WebSocket(
|
|
60
|
+
`${protocol}//${window.location.host}/ws/updates`
|
|
61
|
+
);
|
|
62
|
+
wsRef.current = ws;
|
|
63
|
+
|
|
64
|
+
ws.onmessage = (event) => {
|
|
65
|
+
try {
|
|
66
|
+
const msg = JSON.parse(event.data);
|
|
67
|
+
if (msg.type === "session-statuses") {
|
|
68
|
+
handleStatuses(msg.statuses);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore parse errors
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
ws.onclose = () => {
|
|
76
|
+
wsRef.current = null;
|
|
77
|
+
if (!disposed) {
|
|
78
|
+
// Reconnect after 2s
|
|
79
|
+
reconnectTimeoutRef.current = setTimeout(connect, 2000);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
ws.onerror = () => {
|
|
84
|
+
ws.close();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
connect();
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
disposed = true;
|
|
92
|
+
if (reconnectTimeoutRef.current) {
|
|
93
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
94
|
+
}
|
|
95
|
+
wsRef.current?.close();
|
|
96
|
+
};
|
|
97
|
+
}, [handleStatuses]);
|
|
64
98
|
|
|
65
99
|
return {
|
|
66
|
-
sessionStatuses
|
|
67
|
-
isLoading:
|
|
100
|
+
sessionStatuses,
|
|
101
|
+
isLoading: false,
|
|
68
102
|
};
|
|
69
103
|
}
|
package/hooks/useSessions.ts
CHANGED
|
@@ -6,20 +6,17 @@ import {
|
|
|
6
6
|
useRenameSession,
|
|
7
7
|
useForkSession,
|
|
8
8
|
useSummarizeSession,
|
|
9
|
-
useMoveSessionToGroup,
|
|
10
9
|
useMoveSessionToProject,
|
|
11
10
|
} from "@/data/sessions";
|
|
12
11
|
|
|
13
12
|
export function useSessions() {
|
|
14
13
|
const { data, refetch } = useSessionsQuery();
|
|
15
14
|
const sessions = data?.sessions ?? [];
|
|
16
|
-
const groups = data?.groups ?? [];
|
|
17
15
|
|
|
18
16
|
const deleteMutation = useDeleteSession();
|
|
19
17
|
const renameMutation = useRenameSession();
|
|
20
18
|
const forkMutation = useForkSession();
|
|
21
19
|
const summarizeMutation = useSummarizeSession();
|
|
22
|
-
const moveToGroupMutation = useMoveSessionToGroup();
|
|
23
20
|
const moveToProjectMutation = useMoveSessionToProject();
|
|
24
21
|
|
|
25
22
|
const fetchSessions = useCallback(async () => {
|
|
@@ -55,13 +52,6 @@ export function useSessions() {
|
|
|
55
52
|
[summarizeMutation]
|
|
56
53
|
);
|
|
57
54
|
|
|
58
|
-
const moveSessionToGroup = useCallback(
|
|
59
|
-
async (sessionId: string, groupPath: string) => {
|
|
60
|
-
await moveToGroupMutation.mutateAsync({ sessionId, groupPath });
|
|
61
|
-
},
|
|
62
|
-
[moveToGroupMutation]
|
|
63
|
-
);
|
|
64
|
-
|
|
65
55
|
const moveSessionToProject = useCallback(
|
|
66
56
|
async (sessionId: string, projectId: string) => {
|
|
67
57
|
await moveToProjectMutation.mutateAsync({ sessionId, projectId });
|
|
@@ -71,7 +61,6 @@ export function useSessions() {
|
|
|
71
61
|
|
|
72
62
|
return {
|
|
73
63
|
sessions,
|
|
74
|
-
groups,
|
|
75
64
|
summarizingSessionId: summarizeMutation.isPending
|
|
76
65
|
? (summarizeMutation.variables as string)
|
|
77
66
|
: null,
|
|
@@ -80,7 +69,6 @@ export function useSessions() {
|
|
|
80
69
|
renameSession,
|
|
81
70
|
forkSession,
|
|
82
71
|
summarizeSession,
|
|
83
|
-
moveSessionToGroup,
|
|
84
72
|
moveSessionToProject,
|
|
85
73
|
};
|
|
86
74
|
}
|
package/lib/claude/watcher.ts
CHANGED
|
@@ -3,6 +3,8 @@ import path from "path";
|
|
|
3
3
|
import os from "os";
|
|
4
4
|
import { WebSocket } from "ws";
|
|
5
5
|
import { invalidateProject, invalidateAll } from "./jsonl-cache";
|
|
6
|
+
import { onStateFileChange, invalidateSessionName } from "../status-monitor";
|
|
7
|
+
import { STATES_DIR } from "../hooks/setup";
|
|
6
8
|
|
|
7
9
|
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
|
|
8
10
|
|
|
@@ -13,7 +15,7 @@ export function addUpdateClient(ws: WebSocket): void {
|
|
|
13
15
|
ws.on("close", () => updateClients.delete(ws));
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
function broadcast(msg: object): void {
|
|
18
|
+
export function broadcast(msg: object): void {
|
|
17
19
|
const data = JSON.stringify(msg);
|
|
18
20
|
for (const ws of updateClients) {
|
|
19
21
|
if (ws.readyState === WebSocket.OPEN) {
|
|
@@ -44,14 +46,21 @@ function handleFileChange(filePath: string): void {
|
|
|
44
46
|
|
|
45
47
|
export function startWatcher(): void {
|
|
46
48
|
try {
|
|
47
|
-
|
|
49
|
+
// Watch Claude projects for session list updates
|
|
50
|
+
const projectsWatcher = watch(CLAUDE_PROJECTS_DIR, {
|
|
48
51
|
ignoreInitial: true,
|
|
49
52
|
depth: 2,
|
|
50
53
|
ignored: [/node_modules/, /\.git/, /subagents/],
|
|
51
54
|
});
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
projectsWatcher.on("change", (fp) => {
|
|
57
|
+
handleFileChange(fp);
|
|
58
|
+
if (fp.endsWith(".jsonl")) {
|
|
59
|
+
const sessionId = path.basename(fp, ".jsonl");
|
|
60
|
+
invalidateSessionName(sessionId);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
projectsWatcher.on("add", (fp) => {
|
|
55
64
|
handleFileChange(fp);
|
|
56
65
|
const relative = path.relative(CLAUDE_PROJECTS_DIR, fp);
|
|
57
66
|
if (!relative.includes(path.sep)) {
|
|
@@ -59,12 +68,26 @@ export function startWatcher(): void {
|
|
|
59
68
|
broadcast({ type: "projects-changed" });
|
|
60
69
|
}
|
|
61
70
|
});
|
|
62
|
-
|
|
71
|
+
projectsWatcher.on("addDir", () => {
|
|
63
72
|
invalidateAll();
|
|
64
73
|
broadcast({ type: "projects-changed" });
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
console.log("> File watcher started on ~/.claude/projects/");
|
|
77
|
+
|
|
78
|
+
// Watch session state files written by hooks
|
|
79
|
+
const statesWatcher = watch(STATES_DIR, {
|
|
80
|
+
ignoreInitial: true,
|
|
81
|
+
depth: 0,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
statesWatcher.on("change", onStateFileChange);
|
|
85
|
+
statesWatcher.on("add", onStateFileChange);
|
|
86
|
+
statesWatcher.on("unlink", onStateFileChange);
|
|
87
|
+
|
|
88
|
+
console.log(
|
|
89
|
+
"> State file watcher started on ~/.claude-deck/session-states/"
|
|
90
|
+
);
|
|
68
91
|
} catch (err) {
|
|
69
92
|
console.error("Failed to start file watcher:", err);
|
|
70
93
|
}
|