@wong2kim/wmux 1.0.0

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 (122) hide show
  1. package/README.md +157 -0
  2. package/assets/icon.ico +0 -0
  3. package/assets/icon.svg +6 -0
  4. package/dist/cli/cli/client.js +102 -0
  5. package/dist/cli/cli/commands/browser.js +137 -0
  6. package/dist/cli/cli/commands/input.js +80 -0
  7. package/dist/cli/cli/commands/notify.js +28 -0
  8. package/dist/cli/cli/commands/pane.js +88 -0
  9. package/dist/cli/cli/commands/surface.js +98 -0
  10. package/dist/cli/cli/commands/system.js +98 -0
  11. package/dist/cli/cli/commands/workspace.js +117 -0
  12. package/dist/cli/cli/index.js +140 -0
  13. package/dist/cli/cli/utils.js +47 -0
  14. package/dist/cli/shared/constants.js +54 -0
  15. package/dist/cli/shared/rpc.js +33 -0
  16. package/dist/cli/shared/types.js +79 -0
  17. package/dist/mcp/mcp/index.js +60 -0
  18. package/dist/mcp/mcp/wmux-client.js +146 -0
  19. package/dist/mcp/shared/constants.js +54 -0
  20. package/dist/mcp/shared/rpc.js +33 -0
  21. package/dist/mcp/shared/types.js +79 -0
  22. package/forge.config.ts +61 -0
  23. package/index.html +12 -0
  24. package/package.json +84 -0
  25. package/postcss.config.js +6 -0
  26. package/src/cli/client.ts +76 -0
  27. package/src/cli/commands/browser.ts +128 -0
  28. package/src/cli/commands/input.ts +72 -0
  29. package/src/cli/commands/notify.ts +29 -0
  30. package/src/cli/commands/pane.ts +90 -0
  31. package/src/cli/commands/surface.ts +102 -0
  32. package/src/cli/commands/system.ts +95 -0
  33. package/src/cli/commands/workspace.ts +116 -0
  34. package/src/cli/index.ts +145 -0
  35. package/src/cli/utils.ts +44 -0
  36. package/src/main/index.ts +86 -0
  37. package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
  38. package/src/main/ipc/handlers/metadata.handler.ts +56 -0
  39. package/src/main/ipc/handlers/pty.handler.ts +69 -0
  40. package/src/main/ipc/handlers/session.handler.ts +17 -0
  41. package/src/main/ipc/handlers/shell.handler.ts +11 -0
  42. package/src/main/ipc/registerHandlers.ts +31 -0
  43. package/src/main/mcp/McpRegistrar.ts +156 -0
  44. package/src/main/metadata/MetadataCollector.ts +58 -0
  45. package/src/main/notification/ToastManager.ts +32 -0
  46. package/src/main/pipe/PipeServer.ts +190 -0
  47. package/src/main/pipe/RpcRouter.ts +46 -0
  48. package/src/main/pipe/handlers/_bridge.ts +40 -0
  49. package/src/main/pipe/handlers/browser.rpc.ts +132 -0
  50. package/src/main/pipe/handlers/input.rpc.ts +120 -0
  51. package/src/main/pipe/handlers/meta.rpc.ts +59 -0
  52. package/src/main/pipe/handlers/notify.rpc.ts +53 -0
  53. package/src/main/pipe/handlers/pane.rpc.ts +39 -0
  54. package/src/main/pipe/handlers/surface.rpc.ts +43 -0
  55. package/src/main/pipe/handlers/system.rpc.ts +36 -0
  56. package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
  57. package/src/main/pty/AgentDetector.ts +247 -0
  58. package/src/main/pty/OscParser.ts +81 -0
  59. package/src/main/pty/PTYBridge.ts +88 -0
  60. package/src/main/pty/PTYManager.ts +104 -0
  61. package/src/main/pty/ShellDetector.ts +63 -0
  62. package/src/main/session/SessionManager.ts +53 -0
  63. package/src/main/updater/AutoUpdater.ts +132 -0
  64. package/src/main/window/createWindow.ts +71 -0
  65. package/src/mcp/README.md +56 -0
  66. package/src/mcp/index.ts +153 -0
  67. package/src/mcp/wmux-client.ts +127 -0
  68. package/src/preload/index.ts +111 -0
  69. package/src/preload/preload.ts +108 -0
  70. package/src/renderer/App.tsx +5 -0
  71. package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
  72. package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
  73. package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
  74. package/src/renderer/components/Company/CompanyView.tsx +7 -0
  75. package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
  76. package/src/renderer/components/Layout/AppLayout.tsx +234 -0
  77. package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
  78. package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
  79. package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
  80. package/src/renderer/components/Pane/Pane.tsx +122 -0
  81. package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
  82. package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
  83. package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
  84. package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
  85. package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
  86. package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
  87. package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
  88. package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
  89. package/src/renderer/components/Terminal/Terminal.tsx +102 -0
  90. package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
  91. package/src/renderer/hooks/useKeyboard.ts +310 -0
  92. package/src/renderer/hooks/useNotificationListener.ts +80 -0
  93. package/src/renderer/hooks/useNotificationSound.ts +75 -0
  94. package/src/renderer/hooks/useRpcBridge.ts +451 -0
  95. package/src/renderer/hooks/useT.ts +11 -0
  96. package/src/renderer/hooks/useTerminal.ts +349 -0
  97. package/src/renderer/hooks/useViCopyMode.ts +320 -0
  98. package/src/renderer/i18n/index.ts +69 -0
  99. package/src/renderer/i18n/locales/en.ts +157 -0
  100. package/src/renderer/i18n/locales/ja.ts +155 -0
  101. package/src/renderer/i18n/locales/ko.ts +155 -0
  102. package/src/renderer/i18n/locales/zh.ts +155 -0
  103. package/src/renderer/index.tsx +6 -0
  104. package/src/renderer/stores/index.ts +19 -0
  105. package/src/renderer/stores/slices/notificationSlice.ts +56 -0
  106. package/src/renderer/stores/slices/paneSlice.ts +141 -0
  107. package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
  108. package/src/renderer/stores/slices/uiSlice.ts +247 -0
  109. package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
  110. package/src/renderer/styles/globals.css +150 -0
  111. package/src/renderer/themes.ts +99 -0
  112. package/src/shared/constants.ts +53 -0
  113. package/src/shared/electron.d.ts +11 -0
  114. package/src/shared/rpc.ts +71 -0
  115. package/src/shared/types.ts +176 -0
  116. package/tailwind.config.js +11 -0
  117. package/tsconfig.cli.json +24 -0
  118. package/tsconfig.json +21 -0
  119. package/tsconfig.mcp.json +25 -0
  120. package/vite.main.config.ts +14 -0
  121. package/vite.preload.config.ts +9 -0
  122. package/vite.renderer.config.ts +6 -0
