@@ -8,17 +8,24 @@ import { TurnStatusBanner } from "@/components/TurnStatusBanner"; |
| 8 | 8 | import { PaneHeader } from "@/components/panes/PaneHeader"; |
| 9 | 9 | import { shortModel } from "@/lib/format"; |
| 10 | 10 | import { useSessionStore } from "@/lib/store/sessions"; |
| 11 | +import type { SessionSummary } from "@/lib/ipc/types"; |
| 11 | 12 | |
| 12 | 13 | export function ViewerPane() { |
| 13 | 14 | const detail = useSessionStore((s) => s.detail); |
| 14 | 15 | const loading = useSessionStore((s) => s.loading.detail); |
| 15 | | - const selectedSessionId = useSessionStore((s) => s.selectedSessionId); |
| 16 | + const pendingSummary = useSessionStore((s) => s.pendingSummary); |
| 16 | 17 | const inFlightTurns = useSessionStore((s) => s.inFlightTurns); |
| 17 | 18 | const viewerMode = useSessionStore((s) => s.viewerMode); |
| 18 | 19 | const ptyIds = useSessionStore((s) => s.ptyIds); |
| 19 | 20 | const toggleViewerMode = useSessionStore((s) => s.toggleViewerMode); |
| 20 | 21 | const closeSessionPty = useSessionStore((s) => s.closeSessionPty); |
| 21 | 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 | + |
| 22 | 29 | // Is an in-flight turn currently targeting the visible session? |
| 23 | 30 | // Used to flip auto-follow on the timeline so new tokens scroll |
| 24 | 31 | // into view without user intervention. |
@@ -42,28 +49,17 @@ export function ViewerPane() { |
| 42 | 49 | // return followed by a later `useMemo` triggers the |
| 43 | 50 | // "Rendered more hooks than during the previous render" crash. |
| 44 | 51 | const claudeArgs = useMemo<string[]>(() => { |
| 45 | | - if (!detail) return []; |
| 46 | | - const { summary } = detail; |
| 47 | | - if (summary.source === "archive") return []; |
| 48 | | - if (summary.id.startsWith("pending-")) { |
| 49 | | - return ["--session-id", summary.id.replace(/^pending-/, "")]; |
| 52 | + if (!headerSummary) return []; |
| 53 | + if (headerSummary.source === "archive") return []; |
| 54 | + if (headerSummary.id.startsWith("pending-")) { |
| 55 | + return ["--session-id", headerSummary.id.replace(/^pending-/, "")]; |
| 50 | 56 | } |
| 51 | | - return ["--resume", summary.id]; |
| 52 | | - }, [detail]); |
| 57 | + return ["--resume", headerSummary.id]; |
| 58 | + }, [headerSummary]); |
| 53 | 59 | |
| 54 | | - if (loading) { |
| 60 | + if (!headerSummary) { |
| 55 | 61 | return ( |
| 56 | | - <Shell subtitle={selectedSessionId ?? ""}> |
| 57 | | - <div className="flex h-full items-center justify-center text-xs text-fg-3"> |
| 58 | | - loading session… |
| 59 | | - </div> |
| 60 | | - </Shell> |
| 61 | | - ); |
| 62 | | - } |
| 63 | | - |
| 64 | | - if (!detail) { |
| 65 | | - return ( |
| 66 | | - <Shell subtitle=""> |
| 62 | + <Shell subtitle="" loading={false}> |
| 67 | 63 | <div className="flex h-full flex-col items-center justify-center gap-2 text-center text-xs text-fg-3"> |
| 68 | 64 | <div className="text-sm text-fg-2">select a session</div> |
| 69 | 65 | <div>browse projects on the left → sessions in the middle</div> |
@@ -72,7 +68,14 @@ export function ViewerPane() { |
| 72 | 68 | ); |
| 73 | 69 | } |
| 74 | 70 | |
| 75 | | - const { summary, messages } = detail; |
| 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 ?? []; |
| 76 | 79 | const isArchive = summary.source === "archive"; |
| 77 | 80 | const mode = viewerMode.get(summary.id) ?? "cards"; |
| 78 | 81 | const hasLivePty = ptyIds.has(summary.id); |
@@ -80,6 +83,7 @@ export function ViewerPane() { |
| 80 | 83 | return ( |
| 81 | 84 | <Shell |
| 82 | 85 | subtitle={summary.title} |
| 86 | + loading={loading} |
| 83 | 87 | right={ |
| 84 | 88 | <div className="flex items-center gap-2 text-[10px] text-fg-3"> |
| 85 | 89 | {isArchive ? ( |
@@ -107,7 +111,7 @@ export function ViewerPane() { |
| 107 | 111 | {shortModel(summary.model)} |
| 108 | 112 | </span> |
| 109 | 113 | )} |
| 110 | | - <span>{messages.length} messages</span> |
| 114 | + <span>{summary.messageCount} messages</span> |
| 111 | 115 | {summary.gitBranch && ( |
| 112 | 116 | <> |
| 113 | 117 | <span>•</span> |
@@ -127,7 +131,9 @@ export function ViewerPane() { |
| 127 | 131 | cwd={summary.cwd} |
| 128 | 132 | claudeArgs={claudeArgs} |
| 129 | 133 | /> |
| 130 | | - ) : ( |
| 134 | + ) : showingSkeleton ? ( |
| 135 | + <CardsSkeleton /> |
| 136 | + ) : detail ? ( |
| 131 | 137 | <div className="flex h-full flex-col"> |
| 132 | 138 | <div className="min-h-0 flex-1"> |
| 133 | 139 | <MessageTimeline messages={messages} autoFollow={hasActiveTurn} /> |
@@ -139,11 +145,36 @@ export function ViewerPane() { |
| 139 | 145 | <ChatInput detail={detail} /> |
| 140 | 146 | )} |
| 141 | 147 | </div> |
| 148 | + ) : ( |
| 149 | + <CardsSkeleton /> |
| 142 | 150 | )} |
| 143 | 151 | </Shell> |
| 144 | 152 | ); |
| 145 | 153 | } |
| 146 | 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 | + |
| 147 | 178 | function ModeToggle({ |
| 148 | 179 | mode, |
| 149 | 180 | onToggle, |
@@ -183,15 +214,27 @@ function ModeToggle({ |
| 183 | 214 | function Shell({ |
| 184 | 215 | subtitle, |
| 185 | 216 | right, |
| 217 | + loading, |
| 186 | 218 | children, |
| 187 | 219 | }: { |
| 188 | 220 | subtitle: string; |
| 189 | 221 | right?: React.ReactNode; |
| 222 | + loading: boolean; |
| 190 | 223 | children: React.ReactNode; |
| 191 | 224 | }) { |
| 192 | 225 | return ( |
| 193 | 226 | <div className="flex h-full flex-col overflow-hidden"> |
| 194 | 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 | + )} |
| 195 | 238 | <div className="min-h-0 flex-1 overflow-hidden">{children}</div> |
| 196 | 239 | </div> |
| 197 | 240 | ); |