@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.
Files changed (43) hide show
  1. package/dist/ppm +0 -0
  2. package/dist/web/assets/{button-CQ5h5gxS.js → button-CvHWF07y.js} +1 -1
  3. package/dist/web/assets/{chat-tab-DGDxIbGD.js → chat-tab-Cbc-uzKV.js} +4 -4
  4. package/dist/web/assets/{code-editor-CYWA87cs.js → code-editor-B9e5P-DN.js} +1 -1
  5. package/dist/web/assets/{dialog-CCBmXo6-.js → dialog-Cn5zGuid.js} +1 -1
  6. package/dist/web/assets/diff-viewer-B0iMQ1Qf.js +4 -0
  7. package/dist/web/assets/git-graph-D3ls9-HA.js +1 -0
  8. package/dist/web/assets/{git-status-panel-D50lP9Ru.js → git-status-panel-UB0AyOdX.js} +1 -1
  9. package/dist/web/assets/index-BdUoflYx.css +2 -0
  10. package/dist/web/assets/index-DLIV9ojh.js +17 -0
  11. package/dist/web/assets/{project-list-Ha_JrM9s.js → project-list-D6oBUMd8.js} +1 -1
  12. package/dist/web/assets/settings-tab-DmTDAK9n.js +1 -0
  13. package/dist/web/assets/{terminal-tab-Cg4Pm_3X.js → terminal-tab-DlRo-KzS.js} +1 -1
  14. package/dist/web/index.html +7 -8
  15. package/dist/web/sw.js +1 -1
  16. package/package.json +1 -1
  17. package/src/providers/claude-agent-sdk.ts +1 -2
  18. package/src/server/ws/chat.ts +211 -94
  19. package/src/web/app.tsx +4 -11
  20. package/src/web/components/layout/draggable-tab.tsx +58 -0
  21. package/src/web/components/layout/editor-panel.tsx +64 -0
  22. package/src/web/components/layout/mobile-nav.tsx +99 -55
  23. package/src/web/components/layout/panel-layout.tsx +71 -0
  24. package/src/web/components/layout/sidebar.tsx +29 -6
  25. package/src/web/components/layout/split-drop-overlay.tsx +111 -0
  26. package/src/web/components/layout/tab-bar.tsx +60 -68
  27. package/src/web/hooks/use-chat.ts +31 -2
  28. package/src/web/hooks/use-global-keybindings.ts +8 -0
  29. package/src/web/hooks/use-tab-drag.ts +109 -0
  30. package/src/web/stores/panel-store.ts +383 -0
  31. package/src/web/stores/panel-utils.ts +116 -0
  32. package/src/web/stores/settings-store.ts +25 -17
  33. package/src/web/stores/tab-store.ts +32 -152
  34. package/dist/web/assets/diff-viewer-CX7iJZTM.js +0 -4
  35. package/dist/web/assets/dist-CYANqO1g.js +0 -1
  36. package/dist/web/assets/git-graph-BPt8qfBw.js +0 -1
  37. package/dist/web/assets/index-DBaFu6Af.js +0 -12
  38. package/dist/web/assets/index-Jhl6F2vS.css +0 -2
  39. package/dist/web/assets/settings-tab-C5SlVPjG.js +0 -1
  40. /package/dist/web/assets/{api-client-tgjN9Mx8.js → api-client-B_eCZViO.js} +0 -0
  41. /package/dist/web/assets/{dist-0XHv8Vwc.js → dist-B6sG2GPc.js} +0 -0
  42. /package/dist/web/assets/{dist-BeHIxUn0.js → dist-CBiGQxfr.js} +0 -0
  43. /package/dist/web/assets/{utils-D6me7KDg.js → utils-61GRB9Cb.js} +0 -0
@@ -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
- * Protocol: JSON messages as defined in ChatWsClientMessage / ChatWsServerMessage.
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
- // Start keepalive ping to prevent proxy/firewall from dropping idle connections
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, { providerId, ws, projectPath, pingInterval });
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 FIRST so it exists in activeSessions,
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
- const abortController = new AbortController();
83
- const entryRef = activeSessions.get(sessionId);
84
- if (entryRef) entryRef.abort = abortController;
85
-
86
- try {
87
- const userPreview = parsed.content.slice(0, 200);
88
- logSessionEvent(sessionId, "USER", userPreview);
89
- console.log(`[chat] session=${sessionId} sending message to provider=${providerId}`);
90
- let eventCount = 0;
91
- for await (const event of chatService.sendMessage(
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
- // Stop keepalive ping
165
- if (entry.pingInterval) clearInterval(entry.pingInterval);
166
- // Force-break the for-await loop — no client to receive events anymore
167
- if (entry.abort) {
168
- entry.abort.abort();
169
- entry.abort = undefined;
170
- }
171
- // Also abort the underlying SDK query so Claude stops working
172
- const provider = providerRegistry.get(entry.providerId);
173
- if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
174
- (provider as any).abortQuery(sessionId);
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 { TabBar } from "@/components/layout/tab-bar";
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
- <div className="flex-1 flex flex-col overflow-hidden">
152
- {/* Desktop tab bar */}
153
- <TabBar />
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
+ }