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
45 changes: 31 additions & 14 deletions app/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -634,20 +634,36 @@
.config-switch {
position: relative;
flex-shrink: 0;
width: 40px;
height: 22px;
width: 32px;
height: 18px;
padding: 0;
border: 0;
border-radius: 11px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 9px;
background: var(--bg-selected);
cursor: pointer;
transition: background 0.18s;
transition: background 0.15s, border-color 0.15s;
}

.config-switch[aria-checked="true"] {
border-color: var(--accent);
background: var(--accent);
}

.config-switch:not(:disabled):hover {
border-color: var(--text-muted);
}

.config-switch[aria-checked="true"]:not(:disabled):hover {
border-color: var(--accent-hover);
background: var(--accent-hover);
}

.config-switch::before {
content: "";
position: absolute;
inset: -5px -3px;
}

.config-switch:disabled {
cursor: default;
opacity: 0.55;
Expand All @@ -659,18 +675,18 @@

.config-switch-knob {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--bg);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.22);
transition: left 0.18s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--text-muted);
transition: transform 0.15s, background 0.15s;
}

.config-switch[aria-checked="true"] .config-switch-knob {
left: 21px;
transform: translateX(14px);
background: var(--bg);
}

.config-switch:focus-visible {
Expand Down Expand Up @@ -1031,6 +1047,7 @@
font-size: 10px;
}

.settings-toggle-option,
.settings-shell-option {
display: flex;
align-items: center;
Expand Down
115 changes: 67 additions & 48 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ
import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, BlockingExtensionUiRequest, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage, UserMessage } from "@/lib/types";
import { normalizeCustomPanelLines } from "@/lib/ansi";
import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input";
import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isMessageGroupAnchor, splitFinalAssistantBlocks } from "@/lib/message-display";
import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isMessageGroupAnchor, splitFinalAssistantBlocks, splitThinkingBlocks } from "@/lib/message-display";
import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files";
import { MessageView } from "./MessageView";
import { MessageView, ThinkingBlock } from "./MessageView";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import { ChatMinimap, useMessageRefs } from "./ChatMinimap";
import { ExtensionStatusBar } from "./ExtensionStatusBar";
Expand Down Expand Up @@ -172,23 +172,6 @@ function getUserInputText(message: AgentMessage): string | null {
return text.length > 0 ? text : null;
}

function countToolCalls(messages: AgentMessage[], indices: number[]): number {
let count = 0;
for (const idx of indices) {
const msg = messages[idx];
if (msg?.role !== "assistant") continue;
count += countToolCallBlocks(getDisplayableAssistantBlocks(msg as AssistantMessage));
}
return count;
}

function hasDisplayableProcessMessage(message: AgentMessage): boolean {
if (message.role === "assistant") {
return getDisplayableAssistantBlocks(message as AssistantMessage).length > 0;
}
return message.role === "custom";
}

