| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useRef, useState, useCallback, useMemo } from "react"; |
| 4 | import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; |
| 5 | import Launcher from "./Launcher"; |
| 6 | import TermPane, { type PaneState } from "./TermPane"; |
| 7 | import SessionPicker from "./SessionPicker"; |
| 8 | import type { |
| 9 | TargetsResponse, |
| 10 | TermCreds, |
| 11 | LaunchSpec, |
| 12 | LiveSession, |
| 13 | AgentKind, |
| 14 | } from "@/lib/term"; |
| 15 | import { fetchTermCreds, fetchTargets, killSession } from "@/lib/term"; |
| 16 | |
| 17 | let PANE_SEQ = 0; |
| 18 | const nextId = () => `pane-${++PANE_SEQ}-${Date.now().toString(36)}`; |
| 19 | |
| 20 | function Gutter({ dir }: { dir: "v" | "h" }) { |
| 21 | return ( |
| 22 | <PanelResizeHandle |
| 23 | className={dir === "v" ? "w-px group relative" : "h-px group relative"} |
| 24 | style={{ background: "rgba(22,224,200,0.10)" }} |
| 25 | > |
| 26 | <div |
| 27 | className="absolute inset-0 transition-colors duration-200 group-hover:bg-teal-bio group-data-[resize-handle-state=drag]:bg-teal-bio" |
| 28 | style={ |
| 29 | dir === "v" |
| 30 | ? { left: -3, right: -3, cursor: "col-resize" } |
| 31 | : { top: -3, bottom: -3, cursor: "row-resize" } |
| 32 | } |
| 33 | /> |
| 34 | <div |
| 35 | className="absolute opacity-0 group-hover:opacity-100 group-data-[resize-handle-state=drag]:opacity-100 transition-opacity" |
| 36 | style={ |
| 37 | dir === "v" |
| 38 | ? { top: "50%", left: -1.5, width: 4, height: 28, transform: "translateY(-50%)", background: "#16e0c8", boxShadow: "0 0 10px #16e0c8" } |
| 39 | : { left: "50%", top: -1.5, height: 4, width: 28, transform: "translateX(-50%)", background: "#16e0c8", boxShadow: "0 0 10px #16e0c8" } |
| 40 | } |
| 41 | /> |
| 42 | </PanelResizeHandle> |
| 43 | ); |
| 44 | } |
| 45 | |
| 46 | interface DesktopSplitProps { |
| 47 | panes: PaneState[]; |
| 48 | creds: TermCreds | null; |
| 49 | focusedId: string | null; |
| 50 | setFocusedId: (id: string) => void; |
| 51 | closePane: (id: string) => void; |
| 52 | onAssign: (paneId: string) => void; |
| 53 | registerFit: (paneId: string, fitFn: (() => void) | null) => void; |
| 54 | onLayout: () => void; |
| 55 | } |
| 56 | |
| 57 | function DesktopSplit({ |
| 58 | panes, |
| 59 | creds, |
| 60 | focusedId, |
| 61 | setFocusedId, |
| 62 | closePane, |
| 63 | onAssign, |
| 64 | registerFit, |
| 65 | onLayout, |
| 66 | }: DesktopSplitProps) { |
| 67 | const n = panes.length; |
| 68 | |
| 69 | const renderPane = (p: PaneState, idx: number) => ( |
| 70 | <TermPane |
| 71 | key={p.id} |
| 72 | pane={p} |
| 73 | index={idx} |
| 74 | creds={creds} |
| 75 | visible |
| 76 | focused={focusedId === p.id} |
| 77 | onFocus={() => setFocusedId(p.id)} |
| 78 | onClose={() => closePane(p.id)} |
| 79 | onAssign={onAssign} |
| 80 | registerFit={registerFit} |
| 81 | /> |
| 82 | ); |
| 83 | |
| 84 | if (n === 0) return <EmptyState />; |
| 85 | if (n === 1) return <div className="h-full w-full p-1">{renderPane(panes[0], 0)}</div>; |
| 86 | |
| 87 | if (n === 2) { |
| 88 | return ( |
| 89 | <PanelGroup direction="horizontal" className="p-1" autoSaveId="dw-split-2" onLayout={onLayout}> |
| 90 | <Panel defaultSize={50} minSize={18} className="p-px">{renderPane(panes[0], 0)}</Panel> |
| 91 | <Gutter dir="v" /> |
| 92 | <Panel defaultSize={50} minSize={18} className="p-px">{renderPane(panes[1], 1)}</Panel> |
| 93 | </PanelGroup> |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | if (n === 3) { |
| 98 | return ( |
| 99 | <PanelGroup direction="horizontal" className="p-1" autoSaveId="dw-split-3" onLayout={onLayout}> |
| 100 | <Panel defaultSize={50} minSize={18} className="p-px">{renderPane(panes[0], 0)}</Panel> |
| 101 | <Gutter dir="v" /> |
| 102 | <Panel defaultSize={50} minSize={18} className="p-px"> |
| 103 | <PanelGroup direction="vertical" autoSaveId="dw-split-3r" onLayout={onLayout}> |
| 104 | <Panel defaultSize={50} minSize={14} className="p-px pt-0 pb-px">{renderPane(panes[1], 1)}</Panel> |
| 105 | <Gutter dir="h" /> |
| 106 | <Panel defaultSize={50} minSize={14} className="p-px pb-0 pt-px">{renderPane(panes[2], 2)}</Panel> |
| 107 | </PanelGroup> |
| 108 | </Panel> |
| 109 | </PanelGroup> |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | return ( |
| 114 | <PanelGroup direction="horizontal" className="p-1" autoSaveId="dw-split-4" onLayout={onLayout}> |
| 115 | <Panel defaultSize={50} minSize={18} className="p-px"> |
| 116 | <PanelGroup direction="vertical" autoSaveId="dw-split-4l" onLayout={onLayout}> |
| 117 | <Panel defaultSize={50} minSize={14} className="p-px pt-0 pb-px">{renderPane(panes[0], 0)}</Panel> |
| 118 | <Gutter dir="h" /> |
| 119 | <Panel defaultSize={50} minSize={14} className="p-px pb-0 pt-px">{renderPane(panes[2], 2)}</Panel> |
| 120 | </PanelGroup> |
| 121 | </Panel> |
| 122 | <Gutter dir="v" /> |
| 123 | <Panel defaultSize={50} minSize={18} className="p-px"> |
| 124 | <PanelGroup direction="vertical" autoSaveId="dw-split-4r" onLayout={onLayout}> |
| 125 | <Panel defaultSize={50} minSize={14} className="p-px pt-0 pb-px">{renderPane(panes[1], 1)}</Panel> |
| 126 | <Gutter dir="h" /> |
| 127 | <Panel defaultSize={50} minSize={14} className="p-px pb-0 pt-px">{renderPane(panes[3], 3)}</Panel> |
| 128 | </PanelGroup> |
| 129 | </Panel> |
| 130 | </PanelGroup> |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | function EmptyState() { |
| 135 | return ( |
| 136 | <div className="flex h-full w-full items-center justify-center p-8"> |
| 137 | <div className="text-center max-w-sm"> |
| 138 | <div |
| 139 | className="mx-auto mb-6" |
| 140 | style={{ width: 46, height: 46, border: "1px solid rgba(22,224,200,0.3)", display: "grid", placeItems: "center" }} |
| 141 | > |
| 142 | <span style={{ color: "#16e0c8", fontSize: "1.4rem" }}>▸</span> |
| 143 | </div> |
| 144 | <h3 className="font-display font-bold uppercase" style={{ fontSize: "1.1rem", color: "#e8eaed", letterSpacing: "0.02em" }}> |
| 145 | No agents running |
| 146 | </h3> |
| 147 | <p className="font-mono" style={{ fontSize: "0.66rem", color: "#4a525e", marginTop: 10, lineHeight: 1.6, letterSpacing: "0.03em" }}> |
| 148 | Pick a source + agent in the launcher and hit Launch. Add up to four panes and drag the |
| 149 | gutters to resize. |
| 150 | </p> |
| 151 | </div> |
| 152 | </div> |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | interface MobileTabsProps { |
| 157 | panes: PaneState[]; |
| 158 | creds: TermCreds | null; |
| 159 | active: number; |
| 160 | setActive: (i: number) => void; |
| 161 | closePane: (id: string) => void; |
| 162 | onAssign: (paneId: string) => void; |
| 163 | registerFit: (paneId: string, fitFn: (() => void) | null) => void; |
| 164 | } |
| 165 | |
| 166 | function MobileTabs({ |
| 167 | panes, |
| 168 | creds, |
| 169 | active, |
| 170 | setActive, |
| 171 | closePane, |
| 172 | onAssign, |
| 173 | registerFit, |
| 174 | }: MobileTabsProps) { |
| 175 | const touchStart = useRef<number | null>(null); |
| 176 | |
| 177 | const onTouchStart = (e: React.TouchEvent) => { |
| 178 | touchStart.current = e.touches[0].clientX; |
| 179 | }; |
| 180 | const onTouchEnd = (e: React.TouchEvent) => { |
| 181 | if (touchStart.current === null) return; |
| 182 | const dx = e.changedTouches[0].clientX - touchStart.current; |
| 183 | if (Math.abs(dx) > 60) { |
| 184 | if (dx < 0 && active < panes.length - 1) setActive(active + 1); |
| 185 | if (dx > 0 && active > 0) setActive(active - 1); |
| 186 | } |
| 187 | touchStart.current = null; |
| 188 | }; |
| 189 | |
| 190 | if (panes.length === 0) |
| 191 | return ( |
| 192 | <div className="h-full"> |
| 193 | <EmptyState /> |
| 194 | </div> |
| 195 | ); |
| 196 | |
| 197 | const clamped = Math.min(active, panes.length - 1); |
| 198 | |
| 199 | return ( |
| 200 | <div className="flex h-full flex-col"> |
| 201 | {} |
| 202 | <div |
| 203 | className="flex shrink-0 overflow-x-auto" |
| 204 | style={{ background: "#0a0c0e", borderBottom: "1px solid rgba(22,224,200,0.10)" }} |
| 205 | > |
| 206 | {panes.map((p, i) => { |
| 207 | const on = i === clamped; |
| 208 | return ( |
| 209 | <button |
| 210 | key={p.id} |
| 211 | onClick={() => setActive(i)} |
| 212 | className="font-display font-bold uppercase shrink-0" |
| 213 | style={{ |
| 214 | padding: "11px 16px", |
| 215 | fontSize: "0.68rem", |
| 216 | letterSpacing: "0.05em", |
| 217 | color: on ? "#16e0c8" : "#4a525e", |
| 218 | borderBottom: on ? "2px solid #16e0c8" : "2px solid transparent", |
| 219 | minHeight: 44, |
| 220 | }} |
| 221 | > |
| 222 | {String(i + 1).padStart(2, "0")} · {p.spec.agent.toUpperCase()} |
| 223 | </button> |
| 224 | ); |
| 225 | })} |
| 226 | </div> |
| 227 | {} |
| 228 | <div className="relative min-h-0 flex-1" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}> |
| 229 | {panes.map((p, i) => ( |
| 230 | <div |
| 231 | key={p.id} |
| 232 | className="absolute inset-0" |
| 233 | style={{ visibility: i === clamped ? "visible" : "hidden", padding: 6 }} |
| 234 | > |
| 235 | <TermPane |
| 236 | pane={p} |
| 237 | index={i} |
| 238 | creds={creds} |
| 239 | visible={i === clamped} |
| 240 | focused={i === clamped} |
| 241 | onFocus={() => setActive(i)} |
| 242 | onClose={() => closePane(p.id)} |
| 243 | onAssign={onAssign} |
| 244 | registerFit={registerFit} |
| 245 | /> |
| 246 | </div> |
| 247 | ))} |
| 248 | </div> |
| 249 | </div> |
| 250 | ); |
| 251 | } |
| 252 | |
| 253 | export default function WorkspaceClient() { |
| 254 | const [creds, setCreds] = useState<TermCreds | null>(null); |
| 255 | const [targets, setTargets] = useState<TargetsResponse | null>(null); |
| 256 | const [credErr, setCredErr] = useState<string | null>(null); |
| 257 | |
| 258 | const [panes, setPanes] = useState<PaneState[]>([]); |
| 259 | const [focusedId, setFocusedId] = useState<string | null>(null); |
| 260 | const [collapsed, setCollapsed] = useState(false); |
| 261 | const [isMobile, setIsMobile] = useState(false); |
| 262 | const [drawerOpen, setDrawerOpen] = useState(false); |
| 263 | const [mobileActive, setMobileActive] = useState(0); |
| 264 | const [pickerForPane, setPickerForPane] = useState<string | null>(null); |
| 265 | const [viewportH, setViewportH] = useState<number | null>(null); |
| 266 | |
| 267 | const fitFnsRef = useRef<Map<string, () => void>>(new Map()); |
| 268 | const refitScheduledRef = useRef(false); |
| 269 | |
| 270 | const registerFit = useCallback((paneId: string, fitFn: (() => void) | null) => { |
| 271 | if (fitFn) fitFnsRef.current.set(paneId, fitFn); |
| 272 | else fitFnsRef.current.delete(paneId); |
| 273 | }, []); |
| 274 | |
| 275 | const refitAll = useCallback(() => { |
| 276 | if (refitScheduledRef.current) return; |
| 277 | refitScheduledRef.current = true; |
| 278 | requestAnimationFrame(() => { |
| 279 | refitScheduledRef.current = false; |
| 280 | fitFnsRef.current.forEach((fn) => { |
| 281 | try { |
| 282 | fn(); |
| 283 | } catch { |
| 284 | } |
| 285 | }); |
| 286 | }); |
| 287 | }, []); |
| 288 | |
| 289 | useEffect(() => { |
| 290 | (async () => { |
| 291 | const c = await fetchTermCreds(); |
| 292 | if (!c.ok) { |
| 293 | setCredErr(c.error || "term backend unavailable"); |
| 294 | return; |
| 295 | } |
| 296 | setCreds(c); |
| 297 | try { |
| 298 | const t = await fetchTargets(c); |
| 299 | setTargets(t); |
| 300 | } catch { |
| 301 | setCredErr("could not reach darwin-term /targets"); |
| 302 | } |
| 303 | |
| 304 | if (typeof window !== "undefined") { |
| 305 | const p = new URLSearchParams(window.location.search); |
| 306 | const session = p.get("session"); |
| 307 | if (session) { |
| 308 | const agentParam = (p.get("agent") as LaunchSpec["agent"]) || "claude"; |
| 309 | launchRef.current?.({ |
| 310 | target: p.get("target") || "ct215", |
| 311 | agent: agentParam, |
| 312 | mode: "persistent", |
| 313 | cwd: "", |
| 314 | session, |
| 315 | }); |
| 316 | window.history.replaceState({}, "", "/workspace"); |
| 317 | } |
| 318 | } |
| 319 | })(); |
| 320 | }, []); |
| 321 | |
| 322 | useEffect(() => { |
| 323 | const mq = window.matchMedia("(max-width: 820px)"); |
| 324 | const apply = () => setIsMobile(mq.matches); |
| 325 | apply(); |
| 326 | mq.addEventListener("change", apply); |
| 327 | return () => mq.removeEventListener("change", apply); |
| 328 | }, []); |
| 329 | |
| 330 | useEffect(() => { |
| 331 | if (!isMobile) { |
| 332 | setViewportH(null); |
| 333 | return; |
| 334 | } |
| 335 | const vv = typeof window !== "undefined" ? window.visualViewport : null; |
| 336 | if (!vv) return; |
| 337 | const handler = () => { |
| 338 | setViewportH(vv.height); |
| 339 | refitAll(); |
| 340 | }; |
| 341 | handler(); |
| 342 | vv.addEventListener("resize", handler); |
| 343 | vv.addEventListener("scroll", handler); |
| 344 | return () => { |
| 345 | vv.removeEventListener("resize", handler); |
| 346 | vv.removeEventListener("scroll", handler); |
| 347 | }; |
| 348 | }, [isMobile, refitAll]); |
| 349 | |
| 350 | useEffect(() => { |
| 351 | const onResize = () => refitAll(); |
| 352 | window.addEventListener("resize", onResize); |
| 353 | return () => window.removeEventListener("resize", onResize); |
| 354 | }, [refitAll]); |
| 355 | |
| 356 | useEffect(() => { |
| 357 | refitAll(); |
| 358 | }, [panes.length, collapsed, refitAll]); |
| 359 | |
| 360 | const launch = useCallback((spec: LaunchSpec) => { |
| 361 | setPanes((prev) => { |
| 362 | if (prev.length >= 4) return prev; |
| 363 | const pane: PaneState = { id: nextId(), spec }; |
| 364 | setFocusedId(pane.id); |
| 365 | setMobileActive(prev.length); |
| 366 | return [...prev, pane]; |
| 367 | }); |
| 368 | setDrawerOpen(false); |
| 369 | }, []); |
| 370 | |
| 371 | const launchRef = useRef(launch); |
| 372 | useEffect(() => { launchRef.current = launch; }, [launch]); |
| 373 | |
| 374 | const closePane = useCallback((id: string) => { |
| 375 | setPanes((prev) => prev.filter((p) => p.id !== id)); |
| 376 | }, []); |
| 377 | |
| 378 | const assignPane = useCallback((paneId: string) => { |
| 379 | setPickerForPane(paneId); |
| 380 | }, []); |
| 381 | |
| 382 | const setPaneSpec = useCallback((paneId: string, spec: LaunchSpec) => { |
| 383 | setPanes((prev) => prev.map((p) => (p.id === paneId ? { ...p, spec } : p))); |
| 384 | setPickerForPane(null); |
| 385 | }, []); |
| 386 | |
| 387 | const openSessionIds = useMemo( |
| 388 | () => |
| 389 | new Set( |
| 390 | panes.map((p) => p.spec.session).filter((x): x is string => Boolean(x)) |
| 391 | ), |
| 392 | [panes] |
| 393 | ); |
| 394 | |
| 395 | const onPickerPick = useCallback( |
| 396 | (s: LiveSession) => { |
| 397 | if (!pickerForPane) return; |
| 398 | setPaneSpec(pickerForPane, { |
| 399 | target: s.target, |
| 400 | agent: (s.agent as AgentKind) || "shell", |
| 401 | mode: "persistent", |
| 402 | cwd: "", |
| 403 | session: s.session, |
| 404 | }); |
| 405 | }, |
| 406 | [pickerForPane, setPaneSpec] |
| 407 | ); |
| 408 | |
| 409 | const onPickerClose = useCallback(() => setPickerForPane(null), []); |
| 410 | const onPickerLaunchNew = useCallback(() => setPickerForPane(null), []); |
| 411 | |
| 412 | const onKillSession = useCallback( |
| 413 | async (s: LiveSession) => { |
| 414 | if (creds) await killSession(creds, s.target, s.session); |
| 415 | }, |
| 416 | [creds] |
| 417 | ); |
| 418 | |
| 419 | const setSplit = (target: number) => { |
| 420 | let firstNewId: string | null = null; |
| 421 | setPanes((prev) => { |
| 422 | if (target === prev.length) return prev; |
| 423 | if (target < prev.length) return prev.slice(0, target); |
| 424 | const base = prev[prev.length - 1]?.spec ?? |
| 425 | ({ target: targets?.targets[0]?.key ?? "ct215", agent: "shell", mode: "persistent", cwd: "" } as LaunchSpec); |
| 426 | const additions: PaneState[] = []; |
| 427 | for (let i = prev.length; i < target; i++) { |
| 428 | additions.push({ |
| 429 | id: nextId(), |
| 430 | spec: { target: base.target, agent: base.agent, mode: base.mode, cwd: base.cwd }, |
| 431 | }); |
| 432 | } |
| 433 | if (additions[0]) firstNewId = additions[0].id; |
| 434 | return [...prev, ...additions]; |
| 435 | }); |
| 436 | if (firstNewId) setPickerForPane(firstNewId); |
| 437 | }; |
| 438 | |
| 439 | const sidebar = ( |
| 440 | <Launcher |
| 441 | creds={creds} |
| 442 | targets={targets} |
| 443 | onLaunch={launch} |
| 444 | onKillSession={onKillSession} |
| 445 | collapsed={false} |
| 446 | /> |
| 447 | ); |
| 448 | |
| 449 | return ( |
| 450 | <div |
| 451 | data-darwin-workspace |
| 452 | className={viewportH != null ? "fixed left-0 top-0 right-0 flex flex-col" : "fixed inset-0 flex flex-col"} |
| 453 | style={viewportH != null ? { background: "#050607", height: viewportH } : { background: "#050607" }} |
| 454 | > |
| 455 | {} |
| 456 | <header |
| 457 | className="flex shrink-0 items-center justify-between px-3 sm:px-5" |
| 458 | style={{ height: 52, background: "rgba(8,10,12,0.96)", borderBottom: "1px solid rgba(22,224,200,0.12)" }} |
| 459 | > |
| 460 | <div className="flex items-center gap-3 sm:gap-5 min-w-0"> |
| 461 | <a href="/" className="font-mono uppercase shrink-0" style={{ fontSize: "0.62rem", letterSpacing: "0.2em", color: "#0d9e8e" }}> |
| 462 | ← DRW |
| 463 | </a> |
| 464 | <span className="hidden sm:block" style={{ width: 1, height: 18, background: "rgba(22,224,200,0.18)" }} /> |
| 465 | <h1 className="font-display font-bold uppercase truncate" style={{ fontSize: "0.92rem", letterSpacing: "0.04em", color: "#e8eaed" }}> |
| 466 | Workspace |
| 467 | </h1> |
| 468 | <span className="hidden md:inline font-mono" style={{ fontSize: "0.56rem", letterSpacing: "0.1em", color: "#4a525e" }}> |
| 469 | TEAM-UP TERMINAL |
| 470 | </span> |
| 471 | </div> |
| 472 | |
| 473 | <div className="flex items-center gap-2 sm:gap-3"> |
| 474 | {} |
| 475 | <div className="hidden md:flex items-center gap-1" style={{ border: "1px solid rgba(22,224,200,0.14)", padding: 2 }}> |
| 476 | {[1, 2, 3, 4].map((k) => { |
| 477 | const on = panes.length === k; |
| 478 | return ( |
| 479 | <button |
| 480 | key={k} |
| 481 | onClick={() => setSplit(k)} |
| 482 | title={`${k}-up split`} |
| 483 | className="font-mono" |
| 484 | style={{ |
| 485 | width: 30, height: 26, fontSize: "0.66rem", |
| 486 | color: on ? "#050607" : "#8b929e", |
| 487 | background: on ? "#16e0c8" : "transparent", |
| 488 | transition: "all 0.2s", |
| 489 | }} |
| 490 | > |
| 491 | {k} |
| 492 | </button> |
| 493 | ); |
| 494 | })} |
| 495 | </div> |
| 496 | |
| 497 | {} |
| 498 | {!isMobile && ( |
| 499 | <button |
| 500 | onClick={() => setCollapsed((c) => !c)} |
| 501 | className="font-mono uppercase flex items-center gap-1.5" |
| 502 | style={{ fontSize: "0.58rem", letterSpacing: "0.08em", color: "#8b929e", padding: "7px 11px", border: "1px solid rgba(22,224,200,0.14)" }} |
| 503 | title={collapsed ? "Show launcher" : "Hide launcher (full-bleed pane)"} |
| 504 | > |
| 505 | <svg width="12" height="12" viewBox="0 0 12 12" fill="none"> |
| 506 | <rect x="0.5" y="0.5" width="11" height="11" stroke="currentColor" /> |
| 507 | <line x1={collapsed ? "4" : "4"} y1="0.5" x2="4" y2="11.5" stroke="currentColor" /> |
| 508 | </svg> |
| 509 | {collapsed ? "PANEL" : "FULL"} |
| 510 | </button> |
| 511 | )} |
| 512 | |
| 513 | {} |
| 514 | {isMobile && ( |
| 515 | <button |
| 516 | onClick={() => setDrawerOpen(true)} |
| 517 | className="font-display font-bold uppercase" |
| 518 | style={{ fontSize: "0.66rem", letterSpacing: "0.06em", color: "#050607", background: "#16e0c8", padding: "8px 14px", minHeight: 40 }} |
| 519 | > |
| 520 | + Launch |
| 521 | </button> |
| 522 | )} |
| 523 | </div> |
| 524 | </header> |
| 525 | |
| 526 | {} |
| 527 | <div className="relative flex min-h-0 flex-1"> |
| 528 | {} |
| 529 | {!isMobile && !collapsed && ( |
| 530 | <div className="shrink-0" style={{ width: 248 }}> |
| 531 | {sidebar} |
| 532 | </div> |
| 533 | )} |
| 534 | |
| 535 | {} |
| 536 | <div className="min-w-0 flex-1"> |
| 537 | {isMobile ? ( |
| 538 | <MobileTabs |
| 539 | panes={panes} |
| 540 | creds={creds} |
| 541 | active={mobileActive} |
| 542 | setActive={setMobileActive} |
| 543 | closePane={closePane} |
| 544 | onAssign={assignPane} |
| 545 | registerFit={registerFit} |
| 546 | /> |
| 547 | ) : ( |
| 548 | <DesktopSplit |
| 549 | panes={panes} |
| 550 | creds={creds} |
| 551 | focusedId={focusedId} |
| 552 | setFocusedId={setFocusedId} |
| 553 | closePane={closePane} |
| 554 | onAssign={assignPane} |
| 555 | registerFit={registerFit} |
| 556 | onLayout={refitAll} |
| 557 | /> |
| 558 | )} |
| 559 | </div> |
| 560 | |
| 561 | {credErr && ( |
| 562 | <div className="absolute left-1/2 top-4 -translate-x-1/2 px-4 py-2 font-mono" style={{ fontSize: "0.62rem", color: "#ff6b6b", background: "rgba(10,12,14,0.95)", border: "1px solid rgba(255,107,107,0.3)", letterSpacing: "0.04em" }}> |
| 563 | ⚠ {credErr} |
| 564 | </div> |
| 565 | )} |
| 566 | </div> |
| 567 | |
| 568 | {} |
| 569 | {isMobile && drawerOpen && ( |
| 570 | <div className="fixed inset-0 z-50" onClick={() => setDrawerOpen(false)}> |
| 571 | <div className="absolute inset-0" style={{ background: "rgba(5,6,7,0.6)", backdropFilter: "blur(2px)" }} /> |
| 572 | <div |
| 573 | onClick={(e) => e.stopPropagation()} |
| 574 | className="absolute bottom-0 left-0 right-0" |
| 575 | style={{ |
| 576 | maxHeight: "88dvh", |
| 577 | overflowY: "auto", |
| 578 | background: "#080a0c", |
| 579 | borderTop: "1px solid rgba(22,224,200,0.2)", |
| 580 | boxShadow: "0 -20px 60px rgba(0,0,0,0.6)", |
| 581 | animation: "sheet-up 0.4s cubic-bezier(0.16,1,0.3,1)", |
| 582 | paddingBottom: "env(safe-area-inset-bottom, 0px)", |
| 583 | }} |
| 584 | > |
| 585 | <div className="flex justify-center pt-3 pb-1"> |
| 586 | <span style={{ width: 36, height: 4, borderRadius: 2, background: "rgba(22,224,200,0.3)" }} /> |
| 587 | </div> |
| 588 | {sidebar} |
| 589 | </div> |
| 590 | </div> |
| 591 | )} |
| 592 | |
| 593 | {} |
| 594 | {pickerForPane && ( |
| 595 | <SessionPicker |
| 596 | openSessionIds={openSessionIds} |
| 597 | onPick={onPickerPick} |
| 598 | onClose={onPickerClose} |
| 599 | onLaunchNew={onPickerLaunchNew} |
| 600 | /> |
| 601 | )} |
| 602 | </div> |
| 603 | ); |
| 604 | } |