diff options
| author | bndw <ben@bdw.to> | 2026-03-01 08:10:35 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-03-01 08:10:35 -0800 |
| commit | afdf3d57cb7ae4cbf0a519d1b53872f151ecba87 (patch) | |
| tree | 601cf9cfa293c2287d6f4fc0aefefd37c99a29b7 /renderer | |
| parent | 454453788e759fa16442e755434fbb842fa1acab (diff) | |
feat: Add `activityStatus` state and `handleCancel` to App.ts… (+4 more)
- ✅ Add `activityStatus` state and `handleCancel` to App.tsx; update Escape handler
- ✅ Extend `onClaudeMessage` handler: tool_progress, task_progress, restructure result block
- ✅ Pass `activityStatus` and `onCancel` props to `<ChatPane>` in App.tsx
- ✅ Update ChatPane.tsx props interface and loading bubble render
- ✅ Add loading bubble CSS (flex layout, cancel × styles, pulse animation)
Diffstat (limited to 'renderer')
| -rw-r--r-- | renderer/src/App.tsx | 105 | ||||
| -rw-r--r-- | renderer/src/components/ChatPane.tsx | 17 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 34 |
3 files changed, 123 insertions, 33 deletions
diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index c3eafd4..36d3a82 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx | |||
| @@ -52,6 +52,7 @@ export function App() { | |||
| 52 | const [documentContent, setDocumentContent] = useState<string>(""); | 52 | const [documentContent, setDocumentContent] = useState<string>(""); |
| 53 | const [originalContent, setOriginalContent] = useState<string>(""); | 53 | const [originalContent, setOriginalContent] = useState<string>(""); |
| 54 | const [isLoading, setIsLoading] = useState(false); | 54 | const [isLoading, setIsLoading] = useState(false); |
| 55 | const [activityStatus, setActivityStatus] = useState<string | null>(null); | ||
| 55 | const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ | 56 | const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ |
| 56 | inputTokens: 0, | 57 | inputTokens: 0, |
| 57 | outputTokens: 0, | 58 | outputTokens: 0, |
| @@ -84,6 +85,13 @@ export function App() { | |||
| 84 | localStorage.setItem("cf-chat-collapsed", String(chatCollapsed)); | 85 | localStorage.setItem("cf-chat-collapsed", String(chatCollapsed)); |
| 85 | }, [chatCollapsed]); | 86 | }, [chatCollapsed]); |
| 86 | 87 | ||
| 88 | const handleCancel = () => { | ||
| 89 | if (!selectedSession) return; | ||
| 90 | api.interruptSession(selectedSession.id); | ||
| 91 | setIsLoading(false); | ||
| 92 | setActivityStatus(null); | ||
| 93 | }; | ||
| 94 | |||
| 87 | const handleToggleTheme = () => | 95 | const handleToggleTheme = () => |
| 88 | setTheme((t) => (t === "dark" ? "light" : "dark")); | 96 | setTheme((t) => (t === "dark" ? "light" : "dark")); |
| 89 | 97 | ||
| @@ -121,6 +129,7 @@ export function App() { | |||
| 121 | if (e.key === "Escape" && isLoading && selectedSession) { | 129 | if (e.key === "Escape" && isLoading && selectedSession) { |
| 122 | api.interruptSession(selectedSession.id); | 130 | api.interruptSession(selectedSession.id); |
| 123 | setIsLoading(false); | 131 | setIsLoading(false); |
| 132 | setActivityStatus(null); | ||
| 124 | } | 133 | } |
| 125 | // Cmd/Ctrl + Enter to submit | 134 | // Cmd/Ctrl + Enter to submit |
| 126 | if ( | 135 | if ( |
| @@ -178,43 +187,73 @@ export function App() { | |||
| 178 | const unsubscribe = api.onClaudeMessage((sessionId, msg) => { | 187 | const unsubscribe = api.onClaudeMessage((sessionId, msg) => { |
| 179 | if (sessionId !== selectedSession?.id) return; | 188 | if (sessionId !== selectedSession?.id) return; |
| 180 | 189 | ||
| 181 | if (msg.type === "result" && msg.subtype === "success") { | 190 | // ── Live activity indicator ────────────────────────────────────── |
| 191 | // Primary: tool_progress fires periodically while a tool is running | ||
| 192 | if (msg.type === "tool_progress") { | ||
| 193 | const elapsed = Math.round(msg.elapsed_time_seconds); | ||
| 194 | setActivityStatus( | ||
| 195 | elapsed > 0 | ||
| 196 | ? `Using ${msg.tool_name} (${elapsed}s)` | ||
| 197 | : `Using ${msg.tool_name}…` | ||
| 198 | ); | ||
| 199 | } | ||
| 200 | |||
| 201 | // Secondary: task_progress carries last_tool_name for sub-agent tasks | ||
| 202 | if ( | ||
| 203 | msg.type === "system" && | ||
| 204 | msg.subtype === "task_progress" && | ||
| 205 | (msg as { last_tool_name?: string }).last_tool_name | ||
| 206 | ) { | ||
| 207 | setActivityStatus( | ||
| 208 | `Using ${(msg as { last_tool_name: string }).last_tool_name}…` | ||
| 209 | ); | ||
| 210 | } | ||
| 211 | |||
| 212 | // ── Result (success or error) ──────────────────────────────────── | ||
| 213 | // Always clear loading state on any result subtype so error results | ||
| 214 | // don't leave the UI stuck in the loading/thinking state. | ||
| 215 | if (msg.type === "result") { | ||
| 182 | setIsLoading(false); | 216 | setIsLoading(false); |
| 183 | if (msg.usage) { | 217 | setActivityStatus(null); |
| 184 | setTokenUsage({ | 218 | |
| 185 | inputTokens: msg.usage.input_tokens, | 219 | if (msg.subtype === "success") { |
| 186 | outputTokens: msg.usage.output_tokens, | 220 | if (msg.usage) { |
| 187 | cacheHits: msg.usage.cache_read_input_tokens, | 221 | setTokenUsage({ |
| 188 | }); | 222 | inputTokens: msg.usage.input_tokens, |
| 189 | } | 223 | outputTokens: msg.usage.output_tokens, |
| 190 | // Reload artifact after Claude updates it | 224 | cacheHits: msg.usage.cache_read_input_tokens, |
| 191 | if (selectedProject && selectedSession) { | ||
| 192 | const filename = | ||
| 193 | selectedSession.phase === "research" ? "research.md" : "plan.md"; | ||
| 194 | // Capture for the async closure | ||
| 195 | const sessionAtTrigger = selectedSession; | ||
| 196 | api | ||
| 197 | .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) | ||
| 198 | .then((content) => { | ||
| 199 | const text = content || ""; | ||
| 200 | setDocumentContent(text); | ||
| 201 | setOriginalContent(text); | ||
| 202 | |||
| 203 | // Auto-name: only during research, only while name is still the default | ||
| 204 | if ( | ||
| 205 | sessionAtTrigger.phase === "research" && | ||
| 206 | /^Session \d+$/.test(sessionAtTrigger.name) && | ||
| 207 | text.length > 0 | ||
| 208 | ) { | ||
| 209 | const derived = extractSessionName(text); | ||
| 210 | if (derived) { | ||
| 211 | handleRenameSession(sessionAtTrigger.id, derived); | ||
| 212 | } | ||
| 213 | } | ||
| 214 | }); | 225 | }); |
| 226 | } | ||
| 227 | // Reload artifact after Claude updates it | ||
| 228 | if (selectedProject && selectedSession) { | ||
| 229 | const filename = | ||
| 230 | selectedSession.phase === "research" ? "research.md" : "plan.md"; | ||
| 231 | // Capture for the async closure | ||
| 232 | const sessionAtTrigger = selectedSession; | ||
| 233 | api | ||
| 234 | .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) | ||
| 235 | .then((content) => { | ||
| 236 | const text = content || ""; | ||
| 237 | setDocumentContent(text); | ||
| 238 | setOriginalContent(text); | ||
| 239 | |||
| 240 | // Auto-name: only during research, only while name is still the default | ||
| 241 | if ( | ||
| 242 | sessionAtTrigger.phase === "research" && | ||
| 243 | /^Session \d+$/.test(sessionAtTrigger.name) && | ||
| 244 | text.length > 0 | ||
| 245 | ) { | ||
| 246 | const derived = extractSessionName(text); | ||
| 247 | if (derived) { | ||
| 248 | handleRenameSession(sessionAtTrigger.id, derived); | ||
| 249 | } | ||
| 250 | } | ||
| 251 | }); | ||
| 252 | } | ||
| 215 | } | 253 | } |
| 216 | } | 254 | } |
| 217 | 255 | ||
| 256 | // ── Assistant message ──────────────────────────────────────────── | ||
| 218 | if (msg.type === "assistant") { | 257 | if (msg.type === "assistant") { |
| 219 | const content = ( | 258 | const content = ( |
| 220 | msg.message.content as Array<{ type: string; text?: string }> | 259 | msg.message.content as Array<{ type: string; text?: string }> |
| @@ -432,6 +471,8 @@ export function App() { | |||
| 432 | collapsed={chatCollapsed} | 471 | collapsed={chatCollapsed} |
| 433 | chatWidth={chatWidth} | 472 | chatWidth={chatWidth} |
| 434 | onToggleCollapse={() => setChatCollapsed((c) => !c)} | 473 | onToggleCollapse={() => setChatCollapsed((c) => !c)} |
| 474 | activityStatus={activityStatus} | ||
| 475 | onCancel={handleCancel} | ||
| 435 | /> | 476 | /> |
| 436 | </div> | 477 | </div> |
| 437 | 478 | ||
diff --git a/renderer/src/components/ChatPane.tsx b/renderer/src/components/ChatPane.tsx index 40e682c..c2cdfbb 100644 --- a/renderer/src/components/ChatPane.tsx +++ b/renderer/src/components/ChatPane.tsx | |||
| @@ -10,6 +10,8 @@ interface ChatPaneProps { | |||
| 10 | collapsed: boolean; | 10 | collapsed: boolean; |
| 11 | chatWidth: number; | 11 | chatWidth: number; |
| 12 | onToggleCollapse: () => void; | 12 | onToggleCollapse: () => void; |
| 13 | activityStatus?: string | null; | ||
| 14 | onCancel?: () => void; | ||
| 13 | } | 15 | } |
| 14 | 16 | ||
| 15 | export function ChatPane({ | 17 | export function ChatPane({ |
| @@ -21,6 +23,8 @@ export function ChatPane({ | |||
| 21 | collapsed, | 23 | collapsed, |
| 22 | chatWidth, | 24 | chatWidth, |
| 23 | onToggleCollapse, | 25 | onToggleCollapse, |
| 26 | activityStatus, | ||
| 27 | onCancel, | ||
| 24 | }: ChatPaneProps) { | 28 | }: ChatPaneProps) { |
| 25 | const [input, setInput] = useState(""); | 29 | const [input, setInput] = useState(""); |
| 26 | const messagesEndRef = useRef<HTMLDivElement>(null); | 30 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| @@ -57,7 +61,18 @@ export function ChatPane({ | |||
| 57 | ))} | 61 | ))} |
| 58 | {isLoading && ( | 62 | {isLoading && ( |
| 59 | <div className="message assistant loading"> | 63 | <div className="message assistant loading"> |
| 60 | <div className="message-content">Thinking...</div> | 64 | <div className="message-content"> |
| 65 | <span>{activityStatus ?? "Thinking…"}</span> | ||
| 66 | {onCancel && ( | ||
| 67 | <button | ||
| 68 | className="loading-cancel-x" | ||
| 69 | onClick={onCancel} | ||
| 70 | title="Cancel (Esc)" | ||
| 71 | > | ||
| 72 | × | ||
| 73 | </button> | ||
| 74 | )} | ||
| 75 | </div> | ||
| 61 | </div> | 76 | </div> |
| 62 | )} | 77 | )} |
| 63 | <div ref={messagesEndRef} /> | 78 | <div ref={messagesEndRef} /> |
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css index 03c7443..7a8ae45 100644 --- a/renderer/src/styles/globals.css +++ b/renderer/src/styles/globals.css | |||
| @@ -490,6 +490,40 @@ html[data-theme="light"] .session-rename-input { | |||
| 490 | .message.loading { | 490 | .message.loading { |
| 491 | color: var(--text-secondary); | 491 | color: var(--text-secondary); |
| 492 | font-style: italic; | 492 | font-style: italic; |
| 493 | animation: thinking-pulse 1.8s ease-in-out infinite; | ||
| 494 | } | ||
| 495 | |||
| 496 | /* Flex row so the activity text and cancel × sit on one line */ | ||
| 497 | .message.loading .message-content { | ||
| 498 | display: flex; | ||
| 499 | align-items: center; | ||
| 500 | gap: 6px; | ||
| 501 | white-space: normal; | ||
| 502 | } | ||
| 503 | |||
| 504 | /* Cancel × button inside the Thinking bubble */ | ||
| 505 | .loading-cancel-x { | ||
| 506 | background: transparent; | ||
| 507 | border: none; | ||
| 508 | color: var(--text-secondary); | ||
| 509 | cursor: pointer; | ||
| 510 | font-size: 16px; | ||
| 511 | line-height: 1; | ||
| 512 | padding: 0 2px; | ||
| 513 | opacity: 0.45; | ||
| 514 | flex-shrink: 0; | ||
| 515 | font-family: inherit; | ||
| 516 | transition: opacity 0.15s, color 0.15s; | ||
| 517 | } | ||
| 518 | |||
| 519 | .loading-cancel-x:hover { | ||
| 520 | opacity: 1; | ||
| 521 | color: var(--text-primary); | ||
| 522 | } | ||
| 523 | |||
| 524 | @keyframes thinking-pulse { | ||
| 525 | 0%, 100% { opacity: 0.55; } | ||
| 526 | 50% { opacity: 1; } | ||
| 493 | } | 527 | } |
| 494 | 528 | ||
| 495 | .chat-input { | 529 | .chat-input { |
