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