| 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 | } |