function withAssistantBlocks(
message: AssistantMessage,
content: AssistantContentBlock[],
Expand Down Expand Up @@ -846,47 +829,83 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi

rendered.push(renderMessage(userIdx));

const processIndices: number[] = [];
for (let processIdx = userIdx + 1; processIdx < finalAssistantIdx; processIdx++) {
processIndices.push(processIdx);
}
const visibleProcessIndices = processIndices.filter((processIdx) => hasDisplayableProcessMessage(messages[processIdx]));
const finalAssistant = messages[finalAssistantIdx] as AssistantMessage;
const finalSplit = splitFinalAssistantBlocks(finalAssistant);
const finalProcessMessage = finalSplit.processBlocks.length > 0
? withAssistantBlocks(finalAssistant, finalSplit.processBlocks, { omitUsage: true })
: null;
const finalAnswerMessage = finalSplit.answerBlocks.length > 0 || getAssistantErrorMessage(finalAssistant)
? withAssistantBlocks(finalAssistant, finalSplit.answerBlocks)
: null;

const processCount = visibleProcessIndices.length + (finalProcessMessage ? 1 : 0);
if (processCount > 0) {
const processRefIdx = visibleProcessIndices
.map((processIdx) => visibleRefIndexByMessage.get(processIdx))
.find((value): value is number => typeof value === "number")
?? (finalAnswerMessage ? undefined : visibleRefIndexByMessage.get(finalAssistantIdx));
const processGroup = (
<ProcessDetailsGroup
messageCount={processCount}
defaultExpanded={!finalAnswerMessage}
reveal={Boolean(pendingSearchScroll && (visibleProcessIndices.some((index) => entryIds[index] === pendingSearchScroll.entryId) || (searchBlock && finalSplit.processBlocks.includes(searchBlock))))}
t={t}
toolCallCount={countToolCalls(messages, visibleProcessIndices) + countToolCallBlocks(finalSplit.processBlocks)}
>
{visibleProcessIndices.map((processIdx) => renderMessage(processIdx, { attachRef: false, keyPrefix: "process" }))}
{finalProcessMessage && renderMessage(finalAssistantIdx, { attachRef: false, keyPrefix: "process-final", messageOverride: finalProcessMessage, showTimestamp: false })}
</ProcessDetailsGroup>
);
let processViews: ReactNode[] = [];
let processToolCount = 0;
let processRefIdx: number | undefined;
let processKey = "";
let revealProcess = false;
const flushProcess = () => {
if (processViews.length === 0) return;
const refIndex = processRefIdx;
rendered.push(
<div
key={`process-group-${entryIds[userIdx] ?? userIdx}-${entryIds[finalAssistantIdx] ?? finalAssistantIdx}`}
ref={processRefIdx === undefined ? undefined : (el) => { messageRefs.current[processRefIdx] = el; }}
key={`process-group-${processKey}`}
ref={refIndex === undefined ? undefined : (el) => { messageRefs.current[refIndex] = el; }}
>
{processGroup}
<ProcessDetailsGroup messageCount={processViews.length} toolCallCount={processToolCount} defaultExpanded={!finalAnswerMessage} reveal={revealProcess} t={t}>
{processViews}
</ProcessDetailsGroup>
</div>,
);
processViews = [];
processToolCount = 0;
processRefIdx = undefined;
revealProcess = false;
};

// Flush each process segment before its next thinking block so
// reasoning stays outside the fold without reordering the turn.
for (let processIdx = userIdx + 1; processIdx <= finalAssistantIdx; processIdx++) {
const processMessage = messages[processIdx];
const messageKey = entryIds[processIdx] ?? processIdx;
if (processMessage.role === "custom") {
if (processViews.length === 0) processKey = String(messageKey);
revealProcess ||= Boolean(pendingSearchScroll && pendingSearchScroll.entryId === entryIds[processIdx]);
processViews.push(renderMessage(processIdx, { attachRef: false, keyPrefix: "process" }));
continue;
}
if (processMessage.role !== "assistant") continue;
const blocks = processIdx === finalAssistantIdx ? finalSplit.processBlocks : getDisplayableAssistantBlocks(processMessage);
const groups = splitThinkingBlocks(blocks);
const lastProcessGroup = groups.findLast((group) => !group.thinking);
for (const group of groups) {
const blockIndex = processMessage.content.indexOf(group.blocks[0]);
const key = `${messageKey}-${blockIndex}`;
if (group.thinking) {
flushProcess();
const previousTimestamp = messages[processIdx - 1]?.timestamp;
const duration = processMessage.timestamp && previousTimestamp
? Math.round((processMessage.timestamp - previousTimestamp) / 1000)
: 0;
const refIndex = visibleRefIndexByMessage.get(processIdx);
rendered.push(
<div key={`thinking-${key}`} style={{ marginBottom: 16, display: "flex", flexDirection: "column", gap: 8 }} ref={refIndex === undefined ? undefined : (el) => { messageRefs.current[refIndex] = el; }}>
{group.blocks.map((block) => block.type === "thinking" && (
<ThinkingBlock key={processMessage.content.indexOf(block)} block={block} blockIndex={processMessage.content.indexOf(block)} entryId={entryIds[processIdx]} sessionId={session?.id ?? sessionIdRef.current ?? undefined} duration={duration > 0 ? duration : undefined} />
))}
</div>,
);
} else {
if (processViews.length === 0) processKey = key;
processRefIdx ??= visibleRefIndexByMessage.get(processIdx);
processToolCount += countToolCallBlocks(group.blocks);
revealProcess ||= Boolean(pendingSearchScroll && entryIds[processIdx] === pendingSearchScroll.entryId && (!searchBlock || group.blocks.includes(searchBlock)));
processViews.push(renderMessage(processIdx, {
attachRef: false,
keyPrefix: `process-${blockIndex}`,
messageOverride: withAssistantBlocks(processMessage, group.blocks, { omitUsage: processIdx === finalAssistantIdx || group !== lastProcessGroup }),
showTimestamp: false,
}));
}
}
}
flushProcess();

if (finalAnswerMessage) {
// Each tool call is stored as its own assistant entry, so the
Expand Down
25 changes: 25 additions & 0 deletions components/MessageView.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const React = await jiti.import("react");
const { renderToStaticMarkup } = await jiti.import("react-dom/server");
const {
MessageView,
ThinkingBlock,
getTokenEstimateText,
getToolCallInputText,
replaceUserMessageText,
Expand All @@ -27,6 +28,30 @@ function renderMessage(message, props = {}) {
);
}

test("renders standalone thinking with the saved default and accessible disclosure state", () => {
const previousWindow = globalThis.window;
try {
for (const expanded of [false, true]) {
globalThis.window = { localStorage: { getItem: () => String(expanded) } };
const html = renderToStaticMarkup(React.createElement(
I18nProvider,
null,
React.createElement(ThinkingBlock, {
block: { type: "thinking", thinking: "Independent reasoning" },
blockIndex: 2,
duration: 3,
}),
));
assert.match(html, new RegExp(`aria-expanded="${expanded}"`));
assert.equal(html.includes("Independent reasoning"), expanded);
assert.match(html, /3s/);
}
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});

test("marks only the matched text block after splitting thinking and the final answer", () => {
const message = {
role: "assistant",
Expand Down
63 changes: 45 additions & 18 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { parseCompactionSummary } from "@/lib/compaction-summary";
import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display";
import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch";
import { isEditToolName } from "@/lib/tool-names";
import { isThinkingExpandedByDefault, THINKING_EXPANDED_EVENT } from "@/lib/thinking-expansion-preference";
import { TurnWrittenFiles } from "./TurnWrittenFiles";
import type { WrittenFile } from "@/lib/turn-written-files";
import { skillExpansionToCommand } from "@/lib/slash-display";
Expand Down Expand Up @@ -874,38 +875,58 @@ function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent
return <SafeMarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile}>{block.text}</SafeMarkdownBody>;
}

function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
export function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
block: ThinkingContent;
duration?: number;
sessionId?: string;
entryId?: string;
blockIndex: number;
}) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(isThinkingExpandedByDefault);
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const tRef = useRef(t);
tRef.current = t;

const toggle = async () => {
const nextExpanded = !expanded;
setExpanded(nextExpanded);
if (!nextExpanded || !block.deferred || content !== null) return;
// Keep already-mounted blocks in sync when the preference changes.
useEffect(() => {
const onChange = () => setExpanded(isThinkingExpandedByDefault());
window.addEventListener(THINKING_EXPANDED_EVENT, onChange);
return () => window.removeEventListener(THINKING_EXPANDED_EVENT, onChange);
}, []);

// Load deferred history content whenever the block is expanded.
// loadThinkingContent() memoizes in-flight promises and drops failed ones
// from its cache, so re-running this effect is cheap and a failed load can
// be retried by collapsing and expanding the block again.
useEffect(() => {
if (!expanded || !block.deferred || content !== null) return;
if (!sessionId || !entryId) {
setError(t("i18n.thinkingUnavailable"));
setError(tRef.current("i18n.thinkingUnavailable"));
return;
}

let cancelled = false;
setLoading(true);
setError(null);
try {
setContent(await loadThinkingContent(sessionId, entryId, blockIndex));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
loadThinkingContent(sessionId, entryId, blockIndex)
.then((value) => {
if (!cancelled) {
setContent(value);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [expanded, block.deferred, content, sessionId, entryId, blockIndex]);

return (
<div
Expand All @@ -917,7 +938,9 @@ function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
}}
>
<button
onClick={() => void toggle()}
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
style={{
display: "flex",
alignItems: "center",
Expand All @@ -932,7 +955,10 @@ function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
textAlign: "left",
}}
>
<span>{t("i18n.thinking")}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ flexShrink: 0, transform: expanded ? "rotate(90deg)" : "none", transition: "transform 0.15s" }}>
<polyline points="4 2.5 7.5 6 4 9.5" />
</svg>
<span>{t("i18n.thinking")}</span>
{duration !== undefined && (
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-dim)", fontVariantNumeric: "tabular-nums" }}>{duration}s</span>
)}
Expand All @@ -946,6 +972,7 @@ function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
lineHeight: 1.6,
whiteSpace: "pre-wrap",
background: "var(--bg-panel)",
overflowWrap: "anywhere",
borderTop: "1px solid var(--border)",
}}
>
Expand Down
Loading