@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.
@@ -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: _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={(id) => {
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={(id) => {
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;
@@ -1,18 +1,6 @@
1
- import { useQuery } from "@tanstack/react-query";
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 query = useQuery({
36
- queryKey: statusKeys.all,
37
- queryFn: fetchStatuses,
38
- staleTime: 2000,
39
- refetchInterval: (query) => {
40
- const statuses = query.state.data?.statuses;
41
- if (!statuses) return 5000;
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
- const hasActive = Object.values(statuses).some(
44
- (s) => s.status === "running" || s.status === "waiting"
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 (!query.data?.statuses) return;
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: (statuses[s.id]?.status || "dead") as SessionStatus["status"],
45
+ status: (sessionStatuses[s.id]?.status ||
46
+ "dead") as SessionStatus["status"],
60
47
  }));
61
48
  checkStateChanges(sessionStates, activeSessionId);
62
- // Note: claude_session_id is now updated server-side in /api/sessions/status
63
- }, [query.data, sessions, activeSessionId, checkStateChanges]);
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: query.data?.statuses ?? {},
67
- isLoading: query.isLoading,
100
+ sessionStatuses,
101
+ isLoading: false,
68
102
  };
69
103
  }