TypeScript · 9119 bytes Raw Blame History
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 }