| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import type { LiveSession, AgentKind } from "@/lib/term"; |
| 5 | import { AGENT_LABEL } from "@/lib/term"; |
| 6 | |
| 7 | interface SessionPickerProps { |
| 8 | openSessionIds: Set<string>; |
| 9 | onPick: (s: LiveSession) => void; |
| 10 | onClose: () => void; |
| 11 | onLaunchNew: () => void; |
| 12 | } |
| 13 | |
| 14 | function ageOf(created: string): string { |
| 15 | const t = parseInt(created, 10); |
| 16 | if (!t) return ""; |
| 17 | const s = Math.max(0, Math.floor(Date.now() / 1000 - t)); |
| 18 | if (s < 60) return `${s}s`; |
| 19 | if (s < 3600) return `${Math.floor(s / 60)}m`; |
| 20 | if (s < 86400) return `${Math.floor(s / 3600)}h`; |
| 21 | return `${Math.floor(s / 86400)}d`; |
| 22 | } |
| 23 | |
| 24 | function truncate(s: string, n: number): string { |
| 25 | return s.length > n ? `${s.slice(0, n)}…` : s; |
| 26 | } |
| 27 | |
| 28 | export default function SessionPicker({ |
| 29 | openSessionIds, |
| 30 | onPick, |
| 31 | onClose, |
| 32 | onLaunchNew, |
| 33 | }: SessionPickerProps) { |
| 34 | const [sessions, setSessions] = useState<LiveSession[]>([]); |
| 35 | |
| 36 | useEffect(() => { |
| 37 | let alive = true; |
| 38 | const tick = async () => { |
| 39 | try { |
| 40 | const res = await fetch("/api/term/sessions", { cache: "no-store" }); |
| 41 | const data = await res.json(); |
| 42 | if (alive) setSessions((data.sessions ?? []) as LiveSession[]); |
| 43 | } catch { |
| 44 | } |
| 45 | }; |
| 46 | tick(); |
| 47 | const id = setInterval(tick, 5000); |
| 48 | return () => { |
| 49 | alive = false; |
| 50 | clearInterval(id); |
| 51 | }; |
| 52 | }, []); |
| 53 | |
| 54 | useEffect(() => { |
| 55 | const onKey = (e: KeyboardEvent) => { |
| 56 | if (e.key === "Escape") onClose(); |
| 57 | }; |
| 58 | window.addEventListener("keydown", onKey); |
| 59 | return () => window.removeEventListener("keydown", onKey); |
| 60 | }, [onClose]); |
| 61 | |
| 62 | const sorted = [...sessions].sort( |
| 63 | (a, b) => parseInt(b.created, 10) - parseInt(a.created, 10) |
| 64 | ); |
| 65 | |
| 66 | return ( |
| 67 | <div |
| 68 | className="fixed inset-0 z-[60] flex items-center justify-center" |
| 69 | style={{ background: "rgba(5,6,7,0.6)", backdropFilter: "blur(2px)" }} |
| 70 | onClick={onClose} |
| 71 | > |
| 72 | <div |
| 73 | onClick={(e) => e.stopPropagation()} |
| 74 | className="flex flex-col" |
| 75 | style={{ |
| 76 | background: "#080a0c", |
| 77 | border: "1px solid rgba(22,224,200,0.2)", |
| 78 | maxWidth: 520, |
| 79 | width: "calc(100% - 32px)", |
| 80 | maxHeight: "70vh", |
| 81 | }} |
| 82 | > |
| 83 | {} |
| 84 | <div |
| 85 | className="flex shrink-0 items-center justify-between" |
| 86 | style={{ padding: "12px 14px", borderBottom: "1px solid rgba(22,224,200,0.12)" }} |
| 87 | > |
| 88 | <div className="flex items-center gap-2 min-w-0"> |
| 89 | <span |
| 90 | className="font-mono uppercase shrink-0" |
| 91 | style={{ fontSize: "0.55rem", letterSpacing: "0.2em", color: "#0d9e8e" }} |
| 92 | > |
| 93 | ⇄ Assign |
| 94 | </span> |
| 95 | <span |
| 96 | className="font-display font-bold uppercase truncate" |
| 97 | style={{ fontSize: "0.82rem", letterSpacing: "0.04em", color: "#e8eaed" }} |
| 98 | > |
| 99 | Live Sessions |
| 100 | </span> |
| 101 | </div> |
| 102 | <button |
| 103 | onClick={onClose} |
| 104 | aria-label="Close" |
| 105 | className="flex shrink-0 items-center justify-center" |
| 106 | style={{ width: 24, height: 24, color: "#4a525e", transition: "color 0.2s" }} |
| 107 | onMouseEnter={(e) => (e.currentTarget.style.color = "#ff6b6b")} |
| 108 | onMouseLeave={(e) => (e.currentTarget.style.color = "#4a525e")} |
| 109 | > |
| 110 | <svg width="12" height="12" viewBox="0 0 11 11" fill="none"> |
| 111 | <path d="M1 1L10 10M10 1L1 10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> |
| 112 | </svg> |
| 113 | </button> |
| 114 | </div> |
| 115 | |
| 116 | {} |
| 117 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 118 | {sorted.length === 0 && ( |
| 119 | <p |
| 120 | className="font-mono" |
| 121 | style={{ |
| 122 | fontSize: "0.62rem", |
| 123 | color: "#4a525e", |
| 124 | letterSpacing: "0.03em", |
| 125 | padding: "24px 14px", |
| 126 | lineHeight: 1.6, |
| 127 | }} |
| 128 | > |
| 129 | No sessions running. Launch a new one below. |
| 130 | </p> |
| 131 | )} |
| 132 | |
| 133 | {sorted.map((s) => { |
| 134 | const inPane = openSessionIds.has(s.session); |
| 135 | const agent = (s.agent as AgentKind) || "shell"; |
| 136 | return ( |
| 137 | <button |
| 138 | key={s.session} |
| 139 | onClick={() => onPick(s)} |
| 140 | className="flex w-full items-center gap-2.5 text-left" |
| 141 | style={{ |
| 142 | padding: "10px 14px", |
| 143 | borderBottom: "1px solid rgba(22,224,200,0.06)", |
| 144 | transition: "background 0.15s", |
| 145 | }} |
| 146 | onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(22,224,200,0.06)")} |
| 147 | onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} |
| 148 | > |
| 149 | {} |
| 150 | <span className="flex shrink-0 items-center justify-center" style={{ width: 6, height: 6 }}> |
| 151 | {inPane && ( |
| 152 | <span |
| 153 | style={{ |
| 154 | width: 6, |
| 155 | height: 6, |
| 156 | background: "#f5c518", |
| 157 | boxShadow: "0 0 6px #f5c518", |
| 158 | display: "block", |
| 159 | }} |
| 160 | /> |
| 161 | )} |
| 162 | </span> |
| 163 | |
| 164 | <div className="min-w-0 flex-1"> |
| 165 | <div className="flex items-center gap-2"> |
| 166 | <span |
| 167 | className="font-display font-bold uppercase" |
| 168 | style={{ fontSize: "0.72rem", color: "#e8eaed", letterSpacing: "0.03em" }} |
| 169 | > |
| 170 | {AGENT_LABEL[agent] ?? s.agent} |
| 171 | </span> |
| 172 | <span className="font-mono" style={{ fontSize: "0.58rem", color: "#8b929e", letterSpacing: "0.04em" }}> |
| 173 | {s.target} |
| 174 | </span> |
| 175 | {inPane && ( |
| 176 | <span className="font-mono" style={{ fontSize: "0.5rem", color: "#f5c518", letterSpacing: "0.08em" }}> |
| 177 | IN PANE |
| 178 | </span> |
| 179 | )} |
| 180 | {s.attached && ( |
| 181 | <span |
| 182 | className="font-mono" |
| 183 | style={{ |
| 184 | fontSize: "0.5rem", |
| 185 | color: "#0d9e8e", |
| 186 | letterSpacing: "0.06em", |
| 187 | border: "1px solid rgba(22,224,200,0.24)", |
| 188 | padding: "1px 4px", |
| 189 | }} |
| 190 | > |
| 191 | OPEN |
| 192 | </span> |
| 193 | )} |
| 194 | </div> |
| 195 | <div className="flex items-center gap-2"> |
| 196 | <span |
| 197 | className="font-mono truncate" |
| 198 | style={{ fontSize: "0.54rem", color: "#4a525e", letterSpacing: "0.04em", maxWidth: 320 }} |
| 199 | title={s.session} |
| 200 | > |
| 201 | {truncate(s.session, 26)} |
| 202 | </span> |
| 203 | <span className="font-mono shrink-0" style={{ fontSize: "0.52rem", color: "#4a525e", letterSpacing: "0.04em" }}> |
| 204 | {ageOf(s.created)} ago |
| 205 | </span> |
| 206 | </div> |
| 207 | </div> |
| 208 | |
| 209 | <span |
| 210 | className="font-display font-bold uppercase shrink-0" |
| 211 | style={{ fontSize: "0.6rem", color: "#16e0c8", letterSpacing: "0.06em" }} |
| 212 | > |
| 213 | Load ▸ |
| 214 | </span> |
| 215 | </button> |
| 216 | ); |
| 217 | })} |
| 218 | </div> |
| 219 | |
| 220 | {} |
| 221 | <div className="shrink-0" style={{ padding: "10px 14px", borderTop: "1px solid rgba(22,224,200,0.12)" }}> |
| 222 | <button |
| 223 | onClick={onLaunchNew} |
| 224 | className="font-display font-bold uppercase w-full" |
| 225 | style={{ |
| 226 | height: 40, |
| 227 | fontSize: "0.72rem", |
| 228 | letterSpacing: "0.08em", |
| 229 | color: "#050607", |
| 230 | background: "#16e0c8", |
| 231 | border: "none", |
| 232 | cursor: "pointer", |
| 233 | transition: "filter 0.2s", |
| 234 | }} |
| 235 | onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.12)")} |
| 236 | onMouseLeave={(e) => (e.currentTarget.style.filter = "brightness(1)")} |
| 237 | > |
| 238 | + Launch New |
| 239 | </button> |
| 240 | </div> |
| 241 | </div> |
| 242 | </div> |
| 243 | ); |
| 244 | } |