| 1 | import { lazy, Suspense, useEffect, useMemo } from "react"; |
| 2 | |
| 3 | import { ArchiveChatBanner } from "@/components/ArchiveChatBanner"; |
| 4 | import { ChatInput } from "@/components/ChatInput"; |
| 5 | import { MessageTimeline } from "@/components/MessageTimeline"; |
| 6 | import { TurnStatusBanner } from "@/components/TurnStatusBanner"; |
| 7 | import { PaneHeader } from "@/components/panes/PaneHeader"; |
| 8 | import { shortModel } from "@/lib/format"; |
| 9 | import { useSessionStore } from "@/lib/store/sessions"; |
| 10 | import type { SessionSummary } from "@/lib/ipc/types"; |
| 11 | |
| 12 | // Code-split xterm. The terminal stack (xterm core + fit + webgl + |
| 13 | // unicode11 + web-links) is ~230 KB gzipped — that's a third of the |
| 14 | // total JS bundle. Lazy-loading keeps it out of initial paint so |
| 15 | // the app starts faster, and users who never open a terminal |
| 16 | // session never pay the cost at all. |
| 17 | const TerminalPane = lazy(() => |
| 18 | import("@/components/TerminalPane").then((m) => ({ default: m.TerminalPane })), |
| 19 | ); |
| 20 | |
| 21 | export function ViewerPane() { |
| 22 | const detail = useSessionStore((s) => s.detail); |
| 23 | const loading = useSessionStore((s) => s.loading.detail); |
| 24 | const pendingSummary = useSessionStore((s) => s.pendingSummary); |
| 25 | const inFlightTurns = useSessionStore((s) => s.inFlightTurns); |
| 26 | const viewerMode = useSessionStore((s) => s.viewerMode); |
| 27 | const ptyIds = useSessionStore((s) => s.ptyIds); |
| 28 | const toggleViewerMode = useSessionStore((s) => s.toggleViewerMode); |
| 29 | const closeSessionPty = useSessionStore((s) => s.closeSessionPty); |
| 30 | const ensureDetailFor = useSessionStore((s) => s.ensureDetailFor); |
| 31 | |
| 32 | // Prefer the pendingSummary when a new session is mid-load — the |
| 33 | // header should flip to the *clicked* title instantly, even if |
| 34 | // `detail` still holds the previous session's content or is null. |
| 35 | const headerSummary: SessionSummary | null = |
| 36 | pendingSummary ?? detail?.summary ?? null; |
| 37 | |
| 38 | // Is an in-flight turn currently targeting the visible session? |
| 39 | // Used to flip auto-follow on the timeline so new tokens scroll |
| 40 | // into view without user intervention. |
| 41 | const hasActiveTurn = useMemo(() => { |
| 42 | if (!detail) return false; |
| 43 | const sid = detail.summary.id; |
| 44 | for (const t of inFlightTurns.values()) { |
| 45 | if ( |
| 46 | t.sessionId === sid && |
| 47 | (t.status === "spawning" || t.status === "streaming") |
| 48 | ) { |
| 49 | return true; |
| 50 | } |
| 51 | } |
| 52 | return false; |
| 53 | }, [detail, inFlightTurns]); |
| 54 | |
| 55 | // ALL HOOKS MUST RUN BEFORE ANY EARLY RETURN. React's rules of |
| 56 | // hooks require a fixed call order per render, and an early |
| 57 | // return sitting above a later hook violates it with |
| 58 | // "Rendered more hooks than during the previous render". |
| 59 | const claudeArgs = useMemo<string[]>(() => { |
| 60 | if (!headerSummary) return []; |
| 61 | if (headerSummary.source === "archive") return []; |
| 62 | if (headerSummary.id.startsWith("pending-")) { |
| 63 | return ["--session-id", headerSummary.id.replace(/^pending-/, "")]; |
| 64 | } |
| 65 | return ["--resume", headerSummary.id]; |
| 66 | }, [headerSummary]); |
| 67 | |
| 68 | // Lazy detail load for terminal-default sessions that the user |
| 69 | // has just toggled to cards mode. Computed pre-return so the |
| 70 | // hook order stays stable whether or not headerSummary is set. |
| 71 | const lazyLoadTarget = useMemo<SessionSummary | null>(() => { |
| 72 | if (!headerSummary) return null; |
| 73 | if (headerSummary.source === "archive") return null; |
| 74 | const mode = viewerMode.get(headerSummary.id) ?? "cards"; |
| 75 | if (mode !== "cards") return null; |
| 76 | if (loading) return null; |
| 77 | if (detail && detail.summary.id === headerSummary.id) return null; |
| 78 | return headerSummary; |
| 79 | }, [headerSummary, viewerMode, loading, detail]); |
| 80 | useEffect(() => { |
| 81 | if (!lazyLoadTarget) return; |
| 82 | void ensureDetailFor(lazyLoadTarget); |
| 83 | }, [lazyLoadTarget, ensureDetailFor]); |
| 84 | |
| 85 | if (!headerSummary) { |
| 86 | return ( |
| 87 | <Shell subtitle="" loading={false}> |
| 88 | <div className="flex h-full flex-col items-center justify-center gap-2 text-center text-xs text-fg-3"> |
| 89 | <div className="text-sm text-fg-2">select a session</div> |
| 90 | <div>browse projects on the left → sessions in the middle</div> |
| 91 | </div> |
| 92 | </Shell> |
| 93 | ); |
| 94 | } |
| 95 | |
| 96 | const summary = headerSummary; |
| 97 | const messages = detail?.messages ?? []; |
| 98 | const isArchive = summary.source === "archive"; |
| 99 | const mode = viewerMode.get(summary.id) ?? "cards"; |
| 100 | const hasLivePty = ptyIds.has(summary.id); |
| 101 | |
| 102 | // Only show the indeterminate stripe when we're showing |
| 103 | // *cached* content while a background refresh runs. On a cold |
| 104 | // load the CardsSkeleton body is its own loading indicator, so |
| 105 | // a second stripe on top of it would just be noise. |
| 106 | const refreshing = |
| 107 | loading && detail !== null && detail.summary.id === summary.id; |
| 108 | |
| 109 | return ( |
| 110 | <Shell |
| 111 | subtitle={summary.title} |
| 112 | loading={refreshing} |
| 113 | right={ |
| 114 | <div className="flex items-center gap-2 text-[10px] text-fg-3"> |
| 115 | {isArchive ? ( |
| 116 | <span className="rounded border border-yellow-900/60 bg-yellow-950/30 px-1.5 py-0.5 font-mono text-yellow-300"> |
| 117 | archived · prompts only |
| 118 | </span> |
| 119 | ) : ( |
| 120 | <ModeToggle |
| 121 | mode={mode} |
| 122 | onToggle={() => toggleViewerMode(summary.id)} |
| 123 | /> |
| 124 | )} |
| 125 | {hasLivePty && ( |
| 126 | <button |
| 127 | type="button" |
| 128 | onClick={() => void closeSessionPty(summary.id)} |
| 129 | title="close terminal" |
| 130 | className="rounded border border-red-900/60 bg-red-950/30 px-1.5 py-0.5 font-mono text-red-300 hover:bg-red-900/40" |
| 131 | > |
| 132 | close ✕ |
| 133 | </button> |
| 134 | )} |
| 135 | {summary.model && ( |
| 136 | <span className="rounded bg-bg-3 px-1.5 py-0.5 font-mono text-fg-2"> |
| 137 | {shortModel(summary.model)} |
| 138 | </span> |
| 139 | )} |
| 140 | <span>{summary.messageCount} messages</span> |
| 141 | {summary.gitBranch && ( |
| 142 | <> |
| 143 | <span>•</span> |
| 144 | <span className="font-mono">{summary.gitBranch}</span> |
| 145 | </> |
| 146 | )} |
| 147 | </div> |
| 148 | } |
| 149 | > |
| 150 | {!isArchive && mode === "terminal" && summary.cwd ? ( |
| 151 | // key forces a fresh mount per session — xterm state is |
| 152 | // bound to sessionId so the backend PTY keeps running |
| 153 | // regardless. |
| 154 | <Suspense fallback={<TerminalLoading />}> |
| 155 | <TerminalPane |
| 156 | key={summary.id} |
| 157 | sessionId={summary.id} |
| 158 | cwd={summary.cwd} |
| 159 | claudeArgs={claudeArgs} |
| 160 | /> |
| 161 | </Suspense> |
| 162 | ) : !detail || detail.summary.id !== summary.id ? ( |
| 163 | <CardsSkeleton /> |
| 164 | ) : ( |
| 165 | <div className="flex h-full flex-col"> |
| 166 | <div className="min-h-0 flex-1"> |
| 167 | <MessageTimeline messages={messages} autoFollow={hasActiveTurn} /> |
| 168 | </div> |
| 169 | {!isArchive && <TurnStatusBanner sessionId={summary.id} />} |
| 170 | {isArchive ? ( |
| 171 | <ArchiveChatBanner detail={detail} /> |
| 172 | ) : ( |
| 173 | <ChatInput detail={detail} /> |
| 174 | )} |
| 175 | </div> |
| 176 | )} |
| 177 | </Shell> |
| 178 | ); |
| 179 | } |
| 180 | |
| 181 | function TerminalLoading() { |
| 182 | return ( |
| 183 | <div className="flex h-full items-center justify-center text-[11px] text-fg-3"> |
| 184 | loading terminal… |
| 185 | </div> |
| 186 | ); |
| 187 | } |
| 188 | |
| 189 | function CardsSkeleton() { |
| 190 | return ( |
| 191 | <div className="flex h-full flex-col gap-4 overflow-hidden p-4"> |
| 192 | {Array.from({ length: 6 }).map((_, i) => ( |
| 193 | <div |
| 194 | key={i} |
| 195 | className="flex animate-pulse flex-col gap-2" |
| 196 | style={{ animationDelay: `${i * 60}ms` }} |
| 197 | > |
| 198 | <div className="h-3 w-24 rounded bg-bg-2" /> |
| 199 | <div className="h-4 w-full rounded bg-bg-2" /> |
| 200 | <div className="h-4 w-5/6 rounded bg-bg-2" /> |
| 201 | <div className="h-4 w-2/3 rounded bg-bg-2" /> |
| 202 | </div> |
| 203 | ))} |
| 204 | </div> |
| 205 | ); |
| 206 | } |
| 207 | |
| 208 | function ModeToggle({ |
| 209 | mode, |
| 210 | onToggle, |
| 211 | }: { |
| 212 | mode: "cards" | "terminal"; |
| 213 | onToggle: () => void; |
| 214 | }) { |
| 215 | return ( |
| 216 | <button |
| 217 | type="button" |
| 218 | onClick={onToggle} |
| 219 | title={mode === "cards" ? "switch to terminal" : "switch to cards"} |
| 220 | className="flex items-center gap-1 rounded border border-bg-3 bg-bg-2 px-1 py-0.5 font-mono text-fg-2 hover:border-accent/40" |
| 221 | > |
| 222 | <span |
| 223 | className={ |
| 224 | mode === "cards" |
| 225 | ? "rounded bg-accent/20 px-1 text-accent" |
| 226 | : "px-1 text-fg-3" |
| 227 | } |
| 228 | > |
| 229 | cards |
| 230 | </span> |
| 231 | <span |
| 232 | className={ |
| 233 | mode === "terminal" |
| 234 | ? "rounded bg-accent/20 px-1 text-accent" |
| 235 | : "px-1 text-fg-3" |
| 236 | } |
| 237 | > |
| 238 | terminal |
| 239 | </span> |
| 240 | </button> |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | function Shell({ |
| 245 | subtitle, |
| 246 | right, |
| 247 | loading, |
| 248 | children, |
| 249 | }: { |
| 250 | subtitle: string; |
| 251 | right?: React.ReactNode; |
| 252 | loading: boolean; |
| 253 | children: React.ReactNode; |
| 254 | }) { |
| 255 | return ( |
| 256 | <div className="flex h-full flex-col overflow-hidden"> |
| 257 | <PaneHeader title="Viewer" subtitle={subtitle} right={right} /> |
| 258 | {loading && ( |
| 259 | <div |
| 260 | aria-hidden |
| 261 | className="h-0.5 w-full shrink-0 bg-gradient-to-r from-transparent via-accent/60 to-transparent" |
| 262 | /> |
| 263 | )} |
| 264 | <div className="min-h-0 flex-1 overflow-hidden">{children}</div> |
| 265 | </div> |
| 266 | ); |
| 267 | } |