From afdf3d57cb7ae4cbf0a519d1b53872f151ecba87 Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 1 Mar 2026 08:10:35 -0800 Subject: feat: Add `activityStatus` state and `handleCancel` to App.ts… (+4 more) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ✅ 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 `` in App.tsx - ✅ Update ChatPane.tsx props interface and loading bubble render - ✅ Add loading bubble CSS (flex layout, cancel × styles, pulse animation) --- renderer/src/App.tsx | 105 ++++++++++++++++++++++++----------- renderer/src/components/ChatPane.tsx | 17 +++++- renderer/src/styles/globals.css | 34 ++++++++++++ 3 files changed, 123 insertions(+), 33 deletions(-) (limited to 'renderer') 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() { const [documentContent, setDocumentContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [activityStatus, setActivityStatus] = useState(null); const [tokenUsage, setTokenUsage] = useState({ inputTokens: 0, outputTokens: 0, @@ -84,6 +85,13 @@ export function App() { localStorage.setItem("cf-chat-collapsed", String(chatCollapsed)); }, [chatCollapsed]); + const handleCancel = () => { + if (!selectedSession) return; + api.interruptSession(selectedSession.id); + setIsLoading(false); + setActivityStatus(null); + }; + const handleToggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark")); @@ -121,6 +129,7 @@ export function App() { if (e.key === "Escape" && isLoading && selectedSession) { api.interruptSession(selectedSession.id); setIsLoading(false); + setActivityStatus(null); } // Cmd/Ctrl + Enter to submit if ( @@ -178,43 +187,73 @@ export function App() { const unsubscribe = api.onClaudeMessage((sessionId, msg) => { if (sessionId !== selectedSession?.id) return; - if (msg.type === "result" && msg.subtype === "success") { + // ── Live activity indicator ────────────────────────────────────── + // Primary: tool_progress fires periodically while a tool is running + if (msg.type === "tool_progress") { + const elapsed = Math.round(msg.elapsed_time_seconds); + setActivityStatus( + elapsed > 0 + ? `Using ${msg.tool_name} (${elapsed}s)` + : `Using ${msg.tool_name}…` + ); + } + + // Secondary: task_progress carries last_tool_name for sub-agent tasks + if ( + msg.type === "system" && + msg.subtype === "task_progress" && + (msg as { last_tool_name?: string }).last_tool_name + ) { + setActivityStatus( + `Using ${(msg as { last_tool_name: string }).last_tool_name}…` + ); + } + + // ── Result (success or error) ──────────────────────────────────── + // Always clear loading state on any result subtype so error results + // don't leave the UI stuck in the loading/thinking state. + if (msg.type === "result") { setIsLoading(false); - if (msg.usage) { - setTokenUsage({ - inputTokens: msg.usage.input_tokens, - outputTokens: msg.usage.output_tokens, - cacheHits: msg.usage.cache_read_input_tokens, - }); - } - // Reload artifact after Claude updates it - if (selectedProject && selectedSession) { - const filename = - selectedSession.phase === "research" ? "research.md" : "plan.md"; - // Capture for the async closure - const sessionAtTrigger = selectedSession; - api - .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) - .then((content) => { - const text = content || ""; - setDocumentContent(text); - setOriginalContent(text); - - // Auto-name: only during research, only while name is still the default - if ( - sessionAtTrigger.phase === "research" && - /^Session \d+$/.test(sessionAtTrigger.name) && - text.length > 0 - ) { - const derived = extractSessionName(text); - if (derived) { - handleRenameSession(sessionAtTrigger.id, derived); - } - } + setActivityStatus(null); + + if (msg.subtype === "success") { + if (msg.usage) { + setTokenUsage({ + inputTokens: msg.usage.input_tokens, + outputTokens: msg.usage.output_tokens, + cacheHits: msg.usage.cache_read_input_tokens, }); + } + // Reload artifact after Claude updates it + if (selectedProject && selectedSession) { + const filename = + selectedSession.phase === "research" ? "research.md" : "plan.md"; + // Capture for the async closure + const sessionAtTrigger = selectedSession; + api + .readSessionArtifact(selectedProject.id, sessionAtTrigger.id, filename) + .then((content) => { + const text = content || ""; + setDocumentContent(text); + setOriginalContent(text); + + // Auto-name: only during research, only while name is still the default + if ( + sessionAtTrigger.phase === "research" && + /^Session \d+$/.test(sessionAtTrigger.name) && + text.length > 0 + ) { + const derived = extractSessionName(text); + if (derived) { + handleRenameSession(sessionAtTrigger.id, derived); + } + } + }); + } } } + // ── Assistant message ──────────────────────────────────────────── if (msg.type === "assistant") { const content = ( msg.message.content as Array<{ type: string; text?: string }> @@ -432,6 +471,8 @@ export function App() { collapsed={chatCollapsed} chatWidth={chatWidth} onToggleCollapse={() => setChatCollapsed((c) => !c)} + activityStatus={activityStatus} + onCancel={handleCancel} /> 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 { collapsed: boolean; chatWidth: number; onToggleCollapse: () => void; + activityStatus?: string | null; + onCancel?: () => void; } export function ChatPane({ @@ -21,6 +23,8 @@ export function ChatPane({ collapsed, chatWidth, onToggleCollapse, + activityStatus, + onCancel, }: ChatPaneProps) { const [input, setInput] = useState(""); const messagesEndRef = useRef(null); @@ -57,7 +61,18 @@ export function ChatPane({ ))} {isLoading && (
-
Thinking...
+
+ {activityStatus ?? "Thinking…"} + {onCancel && ( + + )} +
)}
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 { .message.loading { color: var(--text-secondary); font-style: italic; + animation: thinking-pulse 1.8s ease-in-out infinite; +} + +/* Flex row so the activity text and cancel × sit on one line */ +.message.loading .message-content { + display: flex; + align-items: center; + gap: 6px; + white-space: normal; +} + +/* Cancel × button inside the Thinking bubble */ +.loading-cancel-x { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 2px; + opacity: 0.45; + flex-shrink: 0; + font-family: inherit; + transition: opacity 0.15s, color 0.15s; +} + +.loading-cancel-x:hover { + opacity: 1; + color: var(--text-primary); +} + +@keyframes thinking-pulse { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } } .chat-input { -- cgit v1.2.3