@@ -0,0 +1,253 @@
1
+ import { useRef, useState, useCallback, useEffect } from 'react';
2
+ import { useT } from '../../hooks/useT';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // SVG Icon components
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function IconBack() {
9
+ return (
10
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
11
+ <polyline points="9,2 4,7 9,12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
12
+ </svg>
13
+ );
14
+ }
15
+
16
+ function IconForward() {
17
+ return (
18
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
19
+ <polyline points="5,2 10,7 5,12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
20
+ </svg>
21
+ );
22
+ }
23
+
24
+ function IconRefresh() {
25
+ return (
26
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
27
+ <path d="M12 7A5 5 0 1 1 7 2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
28
+ <polyline points="7,0.5 9.5,2.5 7,4.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
29
+ </svg>
30
+ );
31
+ }
32
+
33
+ function IconDevTools() {
34
+ return (
35
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
36
+ <rect x="1" y="1" width="12" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
37
+ <line x1="1" y1="4.5" x2="13" y2="4.5" stroke="currentColor" strokeWidth="1.2" />
38
+ <polyline points="3.5,7 5.5,9 3.5,11" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
39
+ <line x1="7" y1="11" x2="10.5" y2="11" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
40
+ </svg>
41
+ );
42
+ }
43
+
44
+ function IconClose() {
45
+ return (
46
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
47
+ <line x1="2" y1="2" x2="10" y2="10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
48
+ <line x1="10" y1="2" x2="2" y2="10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
49
+ </svg>
50
+ );
51
+ }
52
+
53
+ function IconLock() {
54
+ return (
55
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
56
+ <rect x="1.5" y="4.5" width="8" height="5.5" rx="1" stroke="currentColor" strokeWidth="1.2" />
57
+ <path d="M3.5 4.5V3a2 2 0 0 1 4 0v1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
58
+ </svg>
59
+ );
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // BrowserToolbar props
64
+ // ---------------------------------------------------------------------------
65
+
66
+ interface BrowserToolbarProps {
67
+ currentUrl: string;
68
+ isLoading: boolean;
69
+ canGoBack: boolean;
70
+ canGoForward: boolean;
71
+ isActive: boolean;
72
+ onNavigate: (url: string) => void;
73
+ onBack: () => void;
74
+ onForward: () => void;
75
+ onRefresh: () => void;
76
+ onOpenDevTools: () => void;
77
+ onClose: () => void;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Component
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export default function BrowserToolbar({
85
+ currentUrl,
86
+ isLoading,
87
+ canGoBack,
88
+ canGoForward,
89
+ isActive,
90
+ onNavigate,
91
+ onBack,
92
+ onForward,
93
+ onRefresh,
94
+ onOpenDevTools,
95
+ onClose,
96
+ }: BrowserToolbarProps) {
97
+ const t = useT();
98
+ const [inputValue, setInputValue] = useState(currentUrl);
99
+ const [isFocused, setIsFocused] = useState(false);
100
+ const inputRef = useRef<HTMLInputElement>(null);
101
+
102
+ // Sync display URL when not focused
103
+ useEffect(() => {
104
+ if (!isFocused) {
105
+ setInputValue(currentUrl);
106
+ }
107
+ }, [currentUrl, isFocused]);
108
+
109
+ // Ctrl+L focuses the URL bar — only register when this browser panel is active
110
+ useEffect(() => {
111
+ if (!isActive) return;
112
+ const handler = (e: KeyboardEvent) => {
113
+ if (e.ctrlKey && !e.shiftKey && !e.altKey && e.key === 'l') {
114
+ e.preventDefault();
115
+ inputRef.current?.focus();
116
+ inputRef.current?.select();
117
+ }
118
+ };
119
+ window.addEventListener('keydown', handler);
120
+ return () => window.removeEventListener('keydown', handler);
121
+ }, [isActive]);
122
+
123
+ const handleSubmit = useCallback((e: React.FormEvent) => {
124
+ e.preventDefault();
125
+ const raw = inputValue.trim();
126
+ if (!raw) return;
127
+ // Normalize: add protocol if missing
128
+ let url = raw;
129
+ if (!/^https?:\/\//i.test(url) && !/^about:/i.test(url)) {
130
+ // If it looks like a domain, add https://; otherwise treat as search
131
+ if (/^[\w-]+(\.[\w-]+)+([\/?#].*)?$/.test(url)) {
132
+ url = `https://${url}`;
133
+ } else {
134
+ url = `https://www.google.com/search?q=${encodeURIComponent(url)}`;
135
+ }
136
+ }
137
+ setInputValue(url);
138
+ onNavigate(url);
139
+ inputRef.current?.blur();
140
+ }, [inputValue, onNavigate]);
141
+
142
+ const isSecure = currentUrl.startsWith('https://');
143
+
144
+ const btnBase = 'flex items-center justify-center w-6 h-6 rounded transition-colors duration-100';
145
+ const btnEnabled = `${btnBase} text-[var(--text-sub2)] hover:text-[var(--text-main)] hover:bg-[var(--bg-surface)] cursor-pointer`;
146
+ const btnDisabled = `${btnBase} text-[var(--bg-overlay)] cursor-default`;
147
+
148
+ return (
149
+ <div
150
+ className="flex items-center gap-1.5 px-2 py-1.5 shrink-0"
151
+ style={{ backgroundColor: 'var(--bg-mantle)', borderBottom: '1px solid var(--bg-surface)' }}
152
+ >
153
+ {/* Back */}
154
+ <button
155
+ className={canGoBack ? btnEnabled : btnDisabled}
156
+ onClick={canGoBack ? onBack : undefined}
157
+ title={t('browser.back')}
158
+ tabIndex={-1}
159
+ >
160
+ <IconBack />
161
+ </button>
162
+
163
+ {/* Forward */}
164
+ <button
165
+ className={canGoForward ? btnEnabled : btnDisabled}
166
+ onClick={canGoForward ? onForward : undefined}
167
+ title={t('browser.forward')}
168
+ tabIndex={-1}
169
+ >
170
+ <IconForward />
171
+ </button>
172
+
173
+ {/* Refresh */}
174
+ <button
175
+ className={btnEnabled}
176
+ onClick={onRefresh}
177
+ title={t('browser.reload')}
178
+ tabIndex={-1}
179
+ >
180
+ <span className={isLoading ? 'animate-spin' : ''}>
181
+ <IconRefresh />
182
+ </span>
183
+ </button>
184
+
185
+ {/* URL bar */}
186
+ <form className="flex-1 min-w-0" onSubmit={handleSubmit}>
187
+ <div
188
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded-md"
189
+ style={{
190
+ backgroundColor: isFocused ? 'var(--bg-base)' : '#11111b',
191
+ border: `1px solid ${isFocused ? 'var(--accent-blue)' : 'var(--bg-surface)'}`,
192
+ transition: 'border-color 0.15s',
193
+ }}
194
+ >
195
+ {/* Lock icon */}
196
+ <span className={isSecure ? 'text-[var(--accent-green)]' : 'text-[var(--text-muted)]'} style={{ flexShrink: 0 }}>
197
+ <IconLock />
198
+ </span>
199
+
200
+ {/* Loading indicator */}
201
+ {isLoading && (
202
+ <span className="w-1.5 h-1.5 rounded-full bg-[var(--accent-blue)] animate-pulse shrink-0" />
203
+ )}
204
+
205
+ <input
206
+ ref={inputRef}
207
+ type="text"
208
+ value={inputValue}
209
+ onChange={(e) => setInputValue(e.target.value)}
210
+ onFocus={() => {
211
+ setIsFocused(true);
212
+ inputRef.current?.select();
213
+ }}
214
+ onBlur={() => {
215
+ setIsFocused(false);
216
+ setInputValue(currentUrl);
217
+ }}
218
+ onKeyDown={(e) => {
219
+ if (e.key === 'Escape') {
220
+ setInputValue(currentUrl);
221
+ inputRef.current?.blur();
222
+ }
223
+ }}
224
+ className="flex-1 min-w-0 bg-transparent text-[var(--text-main)] text-xs outline-none"
225
+ style={{ fontFamily: 'ui-monospace, monospace' }}
226
+ spellCheck={false}
227
+ autoComplete="off"
228
+ />
229
+ </div>
230
+ </form>
231
+
232
+ {/* DevTools */}
233
+ <button
234
+ className={btnEnabled}
235
+ onClick={onOpenDevTools}
236
+ title={t('browser.devToolsTooltip')}
237
+ tabIndex={-1}
238
+ >
239
+ <IconDevTools />
240
+ </button>
241
+
242
+ {/* Close */}
243
+ <button
244
+ className={`${btnBase} text-[var(--text-sub2)] hover:text-[var(--accent-red)] hover:bg-[#3b1e1e] cursor-pointer`}
245
+ onClick={onClose}
246
+ title={t('browser.close')}
247
+ tabIndex={-1}
248
+ >
249
+ <IconClose />
250
+ </button>
251
+ </div>
252
+ );
253
+ }
@@ -0,0 +1,3 @@
1
+ export default function ApprovalDialog() {
2
+ return null;
3
+ }
@@ -0,0 +1,7 @@
1
+ interface CompanyViewProps {
2
+ onClose: () => void;
3
+ }
4
+
5
+ export default function CompanyView({ onClose }: CompanyViewProps) {
6
+ return null;
7
+ }
@@ -0,0 +1,3 @@
1
+ export default function MessageFeedPanel() {
2
+ return null;
3
+ }
@@ -0,0 +1,234 @@
1
+ import { useEffect, useState, useRef } from 'react';
2
+ import { useStore } from '../../stores';
3
+ import Sidebar from '../Sidebar/Sidebar';
4
+ import MiniSidebar from '../Sidebar/MiniSidebar';
5
+ import PaneContainer from '../Pane/PaneContainer';
6
+ import StatusBar from '../StatusBar/StatusBar';
7
+ import NotificationPanel from '../Notification/NotificationPanel';
8
+ import CommandPalette from '../Palette/CommandPalette';
9
+ import SettingsPanel from '../Settings/SettingsPanel';
10
+ import ApprovalDialog from '../Company/ApprovalDialog';
11
+ import CompanyView from '../Company/CompanyView';
12
+ import MessageFeedPanel from '../Company/MessageFeedPanel';
13
+ import { useKeyboard } from '../../hooks/useKeyboard';
14
+ import { useNotificationListener } from '../../hooks/useNotificationListener';
15
+ import { useRpcBridge } from '../../hooks/useRpcBridge';
16
+ import type { SessionData, PaneLeaf } from '../../../shared/types';
17
+
18
+ export default function AppLayout() {
19
+ const sidebarVisible = useStore((s) => s.sidebarVisible);
20
+ const sidebarPosition = useStore((s) => s.sidebarPosition);
21
+ const companyViewVisible = useStore((s) => s.companyViewVisible);
22
+ const setCompanyViewVisible = useStore((s) => s.setCompanyViewVisible);
23
+ const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
24
+ const workspaces = useStore((s) => s.workspaces);
25
+ const addSurface = useStore((s) => s.addSurface);
26
+
27
+ const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId);
28
+
29
+ useKeyboard();
30
+ useNotificationListener();
31
+ useRpcBridge();
32
+
33
+ // ─── Drop overlay (VS Code-style) ─────────────────────────────────────
34
+ // A transparent full-window overlay appears during external file drags.
35
+ // This guarantees the OS-level cursor shows "copy" regardless of WebGL canvas.
36
+ const [isDragging, setIsDragging] = useState(false);
37
+ const dragCounterRef = useRef(0);
38
+
39
+ useEffect(() => {
40
+ // dragenter/dragleave fire for every child boundary crossing,
41
+ // so we use a counter to track when the drag truly leaves the window.
42
+ const onEnter = (e: DragEvent) => {
43
+ e.preventDefault();
44
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
45
+ dragCounterRef.current++;
46
+ if (dragCounterRef.current === 1) setIsDragging(true);
47
+ };
48
+ const onLeave = () => {
49
+ dragCounterRef.current--;
50
+ if (dragCounterRef.current <= 0) {
51
+ dragCounterRef.current = 0;
52
+ setIsDragging(false);
53
+ }
54
+ };
55
+ const onDrop = (e: DragEvent) => {
56
+ e.preventDefault();
57
+ dragCounterRef.current = 0;
58
+ setIsDragging(false);
59
+
60
+ const files = e.dataTransfer?.files;
61
+ if (!files || files.length === 0) return;
62
+
63
+ // Get active terminal's PTY ID
64
+ const state = useStore.getState();
65
+ const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
66
+ if (!ws) return;
67
+
68
+ const findLeaf = (pane: typeof ws.rootPane): PaneLeaf | null => {
69
+ if (pane.type === 'leaf') return pane.id === ws.activePaneId ? pane : null;
70
+ for (const child of pane.children) {
71
+ const found = findLeaf(child);
72
+ if (found) return found;
73
+ }
74
+ return null;
75
+ };
76
+ const leaf = findLeaf(ws.rootPane);
77
+ if (!leaf) return;
78
+
79
+ const activeSurface = leaf.surfaces.find((s) => s.id === leaf.activeSurfaceId);
80
+ if (!activeSurface || activeSurface.surfaceType === 'browser') return;
81
+
82
+ const ptyId = activeSurface.ptyId;
83
+ const paths: string[] = [];
84
+ for (let i = 0; i < files.length; i++) {
85
+ paths.push((files[i] as File & { path: string }).path);
86
+ }
87
+ const text = paths.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' ');
88
+ window.electronAPI.pty.write(ptyId, text);
89
+ };
90
+ // Prevent default on dragover at window level to allow drop everywhere
91
+ const onOver = (e: DragEvent) => {
92
+ e.preventDefault();
93
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
94
+ };
95
+ // Use capture phase so these fire before any child element (e.g. xterm
96
+ // WebGL canvas) can consume the event and cause a "forbidden" cursor.
97
+ window.addEventListener('dragenter', onEnter, true);
98
+ window.addEventListener('dragleave', onLeave, true);
99
+ window.addEventListener('dragover', onOver, true);
100
+ window.addEventListener('drop', onDrop, true);
101
+ return () => {
102
+ window.removeEventListener('dragenter', onEnter, true);
103
+ window.removeEventListener('dragleave', onLeave, true);
104
+ window.removeEventListener('dragover', onOver, true);
105
+ window.removeEventListener('drop', onDrop, true);
106
+ };
107
+ }, []);
108
+
109
+ // ė•ą ė‹œėž‘ ė‹œ ė„¸ė…˜ ëŗĩ뛐
110
+ useEffect(() => {
111
+ window.electronAPI.session.load().then((saved: SessionData | null) => {
112
+ if (!saved) return;
113
+ useStore.getState().loadSession(saved);
114
+ });
115
+ }, []);
116
+
117
+ // Save session on beforeunload
118
+ useEffect(() => {
119
+ const saveSession = () => {
120
+ const state = useStore.getState();
121
+ // Strip dangerous flags from session persistence
122
+ const companySafe = state.company ? { ...state.company, skipPermissions: undefined } : null;
123
+ const data: SessionData = {
124
+ workspaces: state.workspaces,
125
+ activeWorkspaceId: state.activeWorkspaceId,
126
+ sidebarVisible: state.sidebarVisible,
127
+ sidebarMode: state.sidebarMode,
128
+ company: companySafe,
129
+ memberCosts: state.memberCosts,
130
+ sessionStartTime: state.sessionStartTime,
131
+ // User preferences
132
+ theme: state.theme,
133
+ locale: state.locale,
134
+ terminalFontSize: state.terminalFontSize,
135
+ terminalFontFamily: state.terminalFontFamily,
136
+ defaultShell: state.defaultShell,
137
+ scrollbackLines: state.scrollbackLines,
138
+ sidebarPosition: state.sidebarPosition,
139
+ notificationSoundEnabled: state.notificationSoundEnabled,
140
+ toastEnabled: state.toastEnabled,
141
+ notificationRingEnabled: state.notificationRingEnabled,
142
+ customKeybindings: state.customKeybindings,
143
+ };
144
+ window.electronAPI.session.save(data);
145
+ };
146
+
147
+ window.addEventListener('beforeunload', saveSession);
148
+ return () => window.removeEventListener('beforeunload', saveSession);
149
+ }, []);
150
+
151
+ // Auto-create initial surface for empty leaf panes
152
+ // ė„¸ė…˜ ëŗĩė›ëœ ę˛Ŋ뚰: surfaces가 ė´ë¯¸ ėžˆėœŧë¯€ëĄœ ė´ effect는 ė‹¤í–‰ë˜ė§€ ė•ŠėŒ
153
+ // 브ëŧėš°ė € surface만 ėžˆëŠ” pane: surfaceTypeė´ 'browser'ė´ëŠ´ PTY ėƒė„ą 늤í‚ĩ
154
+ useEffect(() => {
155
+ if (!activeWorkspace) return;
156
+ const root = activeWorkspace.rootPane;
157
+ if (root.type !== 'leaf') return;
158
+
159
+ // surfaces가 ëš„ė–´ėžˆė„ 때만 냈 PTY ėƒė„ą
160
+ if (root.surfaces.length === 0) {
161
+ let cancelled = false;
162
+ const paneId = root.id;
163
+ window.electronAPI.pty.create().then((result: { id: string }) => {
164
+ if (cancelled) {
165
+ window.electronAPI.pty.dispose(result.id);
166
+ return;
167
+ }
168
+ addSurface(paneId, result.id, 'Terminal', '');
169
+ });
170
+ return () => { cancelled = true; };
171
+ }
172
+
173
+ // surfaces가 ėžˆė§€ë§Œ ëĒ¨ë‘ browser íƒ€ėž…ė¸ ę˛Ŋ뚰 PTY ėƒė„ą 늤í‚ĩ
174
+ const hasTerminalSurface = root.surfaces.some(
175
+ (s) => !s.surfaceType || s.surfaceType === 'terminal'
176
+ );
177
+ if (!hasTerminalSurface) {
178
+ // 브ëŧėš°ė €ë§Œ ėžˆëŠ” pane — PTY ëļˆí•„ėš”, ė•„ëŦ´ę˛ƒë„ í•˜ė§€ ė•ŠėŒ
179
+ return;
180
+ }
181
+ }, [activeWorkspace?.id]);
182
+
183
+ if (!activeWorkspace) return null;
184
+
185
+ return (
186
+ <div className={`flex h-screen w-screen bg-[var(--bg-base)] overflow-hidden ${sidebarPosition === 'right' ? 'flex-row-reverse' : ''}`}>
187
+ {sidebarVisible ? <Sidebar /> : <MiniSidebar />}
188
+ <div className="flex-1 min-w-0 flex flex-col">
189
+ <StatusBar />
190
+ {/* Render ALL workspaces but only show the active one.
191
+ This preserves xterm Terminal instances (and their scroll state)
192
+ across workspace switches — same pattern as surface tab switching. */}
193
+ <div className="flex-1 min-h-0 relative">
194
+ {workspaces.map((ws) => (
195
+ <div
196
+ key={ws.id}
197
+ style={{
198
+ position: 'absolute',
199
+ inset: 0,
200
+ display: ws.id === activeWorkspaceId ? 'flex' : 'none',
201
+ flexDirection: 'column',
202
+ }}
203
+ >
204
+ <PaneContainer pane={ws.rootPane} isWorkspaceVisible={ws.id === activeWorkspaceId} />
205
+ </div>
206
+ ))}
207
+ </div>
208
+ </div>
209
+ <NotificationPanel />
210
+ <MessageFeedPanel />
211
+ <CommandPalette />
212
+ <SettingsPanel />
213
+ <ApprovalDialog />
214
+ {companyViewVisible && (
215
+ <CompanyView onClose={() => setCompanyViewVisible(false)} />
216
+ )}
217
+
218
+ {/* Visual drag indicator — pointer-events always 'none' so it never
219
+ blocks clicks, scrolling, or keyboard. Drop handling is done entirely
220
+ via the window-level listeners registered in the useEffect above. */}
221
+ {isDragging && (
222
+ <div
223
+ style={{
224
+ position: 'fixed',
225
+ inset: 0,
226
+ zIndex: 99999,
227
+ pointerEvents: 'none',
228
+ backgroundColor: 'rgba(137, 180, 250, 0.08)',
229
+ }}
230
+ />
231
+ )}
232
+ </div>
233
+ );
234
+ }
@@ -0,0 +1,129 @@
1
+ import { useMemo } from 'react';
2
+ import { useStore } from '../../stores';
3
+ import { useT } from '../../hooks/useT';
4
+
5
+ export default function NotificationPanel() {
6
+ const t = useT();
7
+ const notifications = useStore((s) => s.notifications);
8
+ const notificationPanelVisible = useStore((s) => s.notificationPanelVisible);
9
+ const toggleNotificationPanel = useStore((s) => s.toggleNotificationPanel);
10
+ const markRead = useStore((s) => s.markRead);
11
+ const markAllReadForWorkspace = useStore((s) => s.markAllReadForWorkspace);
12
+ const clearNotifications = useStore((s) => s.clearNotifications);
13
+ const setActiveWorkspace = useStore((s) => s.setActiveWorkspace);
14
+ const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
15
+
16
+ const sorted = useMemo(
17
+ () => [...notifications].sort((a, b) => b.timestamp - a.timestamp),
18
+ [notifications],
19
+ );
20
+ const unreadCount = useMemo(
21
+ () => notifications.filter((n) => !n.read).length,
22
+ [notifications],
23
+ );
24
+
25
+ if (!notificationPanelVisible) return null;
26
+
27
+ const handleNotifClick = (notif: typeof sorted[0]) => {
28
+ markRead(notif.id);
29
+ if (notif.workspaceId !== activeWorkspaceId) {
30
+ setActiveWorkspace(notif.workspaceId);
31
+ }
32
+ };
33
+
34
+ const formatTime = (ts: number) => {
35
+ const d = new Date(ts);
36
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
37
+ };
38
+
39
+ const typeIcon = (type: string) => {
40
+ switch (type) {
41
+ case 'agent': return '🤖';
42
+ case 'error': return '❌';
43
+ case 'warning': return 'âš ī¸';
44
+ default: return 'â„šī¸';
45
+ }
46
+ };
47
+
48
+ return (
49
+ <div className="fixed right-0 top-0 h-full w-80 bg-[var(--bg-mantle)] border-l border-[var(--bg-surface)] z-50 flex flex-col shadow-2xl notification-panel-enter">
50
+ {/* Header */}
51
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--bg-surface)]">
52
+ <div className="flex items-center gap-2">
53
+ <span className="text-sm font-bold text-[var(--text-main)]">{t('notification.title')}</span>
54
+ {unreadCount > 0 && (
55
+ <span className="bg-[var(--accent-blue)] text-[var(--bg-base)] text-[10px] font-bold px-1.5 py-0.5 rounded-full">
56
+ {unreadCount}
57
+ </span>
58
+ )}
59
+ </div>
60
+ <div className="flex items-center gap-2">
61
+ {notifications.length > 0 && (
62
+ <>
63
+ <button
64
+ className="text-[10px] text-[var(--text-subtle)] hover:text-[var(--accent-blue)] transition-colors"
65
+ onClick={() => markAllReadForWorkspace(activeWorkspaceId)}
66
+ >
67
+ {t('notification.markAllRead')}
68
+ </button>
69
+ <button
70
+ className="text-[10px] text-[var(--text-subtle)] hover:text-[var(--accent-red)] transition-colors"
71
+ onClick={clearNotifications}
72
+ >
73
+ {t('notification.clear')}
74
+ </button>
75
+ </>
76
+ )}
77
+ <button
78
+ className="text-[var(--text-subtle)] hover:text-[var(--text-main)] text-sm transition-colors"
79
+ onClick={toggleNotificationPanel}
80
+ >
81
+ ✕
82
+ </button>
83
+ </div>
84
+ </div>
85
+
86
+ {/* List */}
87
+ <div className="flex-1 overflow-y-auto">
88
+ {sorted.length === 0 ? (
89
+ <div className="flex items-center justify-center h-full text-[var(--text-muted)] text-sm">
90
+ {t('notification.empty')}
91
+ </div>
92
+ ) : (
93
+ sorted.map((notif) => (
94
+ <div
95
+ key={notif.id}
96
+ className={`px-4 py-3 border-b border-[rgba(var(--bg-surface-rgb),0.5)] cursor-pointer hover:bg-[rgba(var(--bg-surface-rgb),0.3)] transition-colors ${
97
+ notif.read ? 'opacity-60' : ''
98
+ }`}
99
+ onClick={() => handleNotifClick(notif)}
100
+ >
101
+ <div className="flex items-start gap-2">
102
+ <span className="text-xs mt-0.5">{typeIcon(notif.type)}</span>
103
+ <div className="flex-1 min-w-0">
104
+ <div className="flex items-center justify-between">
105
+ <span className={`text-xs font-medium truncate ${notif.read ? 'text-[var(--text-subtle)]' : 'text-[var(--text-main)]'}`}>
106
+ {notif.title}
107
+ </span>
108
+ <span className="text-[10px] text-[var(--text-muted)] flex-shrink-0 ml-2">
109
+ {formatTime(notif.timestamp)}
110
+ </span>
111
+ </div>
112
+ <p className="text-[11px] text-[var(--text-sub2)] mt-0.5 truncate">{notif.body}</p>
113
+ </div>
114
+ {!notif.read && (
115
+ <div className="w-1.5 h-1.5 rounded-full bg-[var(--accent-blue)] mt-1.5 flex-shrink-0" />
116
+ )}
117
+ </div>
118
+ </div>
119
+ ))
120
+ )}
121
+ </div>
122
+
123
+ {/* Footer */}
124
+ <div className="px-4 py-2 border-t border-[var(--bg-surface)] text-[10px] text-[var(--text-muted)]">
125
+ {t('notification.toggle')}
126
+ </div>
127
+ </div>
128
+ );
129
+ }