● Zion Boggan
repos/Darwin/face/src/components/workspace/Launcher.tsx
zionboggan.com ↗
392 lines · tsx
History for this file →
1
"use client";
2
 
3
import { useEffect, useMemo, useState } from "react";
4
 
5
const LIVE_WINDOW_S = 20 * 60;
6
 
7
function isLive(s: LiveSession): boolean {
8
  if (s.attached) return true;
9
  const t = parseInt(s.created, 10);
10
  if (!t) return false;
11
  return Date.now() / 1000 - t < LIVE_WINDOW_S;
12
}
13
import type {
14
  TargetsResponse,
15
  TermCreds,
16
  LiveSession,
17
  AgentKind,
18
  TermMode,
19
  LaunchSpec,
20
} from "@/lib/term";
21
import { fetchSessions, AGENT_LABEL } from "@/lib/term";
22
 
23
interface LauncherProps {
24
  creds: TermCreds | null;
25
  targets: TargetsResponse | null;
26
  onLaunch: (spec: LaunchSpec) => void;
27
  onKillSession: (s: LiveSession) => void;
28
  collapsed: boolean;
29
}
30
 
31
function FieldLabel({ children }: { children: React.ReactNode }) {
32
  return (
33
    <span
34
      className="font-mono uppercase block"
35
      style={{ fontSize: "0.58rem", letterSpacing: "0.14em", color: "#4a525e", marginBottom: 7 }}
36
    >
37
      {children}
38
    </span>
39
  );
40
}
41
 
42
function ageOf(created: string): string {
43
  const t = parseInt(created, 10);
44
  if (!t) return "";
45
  const s = Math.max(0, Math.floor(Date.now() / 1000 - t));
46
  if (s < 60) return `${s}s`;
47
  if (s < 3600) return `${Math.floor(s / 60)}m`;
48
  if (s < 86400) return `${Math.floor(s / 3600)}h`;
49
  return `${Math.floor(s / 86400)}d`;
50
}
51
 
52
const selectStyle: React.CSSProperties = {
53
  width: "100%",
54
  height: 40,
55
  background: "#0a0c0e",
56
  border: "1px solid rgba(22,224,200,0.14)",
57
  color: "#e8eaed",
58
  fontFamily: "'JetBrains Mono', monospace",
59
  fontSize: "0.74rem",
60
  letterSpacing: "0.02em",
61
  padding: "0 12px",
62
  outline: "none",
63
  appearance: "none",
64
  borderRadius: 0,
65
  cursor: "pointer",
66
};
67
 
68
function SessionRow({
69
  s,
70
  live,
71
  onAttach,
72
  onKill,
73
}: {
74
  s: LiveSession;
75
  live: boolean;
76
  onAttach: (s: LiveSession) => void;
77
  onKill: (s: LiveSession) => void;
78
}) {
79
  const accent = live ? "#16e0c8" : "#4a525e";
80
  return (
81
    <div
82
      className="group"
83
      style={{
84
        background: "#0a0c0e",
85
        border: `1px solid ${live ? "rgba(22,224,200,0.18)" : "rgba(22,224,200,0.06)"}`,
86
        opacity: live ? 1 : 0.62,
87
      }}
88
    >
89
      <button
90
        onClick={() => onAttach(s)}
91
        className="flex w-full items-center justify-between gap-2 px-3.5 text-left"
92
        style={{ minHeight: live ? 52 : 42, transition: "background 0.2s" }}
93
        onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(22,224,200,0.06)")}
94
        onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
95
      >
96
        <div className="min-w-0">
97
          <div className="flex items-center gap-2">
98
            {live && (
99
              <span className="rounded-full shrink-0" style={{ width: 5, height: 5, background: "#16e0c8", boxShadow: "0 0 6px #16e0c8" }} />
100
            )}
101
            <span className="font-display font-bold uppercase" style={{ fontSize: live ? "0.74rem" : "0.66rem", color: accent, letterSpacing: "0.03em" }}>
102
              {AGENT_LABEL[(s.agent as AgentKind)] ?? s.agent}
103
            </span>
104
            <span className="font-mono" style={{ fontSize: "0.56rem", color: "#8b929e", letterSpacing: "0.04em" }}>
105
              {s.target}
106
            </span>
107
            {s.attached && (
108
              <span className="font-mono" style={{ fontSize: "0.5rem", color: "#0d9e8e", letterSpacing: "0.06em", border: "1px solid rgba(22,224,200,0.24)", padding: "1px 4px" }}>
109
                OPEN
110
              </span>
111
            )}
112
          </div>
113
          <span className="font-mono block truncate" style={{ fontSize: "0.54rem", color: "#4a525e", maxWidth: 180, marginTop: 3 }} title={s.session}>
114
            {s.session} · {ageOf(s.created)} ago
115
          </span>
116
        </div>
117
        <span className="font-display font-bold uppercase shrink-0 flex items-center gap-1" style={{ fontSize: "0.6rem", color: accent, letterSpacing: "0.06em" }}>
118
          Attach ▸
119
        </span>
120
      </button>
121
      <button
122
        onClick={() => onKill(s)}
123
        className="font-mono uppercase w-full opacity-40 hover:opacity-100"
124
        style={{ fontSize: "0.5rem", letterSpacing: "0.08em", color: "#ff6b6b", padding: "5px", borderTop: "1px solid rgba(255,107,107,0.12)", transition: "opacity 0.2s", minHeight: 30 }}
125
      >
126
        Kill session
127
      </button>
128
    </div>
129
  );
130
}
131
 
