@@ -52,11 +52,10 @@ export function ViewerPane() { |
| 52 | 52 | return false; |
| 53 | 53 | }, [detail, inFlightTurns]); |
| 54 | 54 | |
| 55 | | - // Build the claude argv for terminal mode. Must live above the |
| 56 | | - // early returns below — React's rules of hooks require every |
| 57 | | - // hook to run in the same order on every render, so an early |
| 58 | | - // return followed by a later `useMemo` triggers the |
| 59 | | - // "Rendered more hooks than during the previous render" crash. |
| 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". |
| 60 | 59 | const claudeArgs = useMemo<string[]>(() => { |
| 61 | 60 | if (!headerSummary) return []; |
| 62 | 61 | if (headerSummary.source === "archive") return []; |
@@ -66,6 +65,23 @@ export function ViewerPane() { |
| 66 | 65 | return ["--resume", headerSummary.id]; |
| 67 | 66 | }, [headerSummary]); |
| 68 | 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 | + |
| 69 | 85 | if (!headerSummary) { |
| 70 | 86 | return ( |
| 71 | 87 | <Shell subtitle="" loading={false}> |
@@ -77,32 +93,23 @@ export function ViewerPane() { |
| 77 | 93 | ); |
| 78 | 94 | } |
| 79 | 95 | |
| 80 | | - const showingSkeleton = pendingSummary !== null && detail === null; |
| 81 | | - |
| 82 | 96 | const summary = headerSummary; |
| 83 | 97 | const messages = detail?.messages ?? []; |
| 84 | 98 | const isArchive = summary.source === "archive"; |
| 85 | 99 | const mode = viewerMode.get(summary.id) ?? "cards"; |
| 86 | 100 | const hasLivePty = ptyIds.has(summary.id); |
| 87 | 101 | |
| 88 | | - // Lazy-load detail if the user is in cards mode for a session |
| 89 | | - // whose body we skipped fetching on click (because it defaulted |
| 90 | | - // to terminal mode). Only fires when the detail we need genuinely |
| 91 | | - // isn't already loaded or in flight. |
| 92 | | - const needsLazyCardsLoad = |
| 93 | | - mode === "cards" && |
| 94 | | - !isArchive && |
| 95 | | - !loading && |
| 96 | | - (detail === null || detail.summary.id !== summary.id); |
| 97 | | - useEffect(() => { |
| 98 | | - if (!needsLazyCardsLoad) return; |
| 99 | | - void ensureDetailFor(summary); |
| 100 | | - }, [needsLazyCardsLoad, summary, ensureDetailFor]); |
| 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; |
| 101 | 108 | |
| 102 | 109 | return ( |
| 103 | 110 | <Shell |
| 104 | 111 | subtitle={summary.title} |
| 105 | | - loading={loading} |
| 112 | + loading={refreshing} |
| 106 | 113 | right={ |
| 107 | 114 | <div className="flex items-center gap-2 text-[10px] text-fg-3"> |
| 108 | 115 | {isArchive ? ( |
@@ -142,12 +149,8 @@ export function ViewerPane() { |
| 142 | 149 | > |
| 143 | 150 | {!isArchive && mode === "terminal" && summary.cwd ? ( |
| 144 | 151 | // key forces a fresh mount per session — xterm state is |
| 145 | | - // bound to sessionId so switching sessions must teardown |
| 146 | | - // and re-open (the backend PTY keeps running regardless). |
| 147 | | - // |
| 148 | | - // Suspense fallback renders while the lazy chunk downloads |
| 149 | | - // on first use. Once loaded, the chunk is cached so |
| 150 | | - // subsequent mounts are instant. |
| 152 | + // bound to sessionId so the backend PTY keeps running |
| 153 | + // regardless. |
| 151 | 154 | <Suspense fallback={<TerminalLoading />}> |
| 152 | 155 | <TerminalPane |
| 153 | 156 | key={summary.id} |
@@ -156,9 +159,9 @@ export function ViewerPane() { |
| 156 | 159 | claudeArgs={claudeArgs} |
| 157 | 160 | /> |
| 158 | 161 | </Suspense> |
| 159 | | - ) : showingSkeleton ? ( |
| 162 | + ) : !detail || detail.summary.id !== summary.id ? ( |
| 160 | 163 | <CardsSkeleton /> |
| 161 | | - ) : detail ? ( |
| 164 | + ) : ( |
| 162 | 165 | <div className="flex h-full flex-col"> |
| 163 | 166 | <div className="min-h-0 flex-1"> |
| 164 | 167 | <MessageTimeline messages={messages} autoFollow={hasActiveTurn} /> |
@@ -170,16 +173,11 @@ export function ViewerPane() { |
| 170 | 173 | <ChatInput detail={detail} /> |
| 171 | 174 | )} |
| 172 | 175 | </div> |
| 173 | | - ) : ( |
| 174 | | - <CardsSkeleton /> |
| 175 | 176 | )} |
| 176 | 177 | </Shell> |
| 177 | 178 | ); |
| 178 | 179 | } |
| 179 | 180 | |
| 180 | | -/** Tiny placeholder for the lazy TerminalPane chunk. Only visible |
| 181 | | - * on the first-ever terminal mount in a session — subsequent |
| 182 | | - * mounts reuse the cached module and render instantly. */ |
| 183 | 181 | function TerminalLoading() { |
| 184 | 182 | return ( |
| 185 | 183 | <div className="flex h-full items-center justify-center text-[11px] text-fg-3"> |
@@ -188,10 +186,6 @@ function TerminalLoading() { |
| 188 | 186 | ); |
| 189 | 187 | } |
| 190 | 188 | |
| 191 | | -/** Shimmer placeholder shown while a session detail is loading. |
| 192 | | - * Six ghost message cards, sized similarly to real messages, with |
| 193 | | - * a subtle animation. Keeps the viewer feeling alive instead of |
| 194 | | - * showing a frozen "loading…" string. */ |
| 195 | 189 | function CardsSkeleton() { |
| 196 | 190 | return ( |
| 197 | 191 | <div className="flex h-full flex-col gap-4 overflow-hidden p-4"> |
@@ -262,10 +256,6 @@ function Shell({ |
| 262 | 256 | <div className="flex h-full flex-col overflow-hidden"> |
| 263 | 257 | <PaneHeader title="Viewer" subtitle={subtitle} right={right} /> |
| 264 | 258 | {loading && ( |
| 265 | | - // Indeterminate progress stripe pinned under the header. |
| 266 | | - // Tells the user "something is in flight" without the |
| 267 | | - // jarring "loading session…" centered text that used to |
| 268 | | - // replace the whole body on every click. |
| 269 | 259 | <div |
| 270 | 260 | aria-hidden |
| 271 | 261 | className="h-0.5 w-full shrink-0 bg-gradient-to-r from-transparent via-accent/60 to-transparent" |