TypeScript · 5515 bytes Raw Blame History
1 import { useMemo, useState } from "react";
2
3 import { useSessionStore, type InFlightTurn } from "@/lib/store/sessions";
4
5 interface TurnStatusBannerProps {
6 /** Only turns targeting this session are surfaced. */
7 sessionId: string;
8 }
9
10 /** Renders the most-recent in-flight or terminal-state turn for the
11 * currently-selected session. Surfaces stderr tail, exit code,
12 * "completed with zero output" warning, and failure reasons — the
13 * things that previously left the user wondering whether anything
14 * happened at all.
15 *
16 * Rendered *above* the ChatInput inside ViewerPane. Hidden when
17 * there's no turn state worth showing (idle + no recent failure). */
18 export function TurnStatusBanner({ sessionId }: TurnStatusBannerProps) {
19 const inFlightTurns = useSessionStore((s) => s.inFlightTurns);
20 const [stderrExpanded, setStderrExpanded] = useState(false);
21
22 const turn = useMemo<InFlightTurn | null>(() => {
23 // Prefer an actively-running turn; fall back to the most recent
24 // terminal-state turn for this session.
25 let active: InFlightTurn | null = null;
26 let mostRecentTerminal: InFlightTurn | null = null;
27 for (const t of inFlightTurns.values()) {
28 if (t.sessionId !== sessionId) continue;
29 if (t.status === "spawning" || t.status === "streaming") {
30 active = t;
31 break;
32 }
33 if (
34 (t.status === "completed" ||
35 t.status === "failed" ||
36 t.status === "cancelled") &&
37 (!mostRecentTerminal || t.startedAt > mostRecentTerminal.startedAt)
38 ) {
39 mostRecentTerminal = t;
40 }
41 }
42 return active ?? mostRecentTerminal;
43 }, [inFlightTurns, sessionId]);
44
45 if (!turn) return null;
46
47 // Nothing to surface for a silent in-flight streaming turn — the
48 // streaming indicator on the assistant card is enough.
49 if (turn.status === "streaming" && turn.assistantEventCount > 0) {
50 return null;
51 }
52
53 const tone = pickTone(turn);
54 const label = pickLabel(turn);
55
56 return (
57 <div
58 className={`shrink-0 border-b border-t ${tone.border} ${tone.bg} px-4 py-2 text-[11px] ${tone.text}`}
59 >
60 <div className="mx-auto flex max-w-4xl flex-col gap-1">
61 <div className="flex items-center gap-2">
62 <span className={`font-semibold uppercase tracking-wide ${tone.labelText}`}>
63 {label}
64 </span>
65 {turn.exitCode !== null && (
66 <span className="rounded bg-bg-3 px-1.5 py-0.5 font-mono text-fg-3">
67 exit {turn.exitCode}
68 </span>
69 )}
70 <span className="ml-auto font-mono text-fg-3">
71 turn {turn.turnId.slice(0, 8)}
72 </span>
73 </div>
74 {turn.error && (
75 <div className="font-mono text-fg-2">{turn.error}</div>
76 )}
77 {turn.status === "completed" && turn.assistantEventCount === 0 && (
78 <div className="text-fg-2">
79 claude exited {turn.exitCode === 0 ? "cleanly" : `with code ${turn.exitCode}`} but
80 wrote no assistant output. This usually means: invalid flags, a
81 blocked dangerous tool under the current permission mode, a
82 credit/quota issue, or the resumed session being too large to
83 context-load. Expand stderr below for details.
84 </div>
85 )}
86 {turn.stderrTail.length > 0 && (
87 <div>
88 <button
89 type="button"
90 onClick={() => setStderrExpanded((x) => !x)}
91 className={`font-mono ${tone.disclosureText} hover:underline`}
92 >
93 {stderrExpanded ? "▾" : "▸"} stderr ({turn.stderrTail.length}{" "}
94 line{turn.stderrTail.length === 1 ? "" : "s"})
95 </button>
96 {stderrExpanded && (
97 <pre className="mt-1 max-h-48 overflow-auto rounded border border-border bg-bg-2 p-2 font-mono text-[10px] text-fg-2">
98 {turn.stderrTail.join("\n")}
99 </pre>
100 )}
101 </div>
102 )}
103 </div>
104 </div>
105 );
106 }
107
108 interface Tone {
109 border: string;
110 bg: string;
111 text: string;
112 labelText: string;
113 disclosureText: string;
114 }
115
116 function pickTone(turn: InFlightTurn): Tone {
117 if (turn.status === "failed") {
118 return {
119 border: "border-red-900/60",
120 bg: "bg-red-950/30",
121 text: "text-red-200",
122 labelText: "text-red-300",
123 disclosureText: "text-red-300",
124 };
125 }
126 if (turn.status === "cancelled") {
127 return {
128 border: "border-yellow-900/60",
129 bg: "bg-yellow-950/20",
130 text: "text-yellow-200",
131 labelText: "text-yellow-300",
132 disclosureText: "text-yellow-300",
133 };
134 }
135 if (turn.status === "completed" && turn.assistantEventCount === 0) {
136 return {
137 border: "border-yellow-900/60",
138 bg: "bg-yellow-950/20",
139 text: "text-yellow-200",
140 labelText: "text-yellow-300",
141 disclosureText: "text-yellow-300",
142 };
143 }
144 // spawning / streaming — informational
145 return {
146 border: "border-border/60",
147 bg: "bg-bg-1/60",
148 text: "text-fg-2",
149 labelText: "text-fg-2",
150 disclosureText: "text-fg-2",
151 };
152 }
153
154 function pickLabel(turn: InFlightTurn): string {
155 switch (turn.status) {
156 case "spawning":
157 return "launching claude…";
158 case "streaming":
159 return "streaming";
160 case "completed":
161 return turn.assistantEventCount === 0
162 ? "completed with no output"
163 : "completed";
164 case "failed":
165 return "turn failed";
166 case "cancelled":
167 return "cancelled";
168 }
169 }