@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,8 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
export async function POST() {
|
|
4
|
+
// With JSONL-based detection, acknowledge is a no-op.
|
|
5
|
+
// Status is determined by file content, not by a flag.
|
|
6
|
+
// The endpoint exists for API compatibility.
|
|
7
|
+
return NextResponse.json({ ok: true });
|
|
8
|
+
}
|
|
@@ -1,237 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
3
|
-
import { promisify } from "util";
|
|
4
|
-
import * as fs from "fs";
|
|
5
|
-
import * as path from "path";
|
|
6
|
-
import * as os from "os";
|
|
7
|
-
import { statusDetector, type SessionStatus } from "@/lib/status-detector";
|
|
8
|
-
import type { AgentType } from "@/lib/providers";
|
|
9
|
-
import {
|
|
10
|
-
getManagedSessionPattern,
|
|
11
|
-
getProviderIdFromSessionName,
|
|
12
|
-
getSessionIdFromName,
|
|
13
|
-
} from "@/lib/providers/registry";
|
|
14
|
-
import { getDb } from "@/lib/db";
|
|
15
|
-
|
|
16
|
-
const execAsync = promisify(exec);
|
|
17
|
-
|
|
18
|
-
interface SessionStatusResponse {
|
|
19
|
-
sessionName: string;
|
|
20
|
-
status: SessionStatus;
|
|
21
|
-
lastLine?: string;
|
|
22
|
-
claudeSessionId?: string | null;
|
|
23
|
-
agentType?: AgentType;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function getTmuxSessions(): Promise<string[]> {
|
|
27
|
-
try {
|
|
28
|
-
const { stdout } = await execAsync(
|
|
29
|
-
"tmux list-sessions -F '#{session_name}' 2>/dev/null || true"
|
|
30
|
-
);
|
|
31
|
-
return stdout.trim().split("\n").filter(Boolean);
|
|
32
|
-
} catch {
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function getTmuxSessionCwd(sessionName: string): Promise<string | null> {
|
|
38
|
-
try {
|
|
39
|
-
const { stdout } = await execAsync(
|
|
40
|
-
`tmux display-message -t "${sessionName}" -p "#{pane_current_path}" 2>/dev/null || echo ""`
|
|
41
|
-
);
|
|
42
|
-
const cwd = stdout.trim();
|
|
43
|
-
return cwd || null;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Get Claude session ID from tmux environment variable
|
|
50
|
-
async function getClaudeSessionIdFromEnv(
|
|
51
|
-
sessionName: string
|
|
52
|
-
): Promise<string | null> {
|
|
53
|
-
try {
|
|
54
|
-
const { stdout } = await execAsync(
|
|
55
|
-
`tmux show-environment -t "${sessionName}" CLAUDE_SESSION_ID 2>/dev/null || echo ""`
|
|
56
|
-
);
|
|
57
|
-
const line = stdout.trim();
|
|
58
|
-
if (line.startsWith("CLAUDE_SESSION_ID=")) {
|
|
59
|
-
const sessionId = line.replace("CLAUDE_SESSION_ID=", "");
|
|
60
|
-
if (sessionId && sessionId !== "null") {
|
|
61
|
-
return sessionId;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
65
|
-
} catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Get Claude session ID by looking at session files on disk
|
|
71
|
-
function getClaudeSessionIdFromFiles(projectPath: string): string | null {
|
|
72
|
-
const home = os.homedir();
|
|
73
|
-
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
|
|
74
|
-
const projectDirName = projectPath.replace(/\//g, "-");
|
|
75
|
-
const projectDir = path.join(claudeDir, "projects", projectDirName);
|
|
76
|
-
|
|
77
|
-
if (!fs.existsSync(projectDir)) {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const uuidPattern =
|
|
82
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/;
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
const files = fs.readdirSync(projectDir);
|
|
86
|
-
let mostRecent: string | null = null;
|
|
87
|
-
let mostRecentTime = 0;
|
|
88
|
-
|
|
89
|
-
for (const file of files) {
|
|
90
|
-
if (file.startsWith("agent-")) continue;
|
|
91
|
-
if (!uuidPattern.test(file)) continue;
|
|
92
|
-
|
|
93
|
-
const filePath = path.join(projectDir, file);
|
|
94
|
-
const stat = fs.statSync(filePath);
|
|
95
|
-
|
|
96
|
-
if (stat.mtimeMs > mostRecentTime) {
|
|
97
|
-
mostRecentTime = stat.mtimeMs;
|
|
98
|
-
mostRecent = file.replace(".jsonl", "");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (mostRecent && Date.now() - mostRecentTime < 5 * 60 * 1000) {
|
|
103
|
-
return mostRecent;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const configFile = path.join(claudeDir, ".claude.json");
|
|
107
|
-
if (fs.existsSync(configFile)) {
|
|
108
|
-
try {
|
|
109
|
-
const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
110
|
-
if (config.projects?.[projectPath]?.lastSessionId) {
|
|
111
|
-
return config.projects[projectPath].lastSessionId;
|
|
112
|
-
}
|
|
113
|
-
} catch {
|
|
114
|
-
// Ignore config parse errors
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return null;
|
|
119
|
-
} catch {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function getClaudeSessionId(sessionName: string): Promise<string | null> {
|
|
125
|
-
const envId = await getClaudeSessionIdFromEnv(sessionName);
|
|
126
|
-
if (envId) {
|
|
127
|
-
return envId;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const cwd = await getTmuxSessionCwd(sessionName);
|
|
131
|
-
if (cwd) {
|
|
132
|
-
return getClaudeSessionIdFromFiles(cwd);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function getLastLine(sessionName: string): Promise<string> {
|
|
139
|
-
try {
|
|
140
|
-
const { stdout } = await execAsync(
|
|
141
|
-
`tmux capture-pane -t "${sessionName}" -p -S -5 2>/dev/null || echo ""`
|
|
142
|
-
);
|
|
143
|
-
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
144
|
-
return lines.pop() || "";
|
|
145
|
-
} catch {
|
|
146
|
-
return "";
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// UUID pattern for claude-deck managed sessions (derived from registry)
|
|
151
|
-
const UUID_PATTERN = getManagedSessionPattern();
|
|
152
|
-
|
|
153
|
-
// Track previous statuses to detect changes
|
|
154
|
-
const previousStatuses = new Map<string, SessionStatus>();
|
|
155
|
-
|
|
156
|
-
function getAgentTypeFromSessionName(sessionName: string): AgentType {
|
|
157
|
-
return getProviderIdFromSessionName(sessionName) || "claude";
|
|
158
|
-
}
|
|
2
|
+
import { getStatusSnapshot } from "@/lib/status-monitor";
|
|
159
3
|
|
|
160
4
|
export async function GET() {
|
|
161
|
-
|
|
162
|
-
const sessions = await getTmuxSessions();
|
|
163
|
-
|
|
164
|
-
// Get status for claude-deck managed sessions
|
|
165
|
-
const managedSessions = sessions.filter((s) => UUID_PATTERN.test(s));
|
|
166
|
-
|
|
167
|
-
// Use the new status detector
|
|
168
|
-
const statusMap: Record<string, SessionStatusResponse> = {};
|
|
169
|
-
|
|
170
|
-
const db = getDb();
|
|
171
|
-
const sessionsToUpdate: string[] = [];
|
|
172
|
-
|
|
173
|
-
// Process all sessions in parallel for speed
|
|
174
|
-
const sessionPromises = managedSessions.map(async (sessionName) => {
|
|
175
|
-
const [status, claudeSessionId, lastLine] = await Promise.all([
|
|
176
|
-
statusDetector.getStatus(sessionName),
|
|
177
|
-
getClaudeSessionId(sessionName),
|
|
178
|
-
getLastLine(sessionName),
|
|
179
|
-
]);
|
|
180
|
-
const id = getSessionIdFromName(sessionName);
|
|
181
|
-
const agentType = getAgentTypeFromSessionName(sessionName);
|
|
182
|
-
|
|
183
|
-
return { sessionName, id, status, claudeSessionId, lastLine, agentType };
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const results = await Promise.all(sessionPromises);
|
|
187
|
-
|
|
188
|
-
for (const {
|
|
189
|
-
sessionName,
|
|
190
|
-
id,
|
|
191
|
-
status,
|
|
192
|
-
claudeSessionId,
|
|
193
|
-
lastLine,
|
|
194
|
-
agentType,
|
|
195
|
-
} of results) {
|
|
196
|
-
// Track status changes - update DB when session becomes active
|
|
197
|
-
const prevStatus = previousStatuses.get(id);
|
|
198
|
-
if (status === "running" || status === "waiting") {
|
|
199
|
-
if (prevStatus !== status) {
|
|
200
|
-
sessionsToUpdate.push(id);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
previousStatuses.set(id, status);
|
|
204
|
-
|
|
205
|
-
statusMap[id] = {
|
|
206
|
-
sessionName,
|
|
207
|
-
status,
|
|
208
|
-
lastLine,
|
|
209
|
-
claudeSessionId,
|
|
210
|
-
agentType,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Batch update sessions and claude_session_id
|
|
215
|
-
for (const id of sessionsToUpdate) {
|
|
216
|
-
db.prepare(
|
|
217
|
-
"UPDATE sessions SET updated_at = datetime('now') WHERE id = ?"
|
|
218
|
-
).run(id);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
for (const { id, claudeSessionId } of results) {
|
|
222
|
-
if (claudeSessionId) {
|
|
223
|
-
db.prepare(
|
|
224
|
-
"UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
|
|
225
|
-
).run(claudeSessionId, id, claudeSessionId);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Cleanup old trackers
|
|
230
|
-
statusDetector.cleanup();
|
|
231
|
-
|
|
232
|
-
return NextResponse.json({ statuses: statusMap });
|
|
233
|
-
} catch (error) {
|
|
234
|
-
console.error("Error getting session statuses:", error);
|
|
235
|
-
return NextResponse.json({ statuses: {} });
|
|
236
|
-
}
|
|
5
|
+
return NextResponse.json({ statuses: getStatusSnapshot() });
|
|
237
6
|
}
|
package/app/page.tsx
CHANGED
|
@@ -296,6 +296,37 @@ function HomeContent() {
|
|
|
296
296
|
[addTab, focusedPaneId, buildSessionCommand, runSessionInTerminal]
|
|
297
297
|
);
|
|
298
298
|
|
|
299
|
+
// Attach to an already-running tmux session (non-destructive)
|
|
300
|
+
const attachToActiveTmux = useCallback(
|
|
301
|
+
(sessionId: string, tmuxSessionName: string) => {
|
|
302
|
+
const terminalInfo = getTerminalWithFallback();
|
|
303
|
+
if (!terminalInfo) return;
|
|
304
|
+
|
|
305
|
+
const { terminal, paneId } = terminalInfo;
|
|
306
|
+
const activeTab = getActiveTab(paneId);
|
|
307
|
+
const isInTmux = !!activeTab?.attachedTmux;
|
|
308
|
+
|
|
309
|
+
if (isInTmux) {
|
|
310
|
+
terminal.sendInput("\x02d");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
setTimeout(
|
|
314
|
+
() => {
|
|
315
|
+
terminal.sendInput("\x03");
|
|
316
|
+
setTimeout(() => {
|
|
317
|
+
terminal.sendCommand(
|
|
318
|
+
`tmux attach -t ${tmuxSessionName} 2>/dev/null`
|
|
319
|
+
);
|
|
320
|
+
attachSession(paneId, sessionId, tmuxSessionName);
|
|
321
|
+
terminal.focus();
|
|
322
|
+
}, 50);
|
|
323
|
+
},
|
|
324
|
+
isInTmux ? 100 : 0
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
[getTerminalWithFallback, getActiveTab, attachSession]
|
|
328
|
+
);
|
|
329
|
+
|
|
299
330
|
const resumeClaudeSession = useCallback(
|
|
300
331
|
(
|
|
301
332
|
claudeSessionId: string,
|
|
@@ -464,6 +495,7 @@ function HomeContent() {
|
|
|
464
495
|
paneId={paneId}
|
|
465
496
|
sessions={sessions}
|
|
466
497
|
projects={projects}
|
|
498
|
+
sessionStatuses={sessionStatuses}
|
|
467
499
|
onRegisterTerminal={registerTerminalRef}
|
|
468
500
|
onMenuClick={isMobile ? () => setSidebarOpen(true) : undefined}
|
|
469
501
|
onSelectSession={handleSelectSession}
|
|
@@ -473,6 +505,7 @@ function HomeContent() {
|
|
|
473
505
|
[
|
|
474
506
|
sessions,
|
|
475
507
|
projects,
|
|
508
|
+
sessionStatuses,
|
|
476
509
|
registerTerminalRef,
|
|
477
510
|
isMobile,
|
|
478
511
|
handleSelectSession,
|
|
@@ -586,6 +619,7 @@ function HomeContent() {
|
|
|
586
619
|
updateSettings,
|
|
587
620
|
requestPermission,
|
|
588
621
|
attachToSession,
|
|
622
|
+
attachToActiveTmux,
|
|
589
623
|
openSessionInNewTab,
|
|
590
624
|
handleNewSessionInProject,
|
|
591
625
|
handleOpenTerminal,
|
|
@@ -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" />
|