@hienlh/ppm 0.2.17 → 0.2.18
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/dist/ppm +0 -0
- package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
- package/dist/web/assets/{chat-tab-DGDxIbGD.js → chat-tab-Cbc-uzKV.js} +4 -4
- package/dist/web/assets/{code-editor-CYWA87cs.js → code-editor-B9e5P-DN.js} +1 -1
- package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
- package/dist/web/assets/diff-viewer-B0iMQ1Qf.js +4 -0
- package/dist/web/assets/git-graph-D3ls9-HA.js +1 -0
- package/dist/web/assets/{git-status-panel-D50lP9Ru.js → git-status-panel-UB0AyOdX.js} +1 -1
- package/dist/web/assets/index-BdUoflYx.css +2 -0
- package/dist/web/assets/index-DLIV9ojh.js +17 -0
- package/dist/web/assets/{project-list-Ha_JrM9s.js → project-list-D6oBUMd8.js} +1 -1
- package/dist/web/assets/settings-tab-DmTDAK9n.js +1 -0
- package/dist/web/assets/{terminal-tab-Cg4Pm_3X.js → terminal-tab-DlRo-KzS.js} +1 -1
- package/dist/web/index.html +7 -8
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +1 -2
- package/src/server/ws/chat.ts +211 -94
- package/src/web/app.tsx +4 -11
- package/src/web/components/layout/draggable-tab.tsx +58 -0
- package/src/web/components/layout/editor-panel.tsx +64 -0
- package/src/web/components/layout/mobile-nav.tsx +99 -55
- package/src/web/components/layout/panel-layout.tsx +71 -0
- package/src/web/components/layout/sidebar.tsx +29 -6
- package/src/web/components/layout/split-drop-overlay.tsx +111 -0
- package/src/web/components/layout/tab-bar.tsx +60 -68
- package/src/web/hooks/use-chat.ts +31 -2
- package/src/web/hooks/use-global-keybindings.ts +8 -0
- package/src/web/hooks/use-tab-drag.ts +109 -0
- package/src/web/stores/panel-store.ts +383 -0
- package/src/web/stores/panel-utils.ts +116 -0
- package/src/web/stores/settings-store.ts +25 -17
- package/src/web/stores/tab-store.ts +32 -152
- package/dist/web/assets/diff-viewer-CX7iJZTM.js +0 -4
- package/dist/web/assets/dist-CYANqO1g.js +0 -1
- package/dist/web/assets/git-graph-BPt8qfBw.js +0 -1
- package/dist/web/assets/index-DBaFu6Af.js +0 -12
- package/dist/web/assets/index-Jhl6F2vS.css +0 -2
- package/dist/web/assets/settings-tab-C5SlVPjG.js +0 -1
- /package/dist/web/assets/{api-client-tgjN9Mx8.js → api-client-B_eCZViO.js} +0 -0
- /package/dist/web/assets/{dist-0XHv8Vwc.js → dist-B6sG2GPc.js} +0 -0
- /package/dist/web/assets/{dist-BeHIxUn0.js → dist-CBiGQxfr.js} +0 -0
- /package/dist/web/assets/{utils-D6me7KDg.js → utils-61GRB9Cb.js} +0 -0
package/src/server/ws/chat.ts
CHANGED
|
@@ -4,13 +4,8 @@ import { resolveProjectPath } from "../helpers/resolve-project.ts";
|
|
|
4
4
|
import { logSessionEvent } from "../../services/session-log.service.ts";
|
|
5
5
|
import type { ChatWsClientMessage } from "../../types/api.ts";
|
|
6
6
|
|
|
7
|
-
/** Tracks active chat WS connections: sessionId -> ws + abort controller + project context */
|
|
8
|
-
const activeSessions = new Map<
|
|
9
|
-
string,
|
|
10
|
-
{ providerId: string; ws: ChatWsSocket; abort?: AbortController; projectPath?: string; pingInterval?: ReturnType<typeof setInterval> }
|
|
11
|
-
>();
|
|
12
|
-
|
|
13
7
|
const PING_INTERVAL_MS = 15_000; // 15s keepalive
|
|
8
|
+
const CLEANUP_TIMEOUT_MS = 5 * 60_000; // 5min after Claude done + no FE
|
|
14
9
|
|
|
15
10
|
type ChatWsSocket = {
|
|
16
11
|
data: { type: string; sessionId: string; projectName?: string };
|
|
@@ -18,29 +13,197 @@ type ChatWsSocket = {
|
|
|
18
13
|
ping?: (data?: string | ArrayBuffer) => void;
|
|
19
14
|
};
|
|
20
15
|
|
|
16
|
+
interface SessionEntry {
|
|
17
|
+
providerId: string;
|
|
18
|
+
ws: ChatWsSocket | null;
|
|
19
|
+
abort?: AbortController;
|
|
20
|
+
projectPath?: string;
|
|
21
|
+
projectName?: string;
|
|
22
|
+
pingInterval?: ReturnType<typeof setInterval>;
|
|
23
|
+
isStreaming: boolean;
|
|
24
|
+
cleanupTimer?: ReturnType<typeof setTimeout>;
|
|
25
|
+
pendingApprovalEvent?: { type: string; requestId: string; tool: string; input: unknown };
|
|
26
|
+
/** When true, accumulate text events until next turn boundary, then flush as one message */
|
|
27
|
+
needsCatchUp: boolean;
|
|
28
|
+
/** Accumulated text content during catch-up phase */
|
|
29
|
+
catchUpText: string;
|
|
30
|
+
/** Reference to the running stream promise — prevents GC */
|
|
31
|
+
streamPromise?: Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Tracks active sessions — persists even when FE disconnects */
|
|
35
|
+
const activeSessions = new Map<string, SessionEntry>();
|
|
36
|
+
|
|
37
|
+
/** Send event to FE if connected, silently drop otherwise */
|
|
38
|
+
function safeSend(sessionId: string, event: unknown): void {
|
|
39
|
+
const entry = activeSessions.get(sessionId);
|
|
40
|
+
if (!entry?.ws) return;
|
|
41
|
+
try {
|
|
42
|
+
entry.ws.send(JSON.stringify(event));
|
|
43
|
+
} catch {
|
|
44
|
+
// WS may have closed between check and send — ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Start cleanup timer — only called when Claude is done AND no FE connected */
|
|
49
|
+
function startCleanupTimer(sessionId: string): void {
|
|
50
|
+
const entry = activeSessions.get(sessionId);
|
|
51
|
+
if (!entry) return;
|
|
52
|
+
if (entry.cleanupTimer) clearTimeout(entry.cleanupTimer);
|
|
53
|
+
entry.cleanupTimer = setTimeout(() => {
|
|
54
|
+
console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
|
|
55
|
+
logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
|
|
56
|
+
if (entry.pingInterval) clearInterval(entry.pingInterval);
|
|
57
|
+
activeSessions.delete(sessionId);
|
|
58
|
+
}, CLEANUP_TIMEOUT_MS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Standalone streaming loop — decoupled from WS message handler.
|
|
63
|
+
* Runs independently so WS close does NOT kill the Claude query.
|
|
64
|
+
*/
|
|
65
|
+
async function runStreamLoop(sessionId: string, providerId: string, content: string): Promise<void> {
|
|
66
|
+
const entry = activeSessions.get(sessionId);
|
|
67
|
+
if (!entry) return;
|
|
68
|
+
|
|
69
|
+
const abortController = new AbortController();
|
|
70
|
+
entry.abort = abortController;
|
|
71
|
+
entry.isStreaming = true;
|
|
72
|
+
entry.pendingApprovalEvent = undefined;
|
|
73
|
+
entry.needsCatchUp = false;
|
|
74
|
+
entry.catchUpText = "";
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const userPreview = content.slice(0, 200);
|
|
78
|
+
logSessionEvent(sessionId, "USER", userPreview);
|
|
79
|
+
console.log(`[chat] session=${sessionId} sending message to provider=${providerId}`);
|
|
80
|
+
let eventCount = 0;
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
for await (const event of chatService.sendMessage(providerId, sessionId, content)) {
|
|
84
|
+
if (abortController.signal.aborted) break;
|
|
85
|
+
eventCount++;
|
|
86
|
+
const ev = event as any;
|
|
87
|
+
const evType = ev.type ?? "unknown";
|
|
88
|
+
|
|
89
|
+
// Log every event
|
|
90
|
+
if (evType === "text") {
|
|
91
|
+
logSessionEvent(sessionId, "TEXT", ev.content?.slice(0, 500) ?? "");
|
|
92
|
+
} else if (evType === "tool_use") {
|
|
93
|
+
logSessionEvent(sessionId, "TOOL_USE", `${ev.tool} ${JSON.stringify(ev.input).slice(0, 300)}`);
|
|
94
|
+
} else if (evType === "tool_result") {
|
|
95
|
+
logSessionEvent(sessionId, "TOOL_RESULT", `error=${ev.isError ?? false} ${(ev.output ?? "").slice(0, 300)}`);
|
|
96
|
+
} else if (evType === "error") {
|
|
97
|
+
logSessionEvent(sessionId, "ERROR", ev.message ?? JSON.stringify(ev).slice(0, 300));
|
|
98
|
+
} else if (evType === "done") {
|
|
99
|
+
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"}`);
|
|
100
|
+
// Fire-and-forget push notification
|
|
101
|
+
import("../../services/push-notification.service.ts").then(({ pushService }) => {
|
|
102
|
+
const project = entry.projectName || "Project";
|
|
103
|
+
const session = chatService.getSession(sessionId);
|
|
104
|
+
const sessionTitle = session?.title || `Session ${sessionId.slice(0, 8)}`;
|
|
105
|
+
pushService.notifyAll("Chat completed", `${project} — ${sessionTitle}`).catch(() => {});
|
|
106
|
+
}).catch(() => {});
|
|
107
|
+
} else if (evType === "approval_request") {
|
|
108
|
+
entry.pendingApprovalEvent = ev;
|
|
109
|
+
} else {
|
|
110
|
+
logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Catch-up mode: accumulate text, flush on turn boundary
|
|
114
|
+
if (entry.needsCatchUp) {
|
|
115
|
+
if (evType === "text") {
|
|
116
|
+
entry.catchUpText += ev.content ?? "";
|
|
117
|
+
} else {
|
|
118
|
+
// Non-text event = turn boundary → flush accumulated text, then send this event
|
|
119
|
+
if (entry.catchUpText) {
|
|
120
|
+
safeSend(sessionId, { type: "text", content: entry.catchUpText });
|
|
121
|
+
}
|
|
122
|
+
entry.needsCatchUp = false;
|
|
123
|
+
entry.catchUpText = "";
|
|
124
|
+
safeSend(sessionId, event);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
safeSend(sessionId, event);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
|
|
132
|
+
console.log(`[chat] session=${sessionId} stream completed (${eventCount} events)`);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const errMsg = (e as Error).message;
|
|
135
|
+
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
136
|
+
if (!abortController.signal.aborted) {
|
|
137
|
+
safeSend(sessionId, { type: "error", message: errMsg });
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
entry.abort = undefined;
|
|
141
|
+
entry.isStreaming = false;
|
|
142
|
+
entry.pendingApprovalEvent = undefined;
|
|
143
|
+
entry.needsCatchUp = false;
|
|
144
|
+
entry.catchUpText = "";
|
|
145
|
+
// Claude is done — if no FE connected, start cleanup timer
|
|
146
|
+
if (!entry.ws) {
|
|
147
|
+
startCleanupTimer(sessionId);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
21
152
|
/**
|
|
22
153
|
* Chat WebSocket handler for Bun.serve().
|
|
23
|
-
*
|
|
154
|
+
*
|
|
155
|
+
* Session lifecycle: BE owns Claude connection. FE disconnect does NOT abort Claude.
|
|
156
|
+
* Streaming runs in standalone async function, not tied to WS message handler.
|
|
24
157
|
*/
|
|
25
158
|
export const chatWebSocket = {
|
|
26
159
|
open(ws: ChatWsSocket) {
|
|
27
160
|
const { sessionId, projectName } = ws.data;
|
|
28
|
-
// Look up session's actual provider, default to "claude-sdk"
|
|
29
161
|
const session = chatService.getSession(sessionId);
|
|
30
162
|
const providerId = session?.providerId ?? "claude-sdk";
|
|
31
163
|
|
|
32
|
-
// Resolve projectPath for skills/settings support
|
|
33
164
|
let projectPath: string | undefined;
|
|
34
165
|
if (projectName) {
|
|
35
166
|
try { projectPath = resolveProjectPath(projectName); } catch { /* ignore */ }
|
|
36
167
|
}
|
|
37
|
-
|
|
38
|
-
// Backfill projectPath on existing session
|
|
39
168
|
if (session && !session.projectPath && projectPath) {
|
|
40
169
|
session.projectPath = projectPath;
|
|
41
170
|
}
|
|
42
171
|
|
|
43
|
-
|
|
172
|
+
const existing = activeSessions.get(sessionId);
|
|
173
|
+
if (existing) {
|
|
174
|
+
// FE reconnecting to existing session — replace ws, clear cleanup timer
|
|
175
|
+
if (existing.cleanupTimer) {
|
|
176
|
+
clearTimeout(existing.cleanupTimer);
|
|
177
|
+
existing.cleanupTimer = undefined;
|
|
178
|
+
}
|
|
179
|
+
if (existing.pingInterval) clearInterval(existing.pingInterval);
|
|
180
|
+
existing.pingInterval = setInterval(() => {
|
|
181
|
+
try {
|
|
182
|
+
if (ws.ping) ws.ping();
|
|
183
|
+
else ws.send(JSON.stringify({ type: "ping" }));
|
|
184
|
+
} catch { /* ws may be closed */ }
|
|
185
|
+
}, PING_INTERVAL_MS);
|
|
186
|
+
existing.ws = ws;
|
|
187
|
+
if (projectPath) existing.projectPath = projectPath;
|
|
188
|
+
if (projectName) existing.projectName = projectName;
|
|
189
|
+
|
|
190
|
+
// If streaming, enter catch-up mode
|
|
191
|
+
if (existing.isStreaming) {
|
|
192
|
+
existing.needsCatchUp = true;
|
|
193
|
+
existing.catchUpText = "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ws.send(JSON.stringify({
|
|
197
|
+
type: "status",
|
|
198
|
+
sessionId,
|
|
199
|
+
isStreaming: existing.isStreaming,
|
|
200
|
+
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
201
|
+
}));
|
|
202
|
+
console.log(`[chat] session=${sessionId} FE reconnected (streaming=${existing.isStreaming}, catchUp=${existing.needsCatchUp})`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// New session entry
|
|
44
207
|
const pingInterval = setInterval(() => {
|
|
45
208
|
try {
|
|
46
209
|
if (ws.ping) ws.ping();
|
|
@@ -48,7 +211,16 @@ export const chatWebSocket = {
|
|
|
48
211
|
} catch { /* ws may be closed */ }
|
|
49
212
|
}, PING_INTERVAL_MS);
|
|
50
213
|
|
|
51
|
-
activeSessions.set(sessionId, {
|
|
214
|
+
activeSessions.set(sessionId, {
|
|
215
|
+
providerId,
|
|
216
|
+
ws,
|
|
217
|
+
projectPath,
|
|
218
|
+
projectName,
|
|
219
|
+
pingInterval,
|
|
220
|
+
isStreaming: false,
|
|
221
|
+
needsCatchUp: false,
|
|
222
|
+
catchUpText: "",
|
|
223
|
+
});
|
|
52
224
|
ws.send(JSON.stringify({ type: "connected", sessionId }));
|
|
53
225
|
},
|
|
54
226
|
|
|
@@ -69,8 +241,7 @@ export const chatWebSocket = {
|
|
|
69
241
|
const providerId = entry?.providerId ?? "mock";
|
|
70
242
|
|
|
71
243
|
if (parsed.type === "message") {
|
|
72
|
-
// Resume session in provider
|
|
73
|
-
// then backfill projectPath — fixes tool execution when server restarted
|
|
244
|
+
// Resume session in provider first
|
|
74
245
|
const provider = providerRegistry.get(providerId);
|
|
75
246
|
if (provider && "resumeSession" in provider) {
|
|
76
247
|
await (provider as any).resumeSession(sessionId);
|
|
@@ -79,101 +250,47 @@ export const chatWebSocket = {
|
|
|
79
250
|
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
80
251
|
}
|
|
81
252
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
providerId,
|
|
93
|
-
sessionId,
|
|
94
|
-
parsed.content,
|
|
95
|
-
)) {
|
|
96
|
-
if (abortController.signal.aborted) break;
|
|
97
|
-
eventCount++;
|
|
98
|
-
const ev = event as any;
|
|
99
|
-
const evType = ev.type ?? "unknown";
|
|
100
|
-
// Log every event to session log
|
|
101
|
-
if (evType === "text") {
|
|
102
|
-
logSessionEvent(sessionId, "TEXT", ev.content?.slice(0, 500) ?? "");
|
|
103
|
-
} else if (evType === "tool_use") {
|
|
104
|
-
logSessionEvent(sessionId, "TOOL_USE", `${ev.tool} ${JSON.stringify(ev.input).slice(0, 300)}`);
|
|
105
|
-
} else if (evType === "tool_result") {
|
|
106
|
-
logSessionEvent(sessionId, "TOOL_RESULT", `error=${ev.isError ?? false} ${(ev.output ?? "").slice(0, 300)}`);
|
|
107
|
-
} else if (evType === "error") {
|
|
108
|
-
logSessionEvent(sessionId, "ERROR", ev.message ?? JSON.stringify(ev).slice(0, 300));
|
|
109
|
-
} else if (evType === "done") {
|
|
110
|
-
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"}`);
|
|
111
|
-
// Fire-and-forget push notification with project + session title
|
|
112
|
-
import("../../services/push-notification.service.ts").then(({ pushService }) => {
|
|
113
|
-
const project = ws.data.projectName || "Project";
|
|
114
|
-
const session = chatService.getSession(sessionId);
|
|
115
|
-
const sessionTitle = session?.title || `Session ${sessionId.slice(0, 8)}`;
|
|
116
|
-
pushService.notifyAll("Chat completed", `${project} — ${sessionTitle}`).catch(() => {});
|
|
117
|
-
}).catch(() => {});
|
|
118
|
-
} else {
|
|
119
|
-
logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
|
|
120
|
-
}
|
|
121
|
-
ws.send(JSON.stringify(event));
|
|
122
|
-
}
|
|
123
|
-
logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
|
|
124
|
-
console.log(`[chat] session=${sessionId} stream completed (${eventCount} events)`);
|
|
125
|
-
} catch (e) {
|
|
126
|
-
const errMsg = (e as Error).message;
|
|
127
|
-
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
128
|
-
console.error(`[chat] session=${sessionId} error:`, errMsg);
|
|
129
|
-
if (!abortController.signal.aborted) {
|
|
130
|
-
ws.send(
|
|
131
|
-
JSON.stringify({
|
|
132
|
-
type: "error",
|
|
133
|
-
message: errMsg,
|
|
134
|
-
}),
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
} finally {
|
|
138
|
-
if (entryRef) entryRef.abort = undefined;
|
|
253
|
+
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
254
|
+
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
255
|
+
if (entry) {
|
|
256
|
+
entry.streamPromise = new Promise<void>((resolve) => {
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
runStreamLoop(sessionId, providerId, parsed.content).then(resolve, resolve);
|
|
259
|
+
}, 0);
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
setTimeout(() => runStreamLoop(sessionId, providerId, parsed.content), 0);
|
|
139
263
|
}
|
|
140
264
|
} else if (parsed.type === "cancel") {
|
|
141
|
-
// Only abort the underlying SDK query — don't break the for-await loop.
|
|
142
|
-
// This lets Claude send its final message before the iterator ends naturally.
|
|
143
265
|
const provider = providerRegistry.get(providerId);
|
|
144
266
|
if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
|
|
145
267
|
(provider as any).abortQuery(sessionId);
|
|
146
268
|
}
|
|
147
269
|
} else if (parsed.type === "approval_response") {
|
|
148
|
-
// Route approval response to the provider
|
|
149
270
|
const provider = providerRegistry.get(providerId);
|
|
150
271
|
if (provider && typeof provider.resolveApproval === "function") {
|
|
151
|
-
provider.resolveApproval(
|
|
152
|
-
parsed.requestId,
|
|
153
|
-
parsed.approved,
|
|
154
|
-
(parsed as any).data,
|
|
155
|
-
);
|
|
272
|
+
provider.resolveApproval(parsed.requestId, parsed.approved, (parsed as any).data);
|
|
156
273
|
}
|
|
274
|
+
if (entry) entry.pendingApprovalEvent = undefined;
|
|
157
275
|
}
|
|
158
276
|
},
|
|
159
277
|
|
|
160
278
|
close(ws: ChatWsSocket) {
|
|
161
279
|
const { sessionId } = ws.data;
|
|
162
280
|
const entry = activeSessions.get(sessionId);
|
|
163
|
-
if (entry)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
281
|
+
if (!entry) return;
|
|
282
|
+
|
|
283
|
+
if (entry.pingInterval) {
|
|
284
|
+
clearInterval(entry.pingInterval);
|
|
285
|
+
entry.pingInterval = undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Detach FE — do NOT abort Claude
|
|
289
|
+
entry.ws = null;
|
|
290
|
+
console.log(`[chat] session=${sessionId} FE disconnected (streaming=${entry.isStreaming})`);
|
|
291
|
+
|
|
292
|
+
if (!entry.isStreaming) {
|
|
293
|
+
startCleanupTimer(sessionId);
|
|
176
294
|
}
|
|
177
|
-
activeSessions.delete(sessionId);
|
|
178
295
|
},
|
|
179
296
|
};
|
package/src/web/app.tsx
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback } from "react";
|
|
2
2
|
import { Toaster } from "@/components/ui/sonner";
|
|
3
3
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
4
|
-
import {
|
|
5
|
-
import { TabContent } from "@/components/layout/tab-content";
|
|
4
|
+
import { PanelLayout } from "@/components/layout/panel-layout";
|
|
6
5
|
import { Sidebar } from "@/components/layout/sidebar";
|
|
7
6
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
|
8
7
|
import { MobileDrawer } from "@/components/layout/mobile-drawer";
|
|
@@ -148,15 +147,9 @@ export function App() {
|
|
|
148
147
|
<Sidebar />
|
|
149
148
|
|
|
150
149
|
{/* Content area */}
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
{/* Tab content */}
|
|
156
|
-
<main className="flex-1 overflow-hidden pb-12 md:pb-0">
|
|
157
|
-
<TabContent />
|
|
158
|
-
</main>
|
|
159
|
-
</div>
|
|
150
|
+
<main className="flex-1 overflow-hidden pb-12 md:pb-0">
|
|
151
|
+
<PanelLayout />
|
|
152
|
+
</main>
|
|
160
153
|
</div>
|
|
161
154
|
|
|
162
155
|
{/* Mobile bottom nav */}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { Tab, TabType } from "@/stores/tab-store";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
interface DraggableTabProps {
|
|
6
|
+
tab: Tab;
|
|
7
|
+
isActive: boolean;
|
|
8
|
+
icon: React.ElementType;
|
|
9
|
+
showDropBefore: boolean;
|
|
10
|
+
onSelect: () => void;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
onDragStart: (e: React.DragEvent) => void;
|
|
13
|
+
onDragOver: (e: React.DragEvent) => void;
|
|
14
|
+
onDragEnd: () => void;
|
|
15
|
+
tabRef: (el: HTMLButtonElement | null) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function DraggableTab({
|
|
19
|
+
tab, isActive, icon: Icon, showDropBefore, onSelect, onClose,
|
|
20
|
+
onDragStart, onDragOver, onDragEnd, tabRef,
|
|
21
|
+
}: DraggableTabProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="relative flex items-center">
|
|
24
|
+
{showDropBefore && (
|
|
25
|
+
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary rounded-full z-10" />
|
|
26
|
+
)}
|
|
27
|
+
<button
|
|
28
|
+
ref={tabRef}
|
|
29
|
+
draggable
|
|
30
|
+
onClick={onSelect}
|
|
31
|
+
onDragStart={onDragStart}
|
|
32
|
+
onDragOver={onDragOver}
|
|
33
|
+
onDragEnd={onDragEnd}
|
|
34
|
+
className={cn(
|
|
35
|
+
"group flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors",
|
|
36
|
+
"border-b-2 -mb-[1px] cursor-grab active:cursor-grabbing",
|
|
37
|
+
isActive
|
|
38
|
+
? "border-primary bg-surface text-foreground"
|
|
39
|
+
: "border-transparent text-text-secondary hover:text-foreground hover:bg-surface-elevated",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<Icon className="size-4" />
|
|
43
|
+
<span className="max-w-[120px] truncate">{tab.title}</span>
|
|
44
|
+
{tab.closable && (
|
|
45
|
+
<span
|
|
46
|
+
role="button"
|
|
47
|
+
tabIndex={0}
|
|
48
|
+
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
|
49
|
+
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onClose(); } }}
|
|
50
|
+
className="ml-1 opacity-0 group-hover:opacity-100 rounded-sm hover:bg-surface-elevated p-0.5 transition-opacity"
|
|
51
|
+
>
|
|
52
|
+
<X className="size-3" />
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Suspense, lazy } from "react";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
3
|
+
import { usePanelStore } from "@/stores/panel-store";
|
|
4
|
+
import type { TabType } from "@/stores/tab-store";
|
|
5
|
+
import { TabBar } from "./tab-bar";
|
|
6
|
+
import { SplitDropOverlay } from "./split-drop-overlay";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentType<{ metadata?: Record<string, unknown>; tabId?: string }>>> = {
|
|
10
|
+
projects: lazy(() => import("@/components/projects/project-list").then((m) => ({ default: m.ProjectList }))),
|
|
11
|
+
terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
|
|
12
|
+
chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
|
|
13
|
+
editor: lazy(() => import("@/components/editor/code-editor").then((m) => ({ default: m.CodeEditor }))),
|
|
14
|
+
"git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
|
|
15
|
+
"git-status": lazy(() => import("@/components/git/git-status-panel").then((m) => ({ default: m.GitStatusPanel }))),
|
|
16
|
+
"git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
|
|
17
|
+
settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface EditorPanelProps {
|
|
21
|
+
panelId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function EditorPanel({ panelId }: EditorPanelProps) {
|
|
25
|
+
const panel = usePanelStore((s) => s.panels[panelId]);
|
|
26
|
+
const isFocused = usePanelStore((s) => s.focusedPanelId === panelId);
|
|
27
|
+
const panelCount = usePanelStore((s) => Object.keys(s.panels).length);
|
|
28
|
+
|
|
29
|
+
if (!panel) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={cn(
|
|
34
|
+
"flex flex-col h-full overflow-hidden",
|
|
35
|
+
panelCount > 1 && "border border-transparent",
|
|
36
|
+
panelCount > 1 && isFocused && "border-primary/30",
|
|
37
|
+
)}
|
|
38
|
+
onClick={() => usePanelStore.getState().setFocusedPanel(panelId)}
|
|
39
|
+
>
|
|
40
|
+
<TabBar panelId={panelId} />
|
|
41
|
+
|
|
42
|
+
<div className="flex-1 overflow-hidden relative">
|
|
43
|
+
{panel.tabs.length === 0 ? (
|
|
44
|
+
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
45
|
+
Drop a tab here
|
|
46
|
+
</div>
|
|
47
|
+
) : (
|
|
48
|
+
panel.tabs.map((tab) => {
|
|
49
|
+
const Component = TAB_COMPONENTS[tab.type];
|
|
50
|
+
const isActive = tab.id === panel.activeTabId;
|
|
51
|
+
return (
|
|
52
|
+
<div key={tab.id} className={isActive ? "h-full w-full" : "hidden"}>
|
|
53
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="size-6 animate-spin text-primary" /></div>}>
|
|
54
|
+
<Component metadata={tab.metadata} tabId={tab.id} />
|
|
55
|
+
</Suspense>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
})
|
|
59
|
+
)}
|
|
60
|
+
<SplitDropOverlay panelId={panelId} />
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|