diff --git a/app/page.tsx b/app/page.tsx index 801488b..6a77642 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -100,7 +100,6 @@ export default forwardRef(function Home(_props, forwardedRef) { const { awaitingResponse, setAwaitingResponse, - thinkingStartTime, setThinkingStartTime, beginContentArrival, resetThinkingState, @@ -530,8 +529,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 {} @@ -793,9 +790,6 @@ export default forwardRef(function Home(_props, forwardedRef) { onPin={handlePinSubagent} 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 78d0da9..b35ea01 100644 --- a/components/MessageRow.tsx +++ b/components/MessageRow.tsx @@ -5,6 +5,7 @@ import type { ContentPart, Message } from "@mc/types/chat"; import { getTextFromContent, getImages, getFiles } from "@mc/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 "@mc/lib/constants"; import { useExpandablePanel } from "@mc/hooks/useExpandablePanel"; +import { useElapsedSeconds } from "@mc/hooks/useElapsedSeconds"; import { SlideContent } from "@mc/components/SlideContent"; import { MarkdownContent } from "@mc/components/markdown/MarkdownContent"; import { StreamingText } from "@mc/components/StreamingText"; @@ -348,26 +349,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}

@@ -474,6 +477,22 @@ function getAssistantDurationText(message: Message): string | null { return null; } +function InlineThinkingIndicator({ startTime }: { startTime?: number }) { + const elapsed = useElapsedSeconds({ 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); @@ -1027,6 +1046,9 @@ export function MessageRow({ : undefined} > {assistantBlocks.map(renderAssistantBlock)} + {isStreaming && message.role === "assistant" && ( + + )} {showAssistantCopyButton ? : null}
diff --git a/components/ThinkingIndicator.tsx b/components/ThinkingIndicator.tsx index 64fe7ad..a6f2a86 100644 --- a/components/ThinkingIndicator.tsx +++ b/components/ThinkingIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useElapsedSeconds } from "@mc/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 bef7f74..4ca2cad 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 "@mc/components/MessageRow"; -import { ThinkingIndicator } from "@mc/components/ThinkingIndicator"; import { ZenToggle } from "@mc/components/ZenToggle"; import { formatMessageTime, getMessageSide } from "@mc/lib/messageUtils"; import { STOP_REASON_INJECTED, isToolCallPart, MESSAGE_SEND_ANIMATION } from "@mc/lib/constants"; import { ZEN_SLIDE_MS, ZEN_FADE_MS, ZEN_TOGGLE_FRAME_MS } from "@mc/lib/chat/zenUi"; -import { getThinkingIndicatorBottom } from "@mc/lib/chat/layout"; import type { Message } from "@mc/types/chat"; import type { useSubagentStore } from "@mc/hooks/useSubagentStore"; import type { PluginActionHandler } from "@mc/lib/plugins/types"; @@ -47,9 +45,6 @@ interface ChatViewportProps { }) => void; 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,9 +76,6 @@ export function ChatViewport({ onPin, onUnpin, zenMode, - isRunActive, - thinkingStartTime, - thinkingLabel, quotePopup, quotePopupRef, onAcceptQuote, @@ -91,7 +83,6 @@ export function ChatViewport({ onAddInputAttachment, }: ChatViewportProps) { const detachedShell = isDetached && !detachedNoBorder; - const thinkingIndicatorBottom = getThinkingIndicatorBottom({ isDetached, inputZoneHeight }); const [zenRenderMode, setZenRenderMode] = useState(zenMode); const [expandedZenGroups, setExpandedZenGroups] = useState>({}); const [collapsingZenGroups, setCollapsingZenGroups] = useState>({}); @@ -850,11 +841,6 @@ export function ChatViewport({
-
-
- -
-
{isDetached && !isNative &&
} 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; +} 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={() => {}} diff --git a/tests/MessageRow.test.tsx b/tests/MessageRow.test.tsx index 1c64cb8..018d77b 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",