| 1 | import { useMemo } from "react"; |
| 2 | |
| 3 | import { ArchiveChatBanner } from "@/components/ArchiveChatBanner"; |
| 4 | import { ChatInput } from "@/components/ChatInput"; |
| 5 | import { MessageTimeline } from "@/components/MessageTimeline"; |
| 6 | import { TerminalPane } from "@/components/TerminalPane"; |
| 7 | import { TurnStatusBanner } from "@/components/TurnStatusBanner"; |
| 8 | import { PaneHeader } from "@/components/panes/PaneHeader"; |
| 9 | import { shortModel } from "@/lib/format"; |
| 10 | import { useSessionStore } from "@/lib/store/sessions"; |
| 11 | import type { SessionSummary } from "@/lib/ipc/types"; |
| 12 | |
| 13 | export function ViewerPane() { |
| 14 | const detail = useSessionStore((s) => s.detail); |
| 15 | const loading = useSessionStore((s) => s.loading.detail); |
| 16 | const pendingSummary = useSessionStore((s) => s.pendingSummary); |
| 17 | const inFlightTurns = useSessionStore((s) => s.inFlightTurns); |
| 18 | const viewerMode = useSessionStore((s) => s.viewerMode); |
| 19 | const ptyIds = useSessionStore((s) => s.ptyIds); |
| 20 | const toggleViewerMode = useSessionStore((s) => s.toggleViewerMode); |
| 21 | const closeSessionPty = useSessionStore((s) => s.closeSessionPty); |
| 22 | |
| 23 | // Prefer the pendingSummary when a new session is mid-load — the |
| 24 | // header should flip to the *clicked* title instantly, even if |
| 25 | // `detail` still holds the previous session's content or is null. |
| 26 | const headerSummary: SessionSummary | null = |
| 27 | pendingSummary ?? detail?.summary ?? null; |
| 28 | |
| 29 | // Is an in-flight turn currently targeting the visible session? |
| 30 | // Used to flip auto-follow on the timeline so new tokens scroll |
| 31 | // into view without user intervention. |
| 32 | const hasActiveTurn = useMemo(() => { |
| 33 | if (!detail) return false; |
| 34 | const sid = detail.summary.id; |
| 35 | for (const t of inFlightTurns.values()) { |
| 36 | if ( |
| 37 | t.sessionId === sid && |
| 38 | (t.status === "spawning" || t.status === "streaming") |
| 39 | ) { |
| 40 | return true; |
| 41 | } |
| 42 | } |
| 43 | return false; |
| 44 | }, [detail, inFlightTurns]); |
| 45 | |
| 46 | // Build the claude argv for terminal mode. Must live above the |
| 47 | // early returns below — React's rules of hooks require every |
| 48 | // hook to run in the same order on every render, so an early |
| 49 | // return followed by a later `useMemo` triggers the |
| 50 | // "Rendered more hooks than during the previous render" crash. |
| 51 | const claudeArgs = useMemo<string[]>(() => { |
| 52 | if (!headerSummary) return []; |
| 53 | if (headerSummary.source === "archive") return []; |
| 54 | if (headerSummary.id.startsWith("pending-")) { |
| 55 | return ["--session-id", headerSummary.id.replace(/^pending-/, "")]; |
| 56 | } |
| 57 | return ["--resume", headerSummary.id]; |
| 58 | }, [headerSummary]); |
| 59 | |
| 60 | if (!headerSummary) { |
| 61 | return ( |
| 62 | <Shell subtitle="" loading={false}> |
| 63 | <div className="flex h-full flex-col items-center justify-center gap-2 text-center text-xs text-fg-3"> |
| 64 | <div className="text-sm text-fg-2">select a session</div> |
| 65 | <div>browse projects on the left → sessions in the middle</div> |
| 66 | </div> |
| 67 | </Shell> |
| 68 | ); |
| 69 | } |
| 70 | |
| 71 | // If the header is showing a pending session that hasn't loaded |
| 72 | // yet (cache miss), render the skeleton so the user sees an |
| 73 | // instant title-flip + shimmering body instead of a blank pane |
| 74 | // that looks frozen. |
| 75 | const showingSkeleton = pendingSummary !== null && detail === null; |
| 76 | |
| 77 | const summary = headerSummary; |
| 78 | const messages = detail?.messages ?? []; |
| 79 | const isArchive = summary.source === "archive"; |
| 80 | const mode = viewerMode.get(summary.id) ?? "cards"; |
| 81 | const hasLivePty = ptyIds.has(summary.id); |
| 82 | |
| 83 | return ( |
| 84 | <Shell |
| 85 | subtitle={summary.title} |
| 86 | loading={loading} |
| 87 | right={ |
| 88 | <div className="flex items-center gap-2 text-[10px] text-fg-3"> |
| 89 | {isArchive ? ( |
| 90 | <span className="rounded border border-yellow-900/60 bg-yellow-950/30 px-1.5 py-0.5 font-mono text-yellow-300"> |
| 91 | archived · prompts only |
| 92 | </span> |
| 93 | ) : ( |
| 94 | <ModeToggle |
| 95 | mode={mode} |
| 96 | onToggle={() => toggleViewerMode(summary.id)} |
| 97 | /> |
| 98 | )} |
| 99 | {hasLivePty && ( |
| 100 | <button |
| 101 | type="button" |
| 102 | onClick={() => void closeSessionPty(summary.id)} |
| 103 | title="close terminal" |
| 104 | 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" |
| 105 | > |
| 106 | close ✕ |
| 107 | </button> |
| 108 | )} |
| 109 | {summary.model && ( |
| 110 | <span className="rounded bg-bg-3 px-1.5 py-0.5 font-mono text-fg-2"> |
| 111 | {shortModel(summary.model)} |
| 112 | </span> |
| 113 | )} |
| 114 | <span>{summary.messageCount} messages</span> |
| 115 | {summary.gitBranch && ( |
| 116 | <> |
| 117 | <span>•</span> |
| 118 | <span className="font-mono">{summary.gitBranch}</span> |
| 119 | </> |
| 120 | )} |
| 121 | </div> |
| 122 | } |
| 123 | > |
| 124 | {!isArchive && mode === "terminal" && summary.cwd ? ( |
| 125 | // key forces a fresh mount per session — xterm state is |
| 126 | // bound to sessionId so switching sessions must teardown |
| 127 | // and re-open (the backend PTY keeps running regardless). |
| 128 | <TerminalPane |
| 129 | key={summary.id} |
| 130 | sessionId={summary.id} |
| 131 | cwd={summary.cwd} |
| 132 | claudeArgs={claudeArgs} |
| 133 | /> |
| 134 | ) : showingSkeleton ? ( |
| 135 | <CardsSkeleton /> |
| 136 | ) : detail ? ( |
| 137 | <div className="flex h-full flex-col"> |
| 138 | <div className="min-h-0 flex-1"> |
| 139 | <MessageTimeline messages={messages} autoFollow={hasActiveTurn} /> |
| 140 | </div> |
| 141 | {!isArchive && <TurnStatusBanner sessionId={summary.id} />} |
| 142 | {isArchive ? ( |
| 143 | <ArchiveChatBanner detail={detail} /> |
| 144 | ) : ( |
| 145 | <ChatInput detail={detail} /> |
| 146 | )} |
| 147 | </div> |
| 148 | ) : ( |
| 149 | <CardsSkeleton /> |
| 150 | )} |
| 151 | </Shell> |
| 152 | ); |
| 153 | } |
| 154 | |
| 155 | /** Shimmer placeholder shown while a session detail is loading. |
| 156 | * Six ghost message cards, sized similarly to real messages, with |
| 157 | * a subtle animation. Keeps the viewer feeling alive instead of |
| 158 | * showing a frozen "loading…" string. */ |
| 159 | function CardsSkeleton() { |
| 160 | return ( |
| 161 | <div className="flex h-full flex-col gap-4 overflow-hidden p-4"> |
| 162 | {Array.from({ length: 6 }).map((_, i) => ( |
| 163 | <div |
| 164 | key={i} |
| 165 | className="flex animate-pulse flex-col gap-2" |
| 166 | style={{ animationDelay: `${i * 60}ms` }} |
| 167 | > |
| 168 | <div className="h-3 w-24 rounded bg-bg-2" /> |
| 169 | <div className="h-4 w-full rounded bg-bg-2" /> |
| 170 | <div className="h-4 w-5/6 rounded bg-bg-2" /> |
| 171 | <div className="h-4 w-2/3 rounded bg-bg-2" /> |
| 172 | </div> |
| 173 | ))} |
| 174 | </div> |
| 175 | ); |
| 176 | } |
| 177 | |
| 178 | function ModeToggle({ |
| 179 | mode, |
| 180 | onToggle, |
| 181 | }: { |
| 182 | mode: "cards" | "terminal"; |
| 183 | onToggle: () => void; |
| 184 | }) { |
| 185 | return ( |
| 186 | <button |
| 187 | type="button" |
| 188 | onClick={onToggle} |
| 189 | title={mode === "cards" ? "switch to terminal" : "switch to cards"} |
| 190 | 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" |
| 191 | > |
| 192 | <span |
| 193 | className={ |
| 194 | mode === "cards" |
| 195 | ? "rounded bg-accent/20 px-1 text-accent" |
| 196 | : "px-1 text-fg-3" |
| 197 | } |
| 198 | > |
| 199 | cards |
| 200 | </span> |
| 201 | <span |
| 202 | className={ |
| 203 | mode === "terminal" |
| 204 | ? "rounded bg-accent/20 px-1 text-accent" |
| 205 | : "px-1 text-fg-3" |
| 206 | } |
| 207 | > |
| 208 | terminal |
| 209 | </span> |
| 210 | </button> |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | function Shell({ |
| 215 | subtitle, |
| 216 | right, |
| 217 | loading, |
| 218 | children, |
| 219 | }: { |
| 220 | subtitle: string; |
| 221 | right?: React.ReactNode; |
| 222 | loading: boolean; |
| 223 | children: React.ReactNode; |
| 224 | }) { |
| 225 | return ( |
| 226 | <div className="flex h-full flex-col overflow-hidden"> |
| 227 | <PaneHeader title="Viewer" subtitle={subtitle} right={right} /> |
| 228 | {loading && ( |
| 229 | // Indeterminate progress stripe pinned under the header. |
| 230 | // Tells the user "something is in flight" without the |
| 231 | // jarring "loading session…" centered text that used to |
| 232 | // replace the whole body on every click. |
| 233 | <div |
| 234 | aria-hidden |
| 235 | className="h-0.5 w-full shrink-0 bg-gradient-to-r from-transparent via-accent/60 to-transparent" |
| 236 | /> |
| 237 | )} |
| 238 | <div className="min-h-0 flex-1 overflow-hidden">{children}</div> |
| 239 | </div> |
| 240 | ); |
| 241 | } |