Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@
const {
awaitingResponse,
setAwaitingResponse,
thinkingStartTime,
setThinkingStartTime,
beginContentArrival,
resetThinkingState,
Expand Down Expand Up @@ -152,7 +151,7 @@
}
}
} catch {}
}, []);

Check warning on line 154 in app/page.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'isNativeRef'. Either include it or remove the dependency array

const handlePinSubagent = useCallback((info: { toolCallId: string | null; childSessionKey: string | null; taskName: string; model: string | null }) => {
setPinnedSubagent((prev) => {
Expand Down Expand Up @@ -248,7 +247,7 @@
const preview = getTextFromContent(msg.content);
if (hasHeartbeatOnOwnLine(preview) || hasUnquotedMarker(preview, NO_REPLY_MARKER)) return;
notifyMessageComplete(preview);
}, []);

Check warning on line 250 in app/page.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useCallback has a missing dependency: 'isDetachedRef'. Either include it or remove the dependency array

const [turnstileVerified, setTurnstileVerified] = useState(!TURNSTILE_SITE_KEY);
const [turnstileChecked, setTurnstileChecked] = useState(!TURNSTILE_SITE_KEY);
Expand Down Expand Up @@ -530,8 +529,6 @@
? { 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 {}
Expand Down Expand Up @@ -793,9 +790,6 @@
onPin={handlePinSubagent}
onUnpin={handleUnpinSubagent}
zenMode={zenMode}
isRunActive={isRunActive}
thinkingStartTime={thinkingStartTime}
thinkingLabel={thinkingLabel}
quotePopup={quotePopup}
quotePopupRef={quotePopupRef}
onAcceptQuote={handleAcceptQuote}
Expand Down
56 changes: 39 additions & 17 deletions components/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
<span>{sentence}</span>
{i === visible.length - 1 && (
<span className="ml-1 inline-flex items-baseline gap-1 align-baseline whitespace-nowrap">
{isStreaming && (
<span className="inline-flex items-baseline gap-0.5 opacity-40">
<span className="animate-[dotFade_1.4s_ease-in-out_infinite]">.</span>
<span className="animate-[dotFade_1.4s_ease-in-out_0.2s_infinite]">.</span>
<span className="animate-[dotFade_1.4s_ease-in-out_0.4s_infinite]">.</span>
</span>
)}
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
className="shrink-0 opacity-60 transition-transform duration-200"
style={{ transform: "rotate(-90deg)" }}
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
)}
</p>
))}
</div>
<div className="flex items-center gap-1 mt-0.5">
{isStreaming && (
<span className="inline-flex items-center gap-0.5 opacity-40">
<span className="animate-[dotFade_1.4s_ease-in-out_infinite]">.</span>
<span className="animate-[dotFade_1.4s_ease-in-out_0.2s_infinite]">.</span>
<span className="animate-[dotFade_1.4s_ease-in-out_0.4s_infinite]">.</span>
</span>
)}
<svg
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
className="shrink-0 opacity-60 transition-transform duration-200"
style={{ transform: "rotate(-90deg)" }}
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</SlideContent>
<SlideContent open={expanded}>
<p className="whitespace-pre-wrap break-words overflow-hidden">{displayText}</p>
Expand Down Expand Up @@ -474,6 +477,22 @@ function getAssistantDurationText(message: Message): string | null {
return null;
}

function InlineThinkingIndicator({ startTime }: { startTime?: number }) {
const elapsed = useElapsedSeconds({ startTime });

return (
<div className="text-2xs text-muted-foreground/50 flex items-baseline animate-[thinkingSentence_0.5s_ease-out_both]">
<span>Thinking</span>
<span className="inline-flex w-[1em]">
<span className="animate-[dotFade_1.4s_ease-in-out_infinite]">.</span>
<span className="animate-[dotFade_1.4s_ease-in-out_0.2s_infinite]">.</span>
<span className="animate-[dotFade_1.4s_ease-in-out_0.4s_infinite]">.</span>
</span>
{elapsed > 0 && <span>{elapsed}s</span>}
</div>
);
}

function AssistantCopyButton({ text, durationText }: { text: string; durationText?: string | null }) {
const [copied, setCopied] = useState(false);
const [mounted, setMounted] = useState(false);
Expand Down Expand Up @@ -1027,6 +1046,9 @@ export function MessageRow({
: undefined}
>
{assistantBlocks.map(renderAssistantBlock)}
{isStreaming && message.role === "assistant" && (
<InlineThinkingIndicator startTime={message.timestamp} />
)}
{showAssistantCopyButton ? <AssistantCopyButton text={assistantCopyText} durationText={assistantDurationText} /> : null}
</div>
</SlideContent>
Expand Down
34 changes: 2 additions & 32 deletions components/ThinkingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState, useRef } from "react";
import { useElapsedSeconds } from "@mc/hooks/useElapsedSeconds";

interface ThinkingIndicatorProps {
visible: boolean;
Expand All @@ -9,37 +9,7 @@ interface ThinkingIndicatorProps {
}

export function ThinkingIndicator({ visible, startTime, label }: ThinkingIndicatorProps) {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | 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";
Expand Down
14 changes: 0 additions & 14 deletions components/chat/ChatViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLButtonElement | null>;
onAcceptQuote: (text: string) => void;
Expand Down Expand Up @@ -81,17 +76,13 @@ export function ChatViewport({
onPin,
onUnpin,
zenMode,
isRunActive,
thinkingStartTime,
thinkingLabel,
quotePopup,
quotePopupRef,
onAcceptQuote,
onPluginAction,
onAddInputAttachment,
}: ChatViewportProps) {
const detachedShell = isDetached && !detachedNoBorder;
const thinkingIndicatorBottom = getThinkingIndicatorBottom({ isDetached, inputZoneHeight });
const [zenRenderMode, setZenRenderMode] = useState(zenMode);
const [expandedZenGroups, setExpandedZenGroups] = useState<Record<string, boolean>>({});
const [collapsingZenGroups, setCollapsingZenGroups] = useState<Record<string, boolean>>({});
Expand Down Expand Up @@ -850,11 +841,6 @@ export function ChatViewport({
<div ref={bottomRef} />
</div>
</main>
<div className="pointer-events-none absolute inset-x-0 px-4 md:px-6" style={{ bottom: thinkingIndicatorBottom }}>
<div className="mx-auto w-full max-w-2xl pl-6">
<ThinkingIndicator visible={isRunActive} startTime={thinkingStartTime ?? undefined} label={thinkingLabel} />
</div>
</div>

{isDetached && !isNative && <div style={{ height: inputZoneHeight, flexShrink: 0 }} />}

Expand Down
36 changes: 36 additions & 0 deletions hooks/useElapsedSeconds.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 0 additions & 4 deletions tests/ChatViewport.zen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ function renderViewport(
onPin={() => {}}
onUnpin={() => {}}
zenMode={zenMode}
isRunActive={false}
thinkingStartTime={null}
quotePopup={null}
quotePopupRef={React.createRef<HTMLButtonElement>()}
onAcceptQuote={() => {}}
Expand Down Expand Up @@ -178,8 +176,6 @@ describe("ChatViewport zen grouping", () => {
onPin={() => {}}
onUnpin={() => {}}
zenMode
isRunActive={false}
thinkingStartTime={null}
quotePopup={null}
quotePopupRef={React.createRef<HTMLButtonElement>()}
onAcceptQuote={() => {}}
Expand Down
16 changes: 16 additions & 0 deletions tests/MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MessageRow message={message} isStreaming={false} />);

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",
Expand Down
Loading