|
11 | 11 | * The WS bridge is a module-level singleton created here via useEffect (once). |
12 | 12 | */ |
13 | 13 |
|
14 | | -import { useEffect, useRef } from 'react' |
| 14 | +import { useCallback, useEffect, useRef, useState } from 'react' |
15 | 15 | import { |
16 | 16 | RuntimeProvider, |
17 | 17 | ToolRegistryProvider, |
@@ -40,6 +40,9 @@ import { CommandPalette } from './components/CommandPalette' |
40 | 40 | import { AuthGate } from './components/AuthGate' |
41 | 41 | import { SetupView } from './components/SetupView' |
42 | 42 | import { SettingsDialog } from './components/SettingsDialog' |
| 43 | +import { TopBar } from './components/TopBar' |
| 44 | +import { RightPanel } from './components/RightPanel' |
| 45 | +import { TerminalPanel } from './components/TerminalPanel' |
43 | 46 |
|
44 | 47 | export default function App() { |
45 | 48 | const dispatch = useAppDispatch() |
@@ -159,22 +162,118 @@ function store_getState() { |
159 | 162 | return store.getState() |
160 | 163 | } |
161 | 164 |
|
| 165 | +type PanelType = 'terminal' | 'files' | 'changes' | 'plan' |
| 166 | + |
162 | 167 | function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'channels' | 'automation-run' }) { |
163 | 168 | const runtime = useChatRuntime() |
164 | 169 | const registry = useRef(createDefaultToolRegistry()).current |
165 | 170 | const paletteOpen = useAppSelector((s) => s.ui.paletteOpen) |
| 171 | + const isRunning = useAppSelector((s) => s.chat.isRunning) |
| 172 | + const wsConnected = useAppSelector((s) => s.session.wsConnected) |
| 173 | + |
| 174 | + // Panel state — mirrors Vue App.vue: a right panel (files/changes/plan) and a |
| 175 | + // bottom panel (terminal) that can be open simultaneously. |
| 176 | + const [rightPanelOpen, setRightPanelOpen] = useState(false) |
| 177 | + const [rightPanelTab, setRightPanelTab] = useState<'files' | 'changes' | 'plan'>('files') |
| 178 | + const [bottomPanel, setBottomPanel] = useState<'none' | 'terminal'>('none') |
| 179 | + const [bottomPanelHeight, setBottomPanelHeight] = useState(260) |
| 180 | + |
| 181 | + const togglePanel = useCallback((panel: PanelType) => { |
| 182 | + if (panel === 'terminal') { |
| 183 | + setBottomPanel((p) => (p === 'terminal' ? 'none' : 'terminal')) |
| 184 | + return |
| 185 | + } |
| 186 | + setRightPanelTab((current) => { |
| 187 | + if (rightPanelOpen && current === panel) { |
| 188 | + setRightPanelOpen(false) |
| 189 | + return current |
| 190 | + } |
| 191 | + setRightPanelOpen(true) |
| 192 | + return panel |
| 193 | + }) |
| 194 | + }, [rightPanelOpen]) |
| 195 | + |
| 196 | + // Panel keyboard shortcuts: ⇧⌘P (plan), ⇧⌘E (files), ⇧⌘G (changes), ⌘` (terminal). |
| 197 | + useEffect(() => { |
| 198 | + function onKey(e: KeyboardEvent) { |
| 199 | + const meta = e.metaKey || e.ctrlKey |
| 200 | + if (meta && e.shiftKey && e.key.toLowerCase() === 'p') { |
| 201 | + e.preventDefault(); togglePanel('plan') |
| 202 | + } else if (meta && e.shiftKey && e.key.toLowerCase() === 'e') { |
| 203 | + e.preventDefault(); togglePanel('files') |
| 204 | + } else if (meta && e.shiftKey && e.key.toLowerCase() === 'g') { |
| 205 | + e.preventDefault(); togglePanel('changes') |
| 206 | + } else if (meta && e.key === '`') { |
| 207 | + e.preventDefault(); togglePanel('terminal') |
| 208 | + } |
| 209 | + } |
| 210 | + window.addEventListener('keydown', onKey) |
| 211 | + return () => window.removeEventListener('keydown', onKey) |
| 212 | + }, [togglePanel]) |
| 213 | + |
| 214 | + // Bottom-panel resize handle. |
| 215 | + const startResize = useCallback((e: React.MouseEvent) => { |
| 216 | + e.preventDefault() |
| 217 | + const startY = e.clientY |
| 218 | + const startH = bottomPanelHeight |
| 219 | + function onMove(ev: MouseEvent) { |
| 220 | + const diff = startY - ev.clientY |
| 221 | + setBottomPanelHeight(Math.max(120, Math.min(600, startH + diff))) |
| 222 | + } |
| 223 | + function onUp() { |
| 224 | + document.removeEventListener('mousemove', onMove) |
| 225 | + document.removeEventListener('mouseup', onUp) |
| 226 | + } |
| 227 | + document.addEventListener('mousemove', onMove) |
| 228 | + document.addEventListener('mouseup', onUp) |
| 229 | + }, [bottomPanelHeight]) |
166 | 230 |
|
167 | 231 | return ( |
168 | 232 | <RuntimeProvider runtime={runtime}> |
169 | 233 | <ToolRegistryProvider registry={registry}> |
170 | | - <div className="flex h-screen overflow-hidden bg-[var(--color-background)] text-[var(--color-foreground)]"> |
| 234 | + <div className="relative flex h-[100dvh] overflow-hidden bg-[var(--color-background)] text-[var(--color-foreground)]"> |
| 235 | + {/* TopBar — floated top-right (only on chat view, like Vue). */} |
| 236 | + {activeView === 'chat' && ( |
| 237 | + <TopBar |
| 238 | + isRunning={isRunning} |
| 239 | + wsConnected={wsConnected} |
| 240 | + activePanel={rightPanelOpen ? rightPanelTab : 'none'} |
| 241 | + terminalOpen={bottomPanel === 'terminal'} |
| 242 | + onTogglePanel={togglePanel} |
| 243 | + /> |
| 244 | + )} |
| 245 | + |
171 | 246 | <Sidebar /> |
172 | | - <main className="flex min-w-0 flex-1 flex-col"> |
| 247 | + |
| 248 | + <main className="relative flex min-w-0 flex-1 flex-col"> |
173 | 249 | {activeView === 'chat' && <ChatView />} |
174 | 250 | {activeView === 'automations' && <AutomationsView />} |
175 | 251 | {activeView === 'channels' && <ChannelsView />} |
176 | 252 | {activeView === 'automation-run' && <ChatView readOnly />} |
| 253 | + |
| 254 | + {/* Bottom panel (terminal) */} |
| 255 | + {bottomPanel === 'terminal' && ( |
| 256 | + <div className="relative shrink-0 border-t border-[var(--color-border)]" style={{ height: bottomPanelHeight }}> |
| 257 | + <div |
| 258 | + className="absolute -top-1 left-0 right-0 z-10 h-2 cursor-row-resize" |
| 259 | + onMouseDown={startResize} |
| 260 | + > |
| 261 | + <div className="absolute left-1/2 top-[3px] h-1 w-8 -translate-x-1/2 rounded-full bg-[var(--color-border)]" /> |
| 262 | + </div> |
| 263 | + <TerminalPanel onClose={() => setBottomPanel('none')} /> |
| 264 | + </div> |
| 265 | + )} |
177 | 266 | </main> |
| 267 | + |
| 268 | + {/* Right panel (files/changes/plan) */} |
| 269 | + {rightPanelOpen && ( |
| 270 | + <RightPanel |
| 271 | + activeTab={rightPanelTab} |
| 272 | + onClose={() => setRightPanelOpen(false)} |
| 273 | + onSwitchTab={setRightPanelTab} |
| 274 | + /> |
| 275 | + )} |
| 276 | + |
178 | 277 | {paletteOpen && <CommandPalette />} |
179 | 278 | <SettingsDialog /> |
180 | 279 | </div> |
|
0 commit comments