@atercates/claude-deck 0.2.3 → 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/sessions/status/acknowledge/route.ts +8 -0
- package/app/api/sessions/status/route.ts +2 -233
- package/app/page.tsx +34 -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/claude/watcher.ts +28 -5
- 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 +1 -1
- package/server.ts +4 -0
- package/lib/status-detector.ts +0 -375
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useCallback } from "react";
|
|
4
4
|
import { ClaudeProjectsSection } from "@/components/ClaudeProjects";
|
|
5
|
+
import { ActiveSessionsSection } from "./ActiveSessionsSection";
|
|
5
6
|
import { NewProjectDialog } from "@/components/Projects";
|
|
6
7
|
import { FolderPicker } from "@/components/FolderPicker";
|
|
7
8
|
import { SelectionToolbar } from "./SelectionToolbar";
|
|
@@ -24,7 +25,7 @@ export type { SessionListProps } from "./SessionList.types";
|
|
|
24
25
|
|
|
25
26
|
export function SessionList({
|
|
26
27
|
activeSessionId: _activeSessionId,
|
|
27
|
-
sessionStatuses
|
|
28
|
+
sessionStatuses,
|
|
28
29
|
onSelect,
|
|
29
30
|
onOpenInTab: _onOpenInTab,
|
|
30
31
|
onNewSessionInProject: _onNewSessionInProject,
|
|
@@ -137,6 +138,13 @@ export function SessionList({
|
|
|
137
138
|
</div>
|
|
138
139
|
)}
|
|
139
140
|
|
|
141
|
+
{!isInitialLoading && !hasError && sessionStatuses && (
|
|
142
|
+
<ActiveSessionsSection
|
|
143
|
+
sessionStatuses={sessionStatuses}
|
|
144
|
+
onSelect={onSelect}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
|
|
140
148
|
{!isInitialLoading && !hasError && (
|
|
141
149
|
<ClaudeProjectsSection
|
|
142
150
|
onSelectSession={(claudeSessionId, cwd, summary, projectName) => {
|
|
@@ -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,
|
|
@@ -46,6 +48,7 @@ export function DesktopView({
|
|
|
46
48
|
updateSettings,
|
|
47
49
|
requestPermission,
|
|
48
50
|
attachToSession,
|
|
51
|
+
attachToActiveTmux,
|
|
49
52
|
openSessionInNewTab,
|
|
50
53
|
handleNewSessionInProject,
|
|
51
54
|
handleOpenTerminal,
|
|
@@ -59,6 +62,20 @@ export function DesktopView({
|
|
|
59
62
|
resumeClaudeSession,
|
|
60
63
|
renderPane,
|
|
61
64
|
}: ViewProps) {
|
|
65
|
+
// Select a session by ID — handles both DB sessions and tmux-only sessions
|
|
66
|
+
const selectSessionById = (id: string) => {
|
|
67
|
+
const session = sessions.find((s) => s.id === id);
|
|
68
|
+
if (session) {
|
|
69
|
+
attachToSession(session);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Not in DB — attach directly to the running tmux session
|
|
73
|
+
const status = sessionStatuses[id];
|
|
74
|
+
if (status?.sessionName) {
|
|
75
|
+
attachToActiveTmux(id, status.sessionName);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
62
79
|
return (
|
|
63
80
|
<div className="bg-background flex h-screen overflow-hidden">
|
|
64
81
|
{/* Desktop Sidebar */}
|
|
@@ -71,10 +88,7 @@ export function DesktopView({
|
|
|
71
88
|
<SessionList
|
|
72
89
|
activeSessionId={focusedActiveTab?.sessionId || undefined}
|
|
73
90
|
sessionStatuses={sessionStatuses}
|
|
74
|
-
onSelect={
|
|
75
|
-
const session = sessions.find((s) => s.id === id);
|
|
76
|
-
if (session) attachToSession(session);
|
|
77
|
-
}}
|
|
91
|
+
onSelect={selectSessionById}
|
|
78
92
|
onOpenInTab={(id) => {
|
|
79
93
|
const session = sessions.find((s) => s.id === id);
|
|
80
94
|
if (session) openSessionInNewTab(session);
|
|
@@ -191,10 +205,7 @@ export function DesktopView({
|
|
|
191
205
|
.map((s) => ({ id: s.id, name: s.name }))}
|
|
192
206
|
onUpdateSettings={updateSettings}
|
|
193
207
|
onRequestPermission={requestPermission}
|
|
194
|
-
onSelectSession={
|
|
195
|
-
const session = sessions.find((s) => s.id === id);
|
|
196
|
-
if (session) attachToSession(session);
|
|
197
|
-
}}
|
|
208
|
+
onSelectSession={selectSessionById}
|
|
198
209
|
/>
|
|
199
210
|
<Button size="sm" onClick={() => newClaudeSession()}>
|
|
200
211
|
<Plus className="mr-1 h-4 w-4" />
|
|
@@ -203,10 +214,22 @@ export function DesktopView({
|
|
|
203
214
|
</div>
|
|
204
215
|
</header>
|
|
205
216
|
|
|
217
|
+
{/* Waiting Banner */}
|
|
218
|
+
<WaitingBanner
|
|
219
|
+
sessionStatuses={sessionStatuses}
|
|
220
|
+
onSelectSession={selectSessionById}
|
|
221
|
+
/>
|
|
222
|
+
|
|
206
223
|
{/* Pane Layout - full height */}
|
|
207
224
|
<div className="min-h-0 flex-1">
|
|
208
225
|
<PaneLayout renderPane={renderPane} />
|
|
209
226
|
</div>
|
|
227
|
+
|
|
228
|
+
{/* Session Status Bar */}
|
|
229
|
+
<SessionStatusBar
|
|
230
|
+
sessionStatuses={sessionStatuses}
|
|
231
|
+
onSelectSession={selectSessionById}
|
|
232
|
+
/>
|
|
210
233
|
</div>
|
|
211
234
|
|
|
212
235
|
{/* Dialogs */}
|
|
@@ -223,6 +246,7 @@ export function DesktopView({
|
|
|
223
246
|
onOpenChange={setShowQuickSwitcher}
|
|
224
247
|
currentSessionId={focusedActiveTab?.sessionId ?? undefined}
|
|
225
248
|
activeSessionWorkingDir={activeSession?.working_directory ?? undefined}
|
|
249
|
+
sessionStatuses={sessionStatuses}
|
|
226
250
|
onResumeClaudeSession={resumeClaudeSession}
|
|
227
251
|
onSelectFile={(file, line) => {
|
|
228
252
|
const absolutePath = activeSession?.working_directory
|
|
@@ -7,6 +7,7 @@ export interface SessionStatus {
|
|
|
7
7
|
sessionName: string;
|
|
8
8
|
status: "idle" | "running" | "waiting" | "error" | "dead";
|
|
9
9
|
lastLine?: string;
|
|
10
|
+
waitingContext?: string;
|
|
10
11
|
claudeSessionId?: string | null;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -38,6 +39,7 @@ export interface ViewProps {
|
|
|
38
39
|
|
|
39
40
|
// Handlers
|
|
40
41
|
attachToSession: (session: Session) => void;
|
|
42
|
+
attachToActiveTmux: (sessionId: string, tmuxSessionName: string) => void;
|
|
41
43
|
openSessionInNewTab: (session: Session) => void;
|
|
42
44
|
handleNewSessionInProject: (projectId: string) => void;
|
|
43
45
|
handleOpenTerminal: (projectId: string) => void;
|
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
|
}
|