132
export default function Launcher({
133
  creds,
134
  targets,
135
  onLaunch,
136
  onKillSession,
137
  collapsed,
138
}: LauncherProps) {
139
  const [targetKey, setTargetKey] = useState<string>("");
140
  const [agent, setAgent] = useState<AgentKind>("shell");
141
  const [mode, setMode] = useState<TermMode>("persistent");
142
  const [cwd, setCwd] = useState<string>("");
143
  const [sessions, setSessions] = useState<LiveSession[]>([]);
144
  const [showIdle, setShowIdle] = useState(false);
145
 
146
  const { liveSessions, idleSessions } = useMemo(() => {
147
    const live: LiveSession[] = [];
148
    const idle: LiveSession[] = [];
149
    for (const s of sessions) (isLive(s) ? live : idle).push(s);
150
    const byNew = (a: LiveSession, b: LiveSession) =>
151
      parseInt(b.created, 10) - parseInt(a.created, 10);
152
    return { liveSessions: live.sort(byNew), idleSessions: idle.sort(byNew) };
153
  }, [sessions]);
154
 
155
  const target = useMemo(
156
    () => targets?.targets.find((t) => t.key === targetKey) ?? null,
157
    [targets, targetKey]
158
  );
159
 
160
  useEffect(() => {
161
    if (targets && !targetKey && targets.targets.length) {
162
      const first = targets.targets[0];
163
      setTargetKey(first.key);
164
      setAgent(first.default_agent);
165
      setCwd(first.default_cwd ?? "");
166
    }
167
  }, [targets, targetKey]);
168
 
169
  useEffect(() => {
170
    if (target) {
171
      setAgent((a) => (target.agents.includes(a) ? a : target.default_agent));
172
      setCwd(target.default_cwd ?? "");
173
    }
174
  }, [target]);
175
 
176
  useEffect(() => {
177
    if (!creds) return;
178
    let alive = true;
179
    const tick = async () => {
180
      try {
181
        const s = await fetchSessions(creds);
182
        if (alive) setSessions(s);
183
      } catch {
184
      }
185
    };
186
    tick();
187
    const id = setInterval(tick, 5000);
188
    return () => {
189
      alive = false;
190
      clearInterval(id);
191
    };
192
  }, [creds]);
193
 
194
  if (collapsed) return null;
195
 
196
  const launch = () => {
197
    if (!targetKey) return;
198
    onLaunch({ target: targetKey, agent, mode, cwd });
199
  };
200
 
201
  const attach = (s: LiveSession) => {
202
    onLaunch({
203
      target: s.target,
204
      agent: (s.agent as AgentKind) || "shell",
205
      mode: "persistent",
206
      cwd: "",
207
      session: s.session,
208
    });
209
  };
210
 
211
  return (
212
    <div
213
      className="flex h-full flex-col overflow-y-auto"
214
      style={{ background: "rgba(8,10,12,0.96)", borderRight: "1px solid rgba(22,224,200,0.10)" }}
215
    >
216
      {}
217
      <div className="px-4 pt-4 pb-3" style={{ borderBottom: "1px solid rgba(22,224,200,0.08)" }}>
218
        <p
219
          className="font-mono uppercase"
220
          style={{ fontSize: "0.55rem", letterSpacing: "0.2em", color: "#0d9e8e", marginBottom: 6 }}
221
        >
222
          Team-Up
223
        </p>
224
        <h2 className="font-display font-bold uppercase" style={{ fontSize: "1.25rem", lineHeight: 0.92, color: "#e8eaed", letterSpacing: "-0.02em" }}>
225
          Workspace
226
        </h2>
227
      </div>
228
 
229
      {
230
}
231
      <div className="px-4 pt-4 pb-3" style={{ borderBottom: "1px solid rgba(22,224,200,0.08)" }}>
232
        <div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
233
          <span className="font-display font-bold uppercase" style={{ fontSize: "0.92rem", color: "#16e0c8", letterSpacing: "0.02em" }}>
234
            Sessions
235
          </span>
236
          <span className="flex items-center gap-1.5">
237
            <span className="rounded-full" style={{ width: 6, height: 6, background: liveSessions.length ? "#16e0c8" : "#4a525e", boxShadow: liveSessions.length ? "0 0 8px #16e0c8" : "none", animation: liveSessions.length ? "glow-breathe 2.4s ease-in-out infinite" : "none" }} />
238
            <span className="font-mono" style={{ fontSize: "0.56rem", color: "#0d9e8e", letterSpacing: "0.08em" }}>
239
              {liveSessions.length} LIVE · {idleSessions.length} IDLE
240
            </span>
241
          </span>
242
        </div>
243
 
244
        {sessions.length === 0 && (
245
          <p className="font-mono" style={{ fontSize: "0.62rem", color: "#4a525e", letterSpacing: "0.03em", lineHeight: 1.6 }}>
246
            No running sessions. Launch one below - persistent sessions survive
247
            reload and reattach from any device (phone or PC).
248
          </p>
249
        )}
250
 
251
        {}
252
        {liveSessions.length > 0 && (
253
          <div className="space-y-2.5 mb-3">
254
            {liveSessions.map((s) => (
255
              <SessionRow key={s.session} s={s} live onAttach={attach} onKill={onKillSession} />
256
            ))}
257
          </div>
258
        )}
259
 
260
        {}
261
        {idleSessions.length > 0 && (
262
          <div>
263
            <button
264
              onClick={() => setShowIdle((v) => !v)}
265
              className="font-mono uppercase flex items-center justify-between w-full"
266
              style={{ fontSize: "0.54rem", letterSpacing: "0.12em", color: "#4a525e", padding: "6px 0" }}
267
            >
268
              <span>{showIdle ? "▾" : "▸"} {idleSessions.length} idle leftover{idleSessions.length === 1 ? "" : "s"}</span>
269
              <span
270
                role="button"
271
                tabIndex={0}
272
                onClick={(e) => { e.stopPropagation(); idleSessions.forEach(onKillSession); }}
273
                onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); idleSessions.forEach(onKillSession); } }}
