● Zion Boggan
repos/Darwin/face/src/components/workspace/WorkspaceClient.tsx
zionboggan.com ↗
604 lines · tsx
History for this file →
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
}