From 1645ab2942e448de9694a5c358508491be0003c7 Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Sat, 21 Mar 2026 14:08:47 +0100 Subject: [PATCH 1/4] Move thinking indicator inline into message row Replace the floating ThinkingIndicator overlay with an inline indicator rendered directly inside each streaming assistant message. Shows animated "Thinking..." dots with an elapsed seconds counter, then transitions to "Worked for Xs" when the run completes. --- app/page.tsx | 2 -- components/MessageRow.tsx | 29 +++++++++++++++++++++++++++++ components/chat/ChatViewport.tsx | 12 ------------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index cf8eb97..a2d3b93 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -787,8 +787,6 @@ export default forwardRef(function Home(_props, forwardedRef) { onUnpin={handleUnpinSubagent} zenMode={zenMode} isRunActive={isRunActive} - thinkingStartTime={thinkingStartTime} - thinkingLabel={thinkingLabel} quotePopup={quotePopup} quotePopupRef={quotePopupRef} onAcceptQuote={handleAcceptQuote} diff --git a/components/MessageRow.tsx b/components/MessageRow.tsx index 08ee48d..fe6512a 100644 --- a/components/MessageRow.tsx +++ b/components/MessageRow.tsx @@ -426,6 +426,32 @@ function getAssistantDurationText(message: Message): string | null { return null; } +function InlineThinkingIndicator({ startTime }: { startTime?: number }) { + const [elapsed, setElapsed] = useState(() => + startTime ? Math.floor((Date.now() - startTime) / 1000) : 0, + ); + + useEffect(() => { + if (!startTime) return; + const id = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTime) / 1000)); + }, 1000); + return () => clearInterval(id); + }, [startTime]); + + return ( +
+ Thinking + + . + . + . + + {elapsed > 0 && {elapsed}s} +
+ ); +} + function AssistantCopyButton({ text, durationText }: { text: string; durationText?: string | null }) { const [copied, setCopied] = useState(false); const [mounted, setMounted] = useState(false); @@ -976,6 +1002,9 @@ export function MessageRow({ : undefined} > {assistantBlocks.map(renderAssistantBlock)} + {isStreaming && message.role === "assistant" && ( + + )} {showAssistantCopyButton ? : null} diff --git a/components/chat/ChatViewport.tsx b/components/chat/ChatViewport.tsx index 17d7af8..76663de 100644 --- a/components/chat/ChatViewport.tsx +++ b/components/chat/ChatViewport.tsx @@ -3,12 +3,10 @@ import React, { useMemo, useState, useEffect, useLayoutEffect, useCallback, useRef } from "react"; import { MessageRow } from "@/components/MessageRow"; -import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ZenToggle } from "@/components/ZenToggle"; import { formatMessageTime, getMessageSide } from "@/lib/messageUtils"; import { STOP_REASON_INJECTED, isToolCallPart, MESSAGE_SEND_ANIMATION } from "@/lib/constants"; import { ZEN_SLIDE_MS, ZEN_FADE_MS, ZEN_TOGGLE_FRAME_MS } from "@/lib/chat/zenUi"; -import { getThinkingIndicatorBottom } from "@/lib/chat/layout"; import type { Message } from "@/types/chat"; import type { useSubagentStore } from "@/hooks/useSubagentStore"; import type { PluginActionHandler } from "@/lib/plugins/types"; @@ -48,8 +46,6 @@ interface ChatViewportProps { onUnpin: () => void; zenMode: boolean; isRunActive: boolean; - thinkingStartTime: number | null; - thinkingLabel?: string; quotePopup: { x: number; y: number; text: string } | null; quotePopupRef: React.RefObject; onAcceptQuote: (text: string) => void; @@ -81,15 +77,12 @@ export function ChatViewport({ onUnpin, zenMode, isRunActive, - thinkingStartTime, - thinkingLabel, quotePopup, quotePopupRef, onAcceptQuote, onPluginAction, }: ChatViewportProps) { const detachedShell = isDetached && !detachedNoBorder; - const thinkingIndicatorBottom = getThinkingIndicatorBottom({ isDetached, inputZoneHeight }); const [zenRenderMode, setZenRenderMode] = useState(zenMode); const [expandedZenGroups, setExpandedZenGroups] = useState>({}); const [collapsingZenGroups, setCollapsingZenGroups] = useState>({}); @@ -847,11 +840,6 @@ export function ChatViewport({
-
-
- -
-
{isDetached && !isNative &&
} From 0910148102e22d49d4d2c621c659563ff8e2fa8c Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Sat, 21 Mar 2026 14:19:24 +0100 Subject: [PATCH 2/4] Address inline thinking review feedback and fix build CI --- app/page.tsx | 4 ---- components/MessageRow.tsx | 13 ++---------- components/ThinkingIndicator.tsx | 34 ++---------------------------- components/chat/ChatViewport.tsx | 2 -- hooks/useElapsedSeconds.ts | 36 ++++++++++++++++++++++++++++++++ 5 files changed, 40 insertions(+), 49 deletions(-) create mode 100644 hooks/useElapsedSeconds.ts diff --git a/app/page.tsx b/app/page.tsx index a2d3b93..37b5851 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -99,7 +99,6 @@ export default forwardRef(function Home(_props, forwardedRef) { const { awaitingResponse, setAwaitingResponse, - thinkingStartTime, setThinkingStartTime, beginContentArrival, resetThinkingState, @@ -523,8 +522,6 @@ export default forwardRef(function Home(_props, forwardedRef) { ? { text: queuedMessage.text, attachments: queuedMessage.attachments as unknown[] | undefined } : null; - const thinkingLabel = isRunActive && lastCommand === "/compact" ? "Compacting" : undefined; - useEffect(() => { const save = () => { try { sessionStorage.setItem("mc-run-active", isRunActive ? "1" : "0"); } catch {} @@ -786,7 +783,6 @@ export default forwardRef(function Home(_props, forwardedRef) { onPin={handlePinSubagent} onUnpin={handleUnpinSubagent} zenMode={zenMode} - isRunActive={isRunActive} quotePopup={quotePopup} quotePopupRef={quotePopupRef} onAcceptQuote={handleAcceptQuote} diff --git a/components/MessageRow.tsx b/components/MessageRow.tsx index fe6512a..5f317f4 100644 --- a/components/MessageRow.tsx +++ b/components/MessageRow.tsx @@ -5,6 +5,7 @@ import type { ContentPart, Message } from "@/types/chat"; import { getTextFromContent, getImages, getFiles } from "@/lib/messageUtils"; import { HEARTBEAT_MARKER, NO_REPLY_MARKER, SYSTEM_PREFIX, SYSTEM_MESSAGE_PREFIX, STOP_REASON_INJECTED, isToolCallPart, SPAWN_TOOL_NAME, hasUnquotedMarker, hasHeartbeatOnOwnLine, SQUIRCLE_RADIUS, MESSAGE_SEND_ANIMATION } from "@/lib/constants"; import { useExpandablePanel } from "@/hooks/useExpandablePanel"; +import { useElapsedSeconds } from "@/hooks/useElapsedSeconds"; import { SlideContent } from "@/components/SlideContent"; import { MarkdownContent } from "@/components/markdown/MarkdownContent"; import { StreamingText } from "@/components/StreamingText"; @@ -427,17 +428,7 @@ function getAssistantDurationText(message: Message): string | null { } function InlineThinkingIndicator({ startTime }: { startTime?: number }) { - const [elapsed, setElapsed] = useState(() => - startTime ? Math.floor((Date.now() - startTime) / 1000) : 0, - ); - - useEffect(() => { - if (!startTime) return; - const id = setInterval(() => { - setElapsed(Math.floor((Date.now() - startTime) / 1000)); - }, 1000); - return () => clearInterval(id); - }, [startTime]); + const elapsed = useElapsedSeconds({ startTime }); return (
diff --git a/components/ThinkingIndicator.tsx b/components/ThinkingIndicator.tsx index 64fe7ad..f7d99da 100644 --- a/components/ThinkingIndicator.tsx +++ b/components/ThinkingIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useElapsedSeconds } from "@/hooks/useElapsedSeconds"; interface ThinkingIndicatorProps { visible: boolean; @@ -9,37 +9,7 @@ interface ThinkingIndicatorProps { } export function ThinkingIndicator({ visible, startTime, label }: ThinkingIndicatorProps) { - const [elapsedSeconds, setElapsedSeconds] = useState(0); - const timerRef = useRef | null>(null); - - useEffect(() => { - if (!visible) setElapsedSeconds(0); - }, [visible]); - - // Update elapsed time every second - useEffect(() => { - if (!startTime || !visible) { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - return; - } - - const updateElapsed = () => { - setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000)); - }; - updateElapsed(); - - timerRef.current = setInterval(updateElapsed, 1000); - - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, [startTime, visible]); + const elapsedSeconds = useElapsedSeconds({ startTime, active: visible }); const isCompacting = label === "Compacting"; const displayLabel = label || "Thinking"; diff --git a/components/chat/ChatViewport.tsx b/components/chat/ChatViewport.tsx index 76663de..232a3bc 100644 --- a/components/chat/ChatViewport.tsx +++ b/components/chat/ChatViewport.tsx @@ -45,7 +45,6 @@ interface ChatViewportProps { }) => void; onUnpin: () => void; zenMode: boolean; - isRunActive: boolean; quotePopup: { x: number; y: number; text: string } | null; quotePopupRef: React.RefObject; onAcceptQuote: (text: string) => void; @@ -76,7 +75,6 @@ export function ChatViewport({ onPin, onUnpin, zenMode, - isRunActive, quotePopup, quotePopupRef, onAcceptQuote, diff --git a/hooks/useElapsedSeconds.ts b/hooks/useElapsedSeconds.ts new file mode 100644 index 0000000..15810c3 --- /dev/null +++ b/hooks/useElapsedSeconds.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface UseElapsedSecondsOptions { + startTime?: number; + active?: boolean; +} + +/** + * Tracks elapsed whole seconds from a start timestamp while active. + * Resets to 0 when inactive or no start time is provided. + */ +export function useElapsedSeconds({ startTime, active = true }: UseElapsedSecondsOptions): number { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!startTime || !active) { + setElapsed(0); + return; + } + + const updateElapsed = () => { + setElapsed(Math.floor((Date.now() - startTime) / 1000)); + }; + + updateElapsed(); + const timerId = window.setInterval(updateElapsed, 1000); + + return () => { + window.clearInterval(timerId); + }; + }, [active, startTime]); + + return elapsed; +} From a780ca2b86ade1a129f3827871e9b57752660a34 Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Sat, 21 Mar 2026 14:24:59 +0100 Subject: [PATCH 3/4] Keep thinking chevron inline with collapsed text --- components/MessageRow.tsx | 36 +++++++++++++++++++----------------- tests/MessageRow.test.tsx | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/components/MessageRow.tsx b/components/MessageRow.tsx index 5f317f4..df757fd 100644 --- a/components/MessageRow.tsx +++ b/components/MessageRow.tsx @@ -327,26 +327,28 @@ function ThinkingPill({ text, isStreaming }: { text: string; isStreaming?: boole key={startIdx + i} className="whitespace-pre-wrap break-words overflow-hidden animate-[thinkingSentence_0.5s_ease-out_both]" > - {sentence} + {sentence} + {i === visible.length - 1 && ( + + {isStreaming && ( + + . + . + . + + )} + + + + + )}

))}
-
- {isStreaming && ( - - . - . - . - - )} - - - -

{displayText}

diff --git a/tests/MessageRow.test.tsx b/tests/MessageRow.test.tsx index 6835114..74d1543 100644 --- a/tests/MessageRow.test.tsx +++ b/tests/MessageRow.test.tsx @@ -308,6 +308,22 @@ describe("MessageRow", () => { }); }); + it("renders the collapsed thinking chevron inline with the last visible sentence", () => { + const message: Message = { + role: "assistant", + content: [], + reasoning: "One.\nTwo.\nThree.\nFour.\nFive.", + id: "test-thinking-inline-chevron", + }; + + render(); + + const lastVisibleSentence = screen.getByText("Five."); + const sentenceLine = lastVisibleSentence.closest("p"); + expect(sentenceLine).not.toBeNull(); + expect(sentenceLine?.querySelector("svg")).not.toBeNull(); + }); + it("unwraps markdown-style underscore emphasis in thinking blocks", () => { const message: Message = { role: "assistant", From 15d987a109ff395d4add1596a3f4b68d7d1e653a Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Sat, 21 Mar 2026 14:30:30 +0100 Subject: [PATCH 4/4] Fix CI typecheck for ChatViewport zen tests --- tests/ChatViewport.zen.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/ChatViewport.zen.test.tsx b/tests/ChatViewport.zen.test.tsx index 4fed6b0..ef5f0fa 100644 --- a/tests/ChatViewport.zen.test.tsx +++ b/tests/ChatViewport.zen.test.tsx @@ -38,8 +38,6 @@ function renderViewport( onPin={() => {}} onUnpin={() => {}} zenMode={zenMode} - isRunActive={false} - thinkingStartTime={null} quotePopup={null} quotePopupRef={React.createRef()} onAcceptQuote={() => {}} @@ -178,8 +176,6 @@ describe("ChatViewport zen grouping", () => { onPin={() => {}} onUnpin={() => {}} zenMode - isRunActive={false} - thinkingStartTime={null} quotePopup={null} quotePopupRef={React.createRef()} onAcceptQuote={() => {}}