274
                className="hover:opacity-100 opacity-60"
275
                style={{ color: "#ff6b6b", letterSpacing: "0.08em" }}
276
              >
277
                Kill all ▸
278
              </span>
279
            </button>
280
            {showIdle && (
281
              <div className="space-y-1.5 mt-1.5">
282
                {idleSessions.map((s) => (
283
                  <SessionRow key={s.session} s={s} live={false} onAttach={attach} onKill={onKillSession} />
284
                ))}
285
              </div>
286
            )}
287
          </div>
288
        )}
289
      </div>
290
 
291
      {}
292
      <div className="px-4 pt-4 pb-4 space-y-4">
293
        <p className="font-mono uppercase" style={{ fontSize: "0.55rem", letterSpacing: "0.16em", color: "#4a525e", marginBottom: 2 }}>
294
          + New agent
295
        </p>
296
        <div>
297
          <FieldLabel>Source</FieldLabel>
298
          <select style={selectStyle} value={targetKey} onChange={(e) => setTargetKey(e.target.value)}>
299
            {targets?.targets.map((t) => (
300
              <option key={t.key} value={t.key} style={{ background: "#0a0c0e" }}>
301
                {t.label}
302
              </option>
303
            ))}
304
          </select>
305
        </div>
306
 
307
        <div>
