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
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,16 @@ const SETTINGS_SCHEMA = {
'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.',
showInDialog: true,
},
showScrollbar: {
type: 'boolean',
label: 'Show Scrollbar (Virtualized History)',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show the auto-hiding scrollbar in the in-app scrollable viewport (Virtualized History). The bar appears while scrolling and fades out when idle. Disable to hide it entirely.',
showInDialog: true,
},
shellOutputMaxLines: {
type: 'number',
label: 'Shell Output Max Lines',
Expand Down
77 changes: 32 additions & 45 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,6 @@ import {
type RenderMode,
} from './contexts/RenderModeContext.js';
import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js';
import {
ThinkingViewerProvider,
type ThinkingViewerData,
} from './contexts/ThinkingViewerContext.js';
import { ThinkingViewer } from './components/ThinkingViewer.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
import {
useBackgroundTaskViewState,
Expand Down Expand Up @@ -480,18 +475,25 @@ export const AppContainer = (props: AppContainerProps) => {

const [userMessages, setUserMessages] = useState<string[]>([]);

// Thinking viewer overlay state
const [thinkingViewerData, setThinkingViewerData] =
useState<ThinkingViewerData | null>(null);
const openThinkingViewer = useCallback((data: ThinkingViewerData) => {
setThinkingViewerData(data);
}, []);
const closeThinkingViewer = useCallback(() => {
setThinkingViewerData(null);
}, []);

// Alt+T inline expansion toggle for thinking blocks
// Alt+T inline expansion toggle for thinking blocks (expands all at once).
const [thoughtExpanded, setThoughtExpanded] = useState(false);
// Per-thought inline expansion: head ids the user expanded by clicking the
// collapsed thinking line (VP mode). Replaces the old full-screen viewer —
// the thought expands in place and scrolls with the conversation.
const [expandedThoughtHeadIds, setExpandedThoughtHeadIds] = useState<
ReadonlySet<number>
>(() => new Set<number>());
const toggleThoughtExpanded = useCallback((headId: number) => {
setExpandedThoughtHeadIds((prev) => {
const next = new Set(prev);
if (next.has(headId)) {
next.delete(headId);
} else {
next.add(headId);
}
return next;
});
}, []);

// Terminal and layout hooks
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
Expand Down Expand Up @@ -959,6 +961,7 @@ export const AppContainer = (props: AppContainerProps) => {
// re-reading `mergedHistory` / `allVirtualItems` on whatever state
// change triggered refreshStatic (Ctrl+O, model change, etc.).
const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false;
const showScrollbar = settings.merged.ui?.showScrollbar ?? true;
const refreshStatic = useCallback(() => {
if (!useTerminalBuffer) {
stdout.write(ansiEscapes.clearTerminal);
Expand Down Expand Up @@ -3278,16 +3281,6 @@ export const AppContainer = (props: AppContainerProps) => {
debugLogger.debug('[DEBUG] Keystroke:', JSON.stringify(key));
}

// ThinkingViewer owns all input while open.
// Ctrl+C / Ctrl+D close the viewer and fall through to quit/exit.
if (thinkingViewerData) {
if (keyMatchers[Command.QUIT](key) || keyMatchers[Command.EXIT](key)) {
closeThinkingViewer();
} else {
return;
}
}

// Alt+T: toggle inline expansion of thinking blocks.
if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) {
setThoughtExpanded((prev) => !prev);
Expand Down Expand Up @@ -3561,8 +3554,6 @@ export const AppContainer = (props: AppContainerProps) => {
handleDoubleEscRewind,
vimEnabled,
vimMode,
thinkingViewerData,
closeThinkingViewer,
setThoughtExpanded,
],
);
Expand Down Expand Up @@ -3723,6 +3714,7 @@ export const AppContainer = (props: AppContainerProps) => {
contextFileNames,
availableTerminalHeight,
useTerminalBuffer,
showScrollbar,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
Expand Down Expand Up @@ -3861,6 +3853,7 @@ export const AppContainer = (props: AppContainerProps) => {
contextFileNames,
availableTerminalHeight,
useTerminalBuffer,
showScrollbar,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
Expand Down Expand Up @@ -4117,9 +4110,13 @@ export const AppContainer = (props: AppContainerProps) => {
[renderMode, setRenderMode],
);

const thinkingViewerValue = useMemo(
() => ({ openThinkingViewer }),
[openThinkingViewer],
const thoughtExpandedValue = useMemo(
() => ({
allExpanded: thoughtExpanded,
expandedHeadIds: expandedThoughtHeadIds,
toggle: toggleThoughtExpanded,
}),
[thoughtExpanded, expandedThoughtHeadIds, toggleThoughtExpanded],
);

return (
Expand All @@ -4133,22 +4130,12 @@ export const AppContainer = (props: AppContainerProps) => {
}}
>
<CompactModeProvider value={compactModeValue}>
<ThoughtExpandedProvider value={thoughtExpanded}>
<ThoughtExpandedProvider value={thoughtExpandedValue}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ThinkingViewerProvider value={thinkingViewerValue}>
<ShellFocusContext.Provider value={isFocused}>
{thinkingViewerData ? (
<ThinkingViewer
data={thinkingViewerData}
onClose={closeThinkingViewer}
useAlternateScreen={!useTerminalBuffer}
/>
) : (
<App />
)}
</ShellFocusContext.Provider>
</ThinkingViewerProvider>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
</ThoughtExpandedProvider>
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,13 @@ describe('<HistoryItemDisplay />', () => {
};

const { lastFrame } = renderWithProviders(
<ThoughtExpandedProvider value={true}>
<ThoughtExpandedProvider
value={{
allExpanded: true,
expandedHeadIds: new Set<number>(),
toggle: () => {},
}}
>
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />
</ThoughtExpandedProvider>,
);
Expand Down
55 changes: 34 additions & 21 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js';
import { GoalStatusMessage } from './messages/GoalStatusMessage.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js';
import { useThinkingViewer } from '../contexts/ThinkingViewerContext.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import type { MouseEvent } from '../utils/mouse.js';
import { measureElementPosition } from '../utils/measure-element-position.js';
Expand All @@ -80,8 +79,12 @@ interface HistoryItemDisplayProps {
sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets;
/** Force thinking blocks expanded (e.g. in SessionPreview). */
thoughtExpanded?: boolean;
/** Aggregated text from this thought + its continuation items. */
thinkingFullText?: string;
/**
* Head id of the thought group this item belongs to (the `gemini_thought`
* head id for both the head and its `gemini_thought_content` continuations).
* Used to expand/collapse the whole group as a unit on click.
*/
thoughtHeadId?: number;
}

/**
Expand All @@ -91,32 +94,31 @@ interface HistoryItemDisplayProps {
*/
const ClickableThinkMessage: React.FC<{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage gap: ClickableThinkMessage is a new component introduced in this PR, but the click hit-testing logic is not tested. The existing test ('subscribes the click handler without bypassVpGate') only verifies useMouseEvents was called with { isActive: true } — it never simulates a mouse event or asserts that (a) an in-bounds left-press fires onToggle, (b) an out-of-bounds click is ignored, or (c) isPending=true disables the handler via isActive=false.

Since this is the core new interaction replacing the deleted ThinkingViewer, consider adding a test that mocks measureElementPosition to return known bounds, feeds a synthetic { name: 'left-press', col, row } event to the captured useMouseEvents callback, and asserts onToggle fires only for in-bounds coordinates.

— qwen3.7-max via Qwen Code /review

text: string;
viewerText: string;
isPending: boolean;
expanded: boolean;
availableTerminalHeight?: number;
contentWidth: number;
durationMs?: number;
onToggle: () => void;
}> = ({
text,
viewerText,
isPending,
expanded,
availableTerminalHeight,
contentWidth,
durationMs,
onToggle,
}) => {
const ref = useRef<DOMElement>(null);
const { openThinkingViewer } = useThinkingViewer();
// Click-to-expand needs SGR mouse tracking. We do NOT pass `bypassVpGate`, so
// useMouseEvents enables it only in VP mode; in non-VP the click handler
// stays dormant and native terminal scrollback is preserved (the block still
// expands via Alt+T — the "option+t to expand" affordance it already shows).
const isActive = !isPending && !expanded;
const sanitizedViewerText = useMemo(
() => escapeAnsiCtrlCodes(viewerText),
[viewerText],
);
// Click toggles the thought's inline expansion in place (it then scrolls
// with the conversation). Click needs SGR mouse tracking; useMouseEvents
// enables it only in VP mode (no `bypassVpGate`), so in non-VP the handler
// stays dormant and native scrollback is preserved — the block still toggles
// via Alt+T. Advertise "click" in the collapsed hint only in VP, where the
// click actually does something.
const settings = useSettings();
const clickable = !!settings.merged.ui?.useTerminalBuffer;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clickable copies one of the three gate terms useMouseEvents actually uses — the hint can advertise a dead click today, and the copies will drift.

The hook's effective gate is enabled = isActive && isRawModeSupported && vpGateOpen (useMouseEvents.ts:119); this line copies only the VP-setting term. Concretely reachable today: VP mode on a non-raw-mode stdin → the collapsed line says "(click or … to expand)" while the handler never subscribes. And any future gate condition (a ui.disableMouse setting, per-platform opt-out) silently desyncs the hint. Three finder angles independently flagged this. Suggest a single source: export a small useIsMouseClickAvailable() next to useMouseEvents (VP gate + isRawModeSupported), or have useMouseEvents return its enabled state, and drive both the subscription and the hint from it. (Note useUIState isn't usable here — HistoryItemDisplay also renders under SessionPreview without that provider.)

中文

clickable 只复制了 useMouseEvents 实际门控三项中的一项——今天就存在提示宣传死点击的场景,且两份拷贝会漂移。

hook 的有效门控是 enabled = isActive && isRawModeSupported && vpGateOpen(useMouseEvents.ts:119);本行只复制了 VP 设置这一项。今天即可触达:VP 模式 + 不支持 raw mode 的 stdin → 折叠行显示 "(click or … to expand)" 而处理器根本不会订阅。将来门控加任何新条件(如 ui.disableMouse 设置、按平台关闭)都会让提示静默失同步。三个查找视角独立标记了此问题。建议单一来源:在 useMouseEvents 旁导出一个小的 useIsMouseClickAvailable()(VP 门 + isRawModeSupported),或让 useMouseEvents 返回其 enabled 状态,订阅与提示都从它驱动。(注意这里不能用 useUIState——HistoryItemDisplay 也在无该 provider 的 SessionPreview 下渲染。)

const isActive = !isPending;

useMouseEvents(
useCallback(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] isActive changed from !isPending && !expanded to !isPending, so every non-pending thought now has an active click handler — including expanded ones. The <Box ref={isActive ? ref : undefined}> wraps the full ThinkMessage, so clicking anywhere on the expanded text body (not just the header line) collapses the block. There's no visual affordance that the expanded text is interactive (the "click to collapse" hint is only shown in collapsed state).

Users reading expanded thought text who click to focus or scroll within it will accidentally collapse the block they're reading. Consider either:

  • Restricting the hit-test to the header line only (wrap only the icon + label <Text> in the ref-bearing <Box>), so clicking the expanded body doesn't trigger collapse.
  • Or restoring !expanded to isActive and relying on Alt+T for collapse (as before).

— qwen3.7-max via Qwen Code /review

Expand All @@ -131,10 +133,10 @@ const ClickableThinkMessage: React.FC<{
row >= metrics.y &&
row < metrics.y + metrics.height
) {
openThinkingViewer({ text: sanitizedViewerText, durationMs });
onToggle();
}
},
[openThinkingViewer, sanitizedViewerText, durationMs],
[onToggle],
),
{ isActive },
);
Expand All @@ -148,6 +150,7 @@ const ClickableThinkMessage: React.FC<{
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
durationMs={durationMs}
clickable={clickable}
/>
</Box>
);
Expand Down Expand Up @@ -199,12 +202,22 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeightGemini,
sourceCopyIndexOffsets,
thoughtExpanded,
thinkingFullText,
thoughtHeadId,
}) => {
const marginTop = getHistoryItemMarginTop(item);

const contextThoughtExpanded = useThoughtExpanded();
const resolvedThoughtExpanded = thoughtExpanded ?? contextThoughtExpanded;
const {
allExpanded,
expandedHeadIds,
toggle: toggleThought,
} = useThoughtExpanded();
// A thought spans the `gemini_thought` head plus its trailing
// `gemini_thought_content` items; all of them key off the head id so one
// click expands the whole group. Continuations receive the head id via
// `thoughtHeadId`; the head itself falls back to its own id.
const thoughtGroupHeadId = thoughtHeadId ?? item.id;
const resolvedThoughtExpanded =
thoughtExpanded ?? (allExpanded || expandedHeadIds.has(thoughtGroupHeadId));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test coverage gap: the new resolvedThoughtExpanded resolution has three untested code paths:

  1. expandedHeadIds.has(thoughtGroupHeadId) — the per-thought expansion via click
  2. thoughtHeadId prop overriding item.id for continuations
  3. thoughtExpanded prop forcing expansion regardless of context

The existing test ('renders committed thinking expanded when ThoughtExpandedProvider is true') only exercises allExpanded: true with an empty expandedHeadIds set. The expandedHeadIds path is the primary replacement for the deleted ThinkingViewer — a regression in the set-membership check or the ?? item.id fallback would silently break per-thought expansion.

Consider adding tests that exercise the expandedHeadIds path with a set containing the target head id, and one that passes thoughtHeadId for a continuation item to verify the grouping.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Confirmed] Alt+T and per-thought click interact badly: clicking while allExpanded is on silently poisons the set.

resolvedThoughtExpanded is a pure OR, and the mouse handler stays armed on expanded blocks (isActive = !isPending, line 121). Sequence: Alt+T on → user clicks a long thought intending to collapse it → nothing changes visually (OR still true) but toggleThoughtExpanded adds its head id to expandedThoughtHeadIds → user turns Alt+T off → that one thought stays expanded and needs a second click to clear the stale entry. Nothing ever resets the set on Alt+T (the Alt+T handler only does setThoughtExpanded + refreshStatic(), AppContainer.tsx:3285-3289).

Two easy fixes: have the click handler treat allExpanded as the effective state (delete-only, never add, while allExpanded is true), or clear expandedThoughtHeadIds whenever Alt+T flips (Alt+T then behaves as an absolute set/clear, which matches its "toggle all" label).

中文

[已确认] Alt+T 与单条点击交互不良:allExpanded 开启时点击会静默污染集合。

resolvedThoughtExpanded 是纯 OR,且展开块上鼠标处理器仍然激活(isActive = !isPending,第 121 行)。序列:开 Alt+T → 用户点击长 thought 想收起 → 视觉无变化(OR 仍为 true)但 toggleThoughtExpanded 把 head id 加入 expandedThoughtHeadIds → 用户关 Alt+T → 该 thought 仍然展开,需再点一次才能清掉脏条目。Alt+T 从不重置集合(其处理器只做 setThoughtExpanded + refreshStatic(),AppContainer.tsx:3285-3289)。

两个简单修法:点击处理器把 allExpanded 视为有效状态(allExpanded 为 true 时只删不加);或 Alt+T 翻转时清空 expandedThoughtHeadIds(Alt+T 变成绝对的全开/全关,与其"全部切换"的语义一致)。

const settings = useSettings();
const showTimestamps = settings.merged.output?.showTimestamps === true;

Expand Down Expand Up @@ -269,14 +282,14 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'gemini_thought' && (
<ClickableThinkMessage
text={itemForDisplay.text.trimEnd()}
viewerText={(thinkingFullText || itemForDisplay.text).trimEnd()}
isPending={isPending}
expanded={resolvedThoughtExpanded}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
contentWidth={contentWidth}
durationMs={itemForDisplay.durationMs}
onToggle={() => toggleThought(thoughtGroupHeadId)}
/>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When the thoughtExpanded prop overrides the context read (e.g., SessionPreview passes true), the resolved expansion state ignores expandedHeadIds. However, onToggle still calls toggleThought(thoughtGroupHeadId), which writes to the shared context's expandedHeadIds. This creates an invisible mutation: clicking a thought in SessionPreview adds head IDs to the global set. When the user navigates back to the main conversation view (prop removed), those IDs cause phantom expansions — thoughts appear expanded without user action in that view.

Guard the toggle when the prop overrides:

Suggested change
/>
onToggle={thoughtExpanded != null ? () => {} : () => toggleThought(thoughtGroupHeadId)}

— qwen3.7-max via Qwen Code /review

)}
{itemForDisplay.type === 'gemini_thought_content' && (
Expand Down
16 changes: 9 additions & 7 deletions packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
countMarkdownSourceBlocks,
type MarkdownSourceCopyIndexOffsets,
} from '../utils/MarkdownDisplay.js';
import { buildThinkingFullTextMap } from '../utils/historyUtils.js';
import { buildThoughtHeadIdMap } from '../utils/historyUtils.js';
import { ScrollableList, SCROLL_TO_ITEM_END } from './shared/ScrollableList.js';

// Limit Gemini messages to a very high number of lines to mitigate performance
Expand Down Expand Up @@ -92,6 +92,7 @@ const virtualIsStaticItem = (item: HistoryItem) => item.id > 0;
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const showScrollbar = uiState.showScrollbar ?? true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the showScrollbar default of true is now encoded in four independent places.

settingsSchema default, AppContainer (settings.merged.ui?.showScrollbar ?? true), this line, and VirtualizedList (props.showScrollbar ?? true, twice at lines 587/800). AppContainer already resolves the default before publishing to UIState, so this layer exists only because UIState.showScrollbar is declared optional — unlike its sibling useTerminalBuffer: boolean, which is required and consumed with no fallback (line 117). Declaring showScrollbar: boolean required removes this re-default; flipping the default later then can't silently disagree across layers.

中文

次要:showScrollbar 的默认值 true 现在编码在四个独立位置。

settingsSchema 默认值、AppContainer(settings.merged.ui?.showScrollbar ?? true)、本行、以及 VirtualizedList(props.showScrollbar ?? true,587/800 两处)。AppContainer 发布到 UIState 前已解析过默认值,这一层的存在只因 UIState.showScrollbar 声明为可选——不像其兄弟 useTerminalBuffer: boolean 是必填且消费时无回退(第 117 行)。把 showScrollbar: boolean 声明为必填即可去掉本处再默认;将来翻转默认值也不会在各层间静默不一致。

const {
pendingHistoryItems,
terminalWidth,
Expand Down Expand Up @@ -276,12 +277,12 @@ export const MainContent = () => {
return map;
}, [historyItemsWithSourceCopyOffsets]);

const thinkingFullTextByItem = useMemo(
() => buildThinkingFullTextMap(visibleHistory),
const thoughtHeadIdByItem = useMemo(
() => buildThoughtHeadIdMap(visibleHistory),
[visibleHistory],
);
const thinkingFullTextByItemRef = useRef(thinkingFullTextByItem);
thinkingFullTextByItemRef.current = thinkingFullTextByItem;
const thoughtHeadIdByItemRef = useRef(thoughtHeadIdByItem);
thoughtHeadIdByItemRef.current = thoughtHeadIdByItem;

const pendingSourceCopyOffsetsByIndex = useMemo(
() =>
Expand Down Expand Up @@ -359,7 +360,7 @@ export const MainContent = () => {
isPending={false}
commands={uiState.slashCommands}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
thinkingFullText={thinkingFullTextByItemRef.current.get(item)}
thoughtHeadId={thoughtHeadIdByItemRef.current.get(item)}
/>
);
},
Expand Down Expand Up @@ -398,6 +399,7 @@ export const MainContent = () => {
initialScrollIndex={SCROLL_TO_ITEM_END}
isStaticItem={virtualIsStaticItem}
containerHeight={scrollContainerHeight}
showScrollbar={showScrollbar}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</OverflowProvider>
Expand Down Expand Up @@ -430,7 +432,7 @@ export const MainContent = () => {
isPending={false}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] thoughtHeadId is correctly passed to committed items here and in the virtual path above (line 363), but the pending items rendering below (lines ~447-463) does not pass thoughtHeadId. The same gap exists in AgentChatContent.tsx (lines ~251-265). Pending items also have their id overridden to 0 ({ ...item, id: 0 }), so thoughtGroupHeadId falls back to 0 for all of them.

If the user expands a thought group by clicking the head, pending continuation items that stream in won't match (expandedHeadIds.has(0) is false) and will render collapsed while the head is expanded — a brief visual desync during streaming. The desync self-heals when the item commits and the correct thoughtHeadId is passed.

Consider passing thoughtHeadId from the map to pending items as well, or using the item's original id instead of overriding it to 0.

— qwen3.7-max via Qwen Code /review

commands={uiState.slashCommands}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
thinkingFullText={thinkingFullTextByItem.get(h)}
thoughtHeadId={thoughtHeadIdByItem.get(h)}
/>
),
),
Expand Down
Loading
Loading