308
          <FieldLabel>Agent</FieldLabel>
309
          <select style={selectStyle} value={agent} onChange={(e) => setAgent(e.target.value as AgentKind)}>
310
            {target?.agents.map((a) => (
311
              <option key={a} value={a} style={{ background: "#0a0c0e" }}>
312
                {AGENT_LABEL[a]} - {a}
313
              </option>
314
            ))}
315
          </select>
316
        </div>
317
 
318
        <div>
319
          <FieldLabel>Mode</FieldLabel>
320
          <div className="grid grid-cols-2 gap-2">
321
            {(["persistent", "ephemeral"] as TermMode[]).map((m) => {
322
              const on = mode === m;
323
              return (
324
                <button
325
                  key={m}
326
                  onClick={() => setMode(m)}
327
                  className="font-mono uppercase"
328
                  style={{
329
                    height: 40,
330
                    fontSize: "0.62rem",
331
                    letterSpacing: "0.08em",
332
                    color: on ? "#050607" : "#8b929e",
333
                    background: on ? "#16e0c8" : "#0a0c0e",
334
                    border: on ? "1px solid #16e0c8" : "1px solid rgba(22,224,200,0.14)",
335
                    transition: "all 0.25s cubic-bezier(0.16,1,0.3,1)",
336
                  }}
337
                >
338
                  {m}
339
                </button>
340
              );
341
            })}
342
          </div>
343
          <p className="font-mono" style={{ fontSize: "0.55rem", color: "#4a525e", marginTop: 6, letterSpacing: "0.02em" }}>
344
            {mode === "persistent" ? "tmux - survives reload, reattachable" : "ephemeral - dies with the socket"}
345
          </p>
346
        </div>
347
 
348
        <div>
349
          <FieldLabel>Working dir</FieldLabel>
350
          <select style={selectStyle} value={cwd} onChange={(e) => setCwd(e.target.value)}>
351
            {(target?.dirs ?? targets?.common_dirs ?? []).map((d) => (
352
              <option key={d.path || "home"} value={d.path} style={{ background: "#0a0c0e" }}>
353
                {d.label}
354
              </option>
355
            ))}
356
          </select>
357
        </div>
358
 
359
        <button
360
          onClick={launch}
361
          disabled={!targetKey}
362
          className="font-display font-bold uppercase w-full group"
363
          style={{
364
            height: 48,
365
            fontSize: "0.84rem",
366
            letterSpacing: "0.08em",
367
            color: "#050607",
368
            background: "#16e0c8",
369
            border: "none",
370
            cursor: targetKey ? "pointer" : "not-allowed",
371
            opacity: targetKey ? 1 : 0.4,
372
            transition: "filter 0.2s",
373
          }}
374
          onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.12)")}
375
          onMouseLeave={(e) => (e.currentTarget.style.filter = "brightness(1)")}
376
        >
377
          Launch ▸
378
        </button>
379
      </div>
380
 
381
      {}
382
      <div className="mt-auto px-4 py-3" style={{ borderTop: "1px solid rgba(22,224,200,0.08)" }}>
383
        <FieldLabel>Cross-device · RTX 3060</FieldLabel>
384
        <p className="font-mono" style={{ fontSize: "0.58rem", color: "#8b929e", lineHeight: 1.55, letterSpacing: "0.01em" }}>
385
          Persistent (default) = tmux on the host. Attach the SAME session from your phone that is
386
          open on your PC. GLM (OpenCode / CT247) runs on the 3060 when the GPU is awake; Codex
387
          (CT241) and Claude (CT215) call out.
388
        </p>
389
      </div>
390
    </div>
391
  );
392
}