feat(chat): prompt navigator list preview, prompt filtering, shell status fix (#2211)

* feat(chat): prompt navigator list preview with prompt filtering

The hover preview is now an interactive scrolling mini-list of prompts:
rows render as bordered two-line cards, the highlighted row stays inside
a center dead zone and the list glides only near the window edges, wheel
steps the highlight, and the panel stays open when the pointer moves into
it so a click can be corrected inside the list.

Rail entries are filtered to real prompts: previews are built from
normalized user display parts (synthetic context stripped), fully
synthetic user messages are excluded, and shell-mode messages show their
extracted command via the shared shell bridge helpers.

* fix(chat): render shell command status transitions

The injected /shell text part carries live state in shellAction, which
the render-relevant part comparator ignored — a running→completed update
reached the store but never re-rendered the message row until the next
send. Compare shellAction command/output/status for text parts.

* fix(sync): stream shell bridge part updates while running

Streaming suspension keeps part updates out of the static message records
while an assistant message streams, relying on the live streaming-tail
path to render it. Shell-mode bridge messages are hidden from the
timeline and rendered inside the user row, so they have no live path —
suspension froze their output chunks and left the card without a Show
output action until the run finished. Exempt shell bridges (single bash
tool part parented to a synthetic shell-marker user message) from
suspension; their updates arrive at command-output pace, not delta pace.

* feat(chat): syntax-highlight shell command card

Render the shell-mode command and its output through the shared
WorkerHighlightedCode (Shiki) with bash grammar, matching the bash tool
part presentation, instead of plain pre blocks.
This commit is contained in:
Bohdan Triapitsyn
2026-07-14 00:59:07 +03:00
committed by GitHub
parent 68f1c1efe3
commit a1badccddd
7 changed files with 434 additions and 97 deletions
@@ -51,6 +51,9 @@ import { useI18n } from '@/lib/i18n';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat';
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
@@ -206,13 +209,46 @@ const ChatViewport = React.memo(({
}: ChatViewportProps) => {
const { t } = useI18n();
const promptPreviewsByTurnIdRef = React.useRef<Map<string, Part[]>>(new Map());
// Cache normalized parts per source array so unchanged messages keep the
// same reference and the memo below can bail out to the previous map.
const normalizedPromptPartsCache = React.useRef(new WeakMap<Part[], Part[]>());
// Shell-mode prompts show their extracted command; cache by message id so
// the parts array reference is stable while the command is unchanged.
const shellPreviewCache = React.useRef(new Map<string, { command: string; parts: Part[] }>());
const promptPreviewsByTurnId = React.useMemo(() => {
const next = new Map<string, Part[]>();
for (const message of renderedMessages) {
for (let index = 0; index < renderedMessages.length; index += 1) {
const message = renderedMessages[index];
if (message.info.role !== 'user') {
continue;
}
next.set(message.info.id, message.parts);
if (isUserShellMarkerMessage(message)) {
const command = findShellCommandForMessage(renderedMessages, index) ?? '';
const cached = shellPreviewCache.current.get(message.info.id);
if (cached && cached.command === command) {
next.set(message.info.id, cached.parts);
} else {
const parts = [{ type: 'text', text: command ? `$ ${command}` : '/shell' } as Part];
shellPreviewCache.current.set(message.info.id, { command, parts });
next.set(message.info.id, parts);
}
continue;
}
// Other fully synthetic user messages (loop continuations,
// plan-mode injections) are not prompts the user typed — keep
// them out of the navigator entirely.
if (isFullySyntheticMessage(message.parts)) {
continue;
}
let displayParts = normalizedPromptPartsCache.current.get(message.parts);
if (!displayParts) {
displayParts = normalizeUserDisplayParts(message.parts);
normalizedPromptPartsCache.current.set(message.parts, displayParts);
}
if (displayParts.length === 0) {
continue;
}
next.set(message.info.id, displayParts);
}
const prev = promptPreviewsByTurnIdRef.current;
if (prev.size === next.size) {
@@ -230,6 +266,27 @@ const ChatViewport = React.memo(({
promptPreviewsByTurnIdRef.current = next;
return next;
}, [renderedMessages]);
// Only real (non-synthetic) prompts become rail entries; selection still
// targets the same turn anchors as the timeline.
const promptTurnIds = React.useMemo(
() => turnIds.filter((id) => promptPreviewsByTurnId.has(id)),
[promptPreviewsByTurnId, turnIds],
);
// If the viewport sits in a filtered-out (synthetic) turn, treat the
// nearest preceding real prompt as active so the rail doesn't jump.
const railActiveTurnId = React.useMemo(() => {
if (!activeTurnId || promptPreviewsByTurnId.has(activeTurnId)) {
return activeTurnId;
}
const activeIndex = turnIds.indexOf(activeTurnId);
for (let index = activeIndex - 1; index >= 0; index -= 1) {
const turnId = turnIds[index];
if (promptPreviewsByTurnId.has(turnId)) {
return turnId;
}
}
return null;
}, [activeTurnId, promptPreviewsByTurnId, turnIds]);
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
return;
@@ -318,11 +375,11 @@ const ChatViewport = React.memo(({
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{showPromptNavigator ? (
{showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail
turnIds={turnIds}
turnIds={promptTurnIds}
previewsByTurnId={promptPreviewsByTurnId}
activeTurnId={activeTurnId}
activeTurnId={railActiveTurnId}
onSelectTurn={onSelectTurn}
canLoadEarlier={canLoadEarlierPrompts}
isLoadingOlder={isLoadingOlderPrompts}
@@ -20,6 +20,12 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionParts } from '@/sync/sync-context';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
import {
USER_SHELL_MARKER,
isUserShellMarkerMessage,
getShellBridgeAssistantDetails,
type ShellBridgeDetails,
} from './lib/shellBridge';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
@@ -122,8 +128,6 @@ const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TAr
return React.useCallback((...args: TArgs) => handlerRef.current(...args), []);
};
const USER_SHELL_MARKER = 'The following tool was executed by the user';
const resolveMessageRole = (message: ChatMessageEntry): string | null => {
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
return (typeof info.clientRole === 'string' ? info.clientRole : null)
@@ -216,72 +220,6 @@ const isInsideStuckSticky = (node: HTMLElement, container: HTMLElement, containe
return false;
};
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
return message.parts.some((part) => {
if (part?.type !== 'text') return false;
const text = (part as unknown as { text?: unknown }).text;
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER);
});
};
type ShellBridgeDetails = {
command?: string;
output?: string;
status?: string;
};
const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParentId: string | null): { hide: boolean; details: ShellBridgeDetails | null } => {
if (resolveMessageRole(message) !== 'assistant') {
return { hide: false, details: null };
}
if (expectedParentId && getMessageParentId(message) !== expectedParentId) {
return { hide: false, details: null };
}
if (message.parts.length !== 1) {
return { hide: false, details: null };
}
const part = message.parts[0] as unknown as {
type?: unknown;
tool?: unknown;
state?: {
status?: unknown;
input?: { command?: unknown };
output?: unknown;
metadata?: { output?: unknown };
};
};
if (part?.type !== 'tool') {
return { hide: false, details: null };
}
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
if (toolName !== 'bash') {
return { hide: false, details: null };
}
const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined;
const output =
(typeof part.state?.output === 'string' ? part.state.output : undefined)
?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined);
const status = typeof part.state?.status === 'string' ? part.state.status : undefined;
return {
hide: true,
details: {
command,
output,
status,
},
};
};
const readTaskSessionId = (toolPart: Part): string | null => {
const partRecord = toolPart as unknown as {
@@ -42,6 +42,18 @@ const TICK_OVERSCAN = 4;
const TICK_BASE_WIDTH_PX = 10;
const TICK_ACTIVE_WIDTH_PX = 14;
const TICK_FOCUS_WIDTH_PX = 20;
// The hover preview is a scrolling mini-list of all prompts: the highlighted
// row stays centered while the list glides, and the panel itself is
// interactive so imprecise gutter hits can be corrected inside the list.
// Rows are fixed-pitch and fit up to two preview lines; short prompts just
// center vertically. Fixed pitch keeps the glide/virtualization math simple.
// Each row renders as a bordered card inset within its pitch slot so
// neighbouring prompts read as separate items instead of one text run.
const PANEL_ROW_HEIGHT_PX = 54;
const PANEL_ROW_INSET_Y_PX = 4;
const PANEL_MAX_ROWS = 8;
const PANEL_SCROLL_MARGIN_ROWS = 2;
const PANEL_HIDE_DELAY_MS = 160;
const buildPromptEntries = (
turnIds: string[],
@@ -271,7 +283,30 @@ export function PromptNavigatorRail({
}
}, []);
// Leaving the gutter hides the panel after a short grace period so the
// pointer can travel into the panel and interact with the list directly.
const hideTimerRef = React.useRef<number | null>(null);
const cancelScheduledHide = React.useCallback(() => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
}, []);
const scheduleHide = React.useCallback(() => {
cancelScheduledHide();
hideTimerRef.current = window.setTimeout(() => {
hideTimerRef.current = null;
setHighlightedIndex(null);
}, PANEL_HIDE_DELAY_MS);
}, [cancelScheduledHide]);
React.useEffect(() => () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
}
}, []);
const handlePointerMove = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
cancelScheduledHide();
pointerYRef.current = event.clientY;
const relative = relativeIndexFromPointer(event.clientY);
if (relative !== null) {
@@ -280,13 +315,13 @@ export function PromptNavigatorRail({
);
}
updateCarousel(event.clientY);
}, [relativeIndexFromPointer, updateCarousel]);
}, [cancelScheduledHide, relativeIndexFromPointer, updateCarousel]);
const handlePointerLeave = React.useCallback(() => {
pointerYRef.current = null;
stopCarousel();
setHighlightedIndex(null);
}, [stopCarousel]);
scheduleHide();
}, [scheduleHide, stopCarousel]);
const closeKeyboardNav = React.useCallback(() => {
setPromptNavigatorPanelOpen(false);
@@ -386,7 +421,85 @@ export function PromptNavigatorRail({
closeKeyboardNav();
}, [closeKeyboardNav, stopCarousel]);
// Wheel over the panel steps the highlight instead of scrolling the chat
// underneath; a native non-passive listener is required for preventDefault.
const panelRef = React.useRef<HTMLDivElement | null>(null);
const highlightedIndexRef = React.useRef(highlightedIndex);
highlightedIndexRef.current = highlightedIndex;
const isPanelVisible = highlightedIndex !== null;
const wheelRemainderRef = React.useRef(0);
React.useEffect(() => {
const panel = panelRef.current;
if (!panel) {
return;
}
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
wheelRemainderRef.current += event.deltaY;
const steps = Math.trunc(wheelRemainderRef.current / PANEL_ROW_HEIGHT_PX);
if (steps === 0) {
return;
}
wheelRemainderRef.current -= steps * PANEL_ROW_HEIGHT_PX;
const current = highlightedIndexRef.current;
if (current === null) {
return;
}
const next = Math.max(0, Math.min(promptsLengthRef.current - 1, current + steps));
if (next !== current) {
ensureWindowContains(next);
setHighlightedIndex(next);
}
};
panel.addEventListener('wheel', handleWheel, { passive: false });
return () => panel.removeEventListener('wheel', handleWheel);
}, [ensureWindowContains, isPanelVisible]);
const highlightedPrompt = highlightedIndex !== null ? prompts[highlightedIndex] : undefined;
// Panel list geometry: centered on the highlight when the panel opens,
// then a dead zone — the list only glides when the highlighted row gets
// within one row of the window edge, so small pointer moves don't scroll.
const panelVisibleRows = Math.min(prompts.length, PANEL_MAX_ROWS);
const panelHeight = panelVisibleRows * PANEL_ROW_HEIGHT_PX;
const panelMaxOffset = prompts.length * PANEL_ROW_HEIGHT_PX - panelHeight;
const clampPanelOffset = (offset: number) => Math.max(0, Math.min(panelMaxOffset, offset));
const panelOffsetRef = React.useRef<number | null>(null);
let panelScrollOffset = 0;
if (highlightedIndex === null) {
panelOffsetRef.current = null;
} else if (panelOffsetRef.current === null) {
panelScrollOffset = clampPanelOffset(
highlightedIndex * PANEL_ROW_HEIGHT_PX - (panelHeight - PANEL_ROW_HEIGHT_PX) / 2,
);
panelOffsetRef.current = panelScrollOffset;
} else {
let offset = panelOffsetRef.current;
// Keep two rows of context visible above and below the highlight —
// the dead zone is the middle third of the window, so the list glides
// noticeably before the highlight reaches the edge but small pointer
// moves around the center don't scroll.
const highestAllowed = (highlightedIndex - PANEL_SCROLL_MARGIN_ROWS) * PANEL_ROW_HEIGHT_PX;
const lowestAllowed =
(highlightedIndex + 1 + PANEL_SCROLL_MARGIN_ROWS) * PANEL_ROW_HEIGHT_PX - panelHeight;
if (offset > highestAllowed) {
offset = highestAllowed;
} else if (offset < lowestAllowed) {
offset = lowestAllowed;
}
panelScrollOffset = clampPanelOffset(offset);
panelOffsetRef.current = panelScrollOffset;
}
// Only rows near the visible window are rendered; extra rows slide in
// under the mask during the glide instead of popping in at the edges.
const panelFirstVisibleRow = Math.floor(panelScrollOffset / PANEL_ROW_HEIGHT_PX);
const panelSliceStart = Math.max(0, panelFirstVisibleRow - TICK_OVERSCAN);
const panelSliceEnd = Math.min(prompts.length, panelFirstVisibleRow + panelVisibleRows + TICK_OVERSCAN);
const panelClippedAbove = panelScrollOffset > 0;
const panelClippedBelow = panelScrollOffset < panelMaxOffset;
const panelMask = panelClippedAbove || panelClippedBelow
? `linear-gradient(to bottom, ${panelClippedAbove ? 'transparent, black 10%' : 'black'}, ${panelClippedBelow ? 'black 90%, transparent' : 'black'})`
: undefined;
// Overscan a few ticks beyond the window so they slide in under the
// gradient mask instead of popping into existence at the edges.
const overscanStart = Math.max(0, clampedWindowStart - TICK_OVERSCAN);
@@ -503,23 +616,87 @@ export function PromptNavigatorRail({
</div>
{highlightedPrompt && highlightedIndex !== null ? (
<div
ref={panelRef}
className={cn(
'pointer-events-none absolute right-full z-30 mr-3 -translate-y-1/2',
'w-[min(20rem,calc(100vw-6rem))] rounded-xl border border-[var(--interactive-border)]/60',
'bg-[var(--surface-elevated)] px-3 py-2 shadow-md',
'pointer-events-auto absolute right-full top-1/2 z-30 mr-3 -translate-y-1/2',
'w-[min(20rem,calc(100vw-6rem))] overflow-hidden rounded-xl',
'border border-[var(--interactive-border)]/60 bg-[var(--surface-elevated)] py-1 shadow-md',
)}
style={{
top: `${(highlightedIndex - clampedWindowStart) * TICK_PITCH_PX + TICK_PITCH_PX / 2}px`,
}}
onMouseEnter={cancelScheduledHide}
onMouseLeave={scheduleHide}
onMouseMove={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<span className="typography-meta line-clamp-3 block text-[var(--surface-foreground)]">
{highlightedPrompt.preview.trim() || emptyPreviewLabel}
</span>
{highlightedPrompt.turnId === activeTurnId ? (
<span className="mt-0.5 block typography-micro text-[var(--surface-mutedForeground)]">
{currentPromptLabel}
</span>
) : null}
<div
className="relative overflow-hidden"
style={{
height: `${panelVisibleRows * PANEL_ROW_HEIGHT_PX}px`,
maskImage: panelMask,
WebkitMaskImage: panelMask,
}}
>
{/* The list glides so the highlighted row stays
centered while scrubbing the rail. */}
<div
className="absolute inset-x-0 top-0 transition-transform duration-200 ease-out"
style={{
height: `${prompts.length * PANEL_ROW_HEIGHT_PX}px`,
transform: `translateY(-${panelScrollOffset}px)`,
}}
>
{prompts.slice(panelSliceStart, panelSliceEnd).map((prompt, slot) => {
const index = panelSliceStart + slot;
const isActive = prompt.turnId === activeTurnId;
const isHighlighted = highlightedIndex === index;
return (
<div
key={prompt.turnId}
role="option"
aria-selected={isHighlighted}
aria-current={isActive ? 'true' : undefined}
title={isActive ? currentPromptLabel : undefined}
className="absolute inset-x-0 cursor-pointer px-1.5"
style={{
top: `${index * PANEL_ROW_HEIGHT_PX + PANEL_ROW_INSET_Y_PX}px`,
height: `${PANEL_ROW_HEIGHT_PX - PANEL_ROW_INSET_Y_PX * 2}px`,
}}
onMouseMove={() => {
cancelScheduledHide();
if (highlightedIndexRef.current !== index) {
ensureWindowContains(index);
setHighlightedIndex(index);
}
}}
onClick={() => handleSelect(index)}
>
<div
className={cn(
'flex h-full items-center rounded-lg border px-2 transition-colors',
isActive
? 'border-transparent bg-[var(--interactive-selection)]'
: isHighlighted
? 'border-[var(--interactive-border)]/60 bg-[var(--interactive-hover)]'
: 'border-[var(--interactive-border)]/40',
)}
>
<span
className={cn(
// Fill both clamp lines to the edge instead of
// leaving a ragged gap before a long next word.
'min-w-0 flex-1 line-clamp-2 [overflow-wrap:anywhere] typography-meta',
isActive
? 'text-[var(--interactive-selectionForeground)]'
: 'text-[var(--surface-mutedForeground)]',
)}
>
{prompt.preview.trim() || emptyPreviewLabel}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
) : null}
</div>
@@ -0,0 +1,113 @@
import type { ChatMessageEntry } from './turns/types';
export const USER_SHELL_MARKER = 'The following tool was executed by the user';
const resolveMessageRole = (message: ChatMessageEntry): string | null => {
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
return (typeof info.clientRole === 'string' ? info.clientRole : null)
?? (typeof info.role === 'string' ? info.role : null)
?? null;
};
const getMessageParentId = (message: ChatMessageEntry): string | null => {
const info = message.info as unknown as { parentID?: unknown };
return typeof info.parentID === 'string' && info.parentID.length > 0 ? info.parentID : null;
};
export const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
if (!message) return false;
if (resolveMessageRole(message) !== 'user') return false;
return message.parts.some((part) => {
if (part?.type !== 'text') return false;
const text = (part as unknown as { text?: unknown }).text;
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER);
});
};
export type ShellBridgeDetails = {
command?: string;
output?: string;
status?: string;
};
export const getShellBridgeAssistantDetails = (
message: ChatMessageEntry,
expectedParentId: string | null,
): { hide: boolean; details: ShellBridgeDetails | null } => {
if (resolveMessageRole(message) !== 'assistant') {
return { hide: false, details: null };
}
if (expectedParentId && getMessageParentId(message) !== expectedParentId) {
return { hide: false, details: null };
}
if (message.parts.length !== 1) {
return { hide: false, details: null };
}
const part = message.parts[0] as unknown as {
type?: unknown;
tool?: unknown;
state?: {
status?: unknown;
input?: { command?: unknown };
output?: unknown;
metadata?: { output?: unknown };
};
};
if (part?.type !== 'tool') {
return { hide: false, details: null };
}
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
if (toolName !== 'bash') {
return { hide: false, details: null };
}
const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined;
const output =
(typeof part.state?.output === 'string' ? part.state.output : undefined)
?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined);
const status = typeof part.state?.status === 'string' ? part.state.status : undefined;
return {
hide: true,
details: {
command,
output,
status,
},
};
};
/**
* Finds the shell command a user shell-mode message executed by locating its
* assistant bridge message (single bash tool part parented to the user
* message) among the following entries.
*/
export const findShellCommandForMessage = (
messages: ChatMessageEntry[],
userIndex: number,
): string | null => {
const userMessage = messages[userIndex];
if (!userMessage) return null;
const userId = userMessage.info.id;
for (let index = userIndex + 1; index < messages.length; index += 1) {
const candidate = messages[index];
if (resolveMessageRole(candidate) === 'user') {
break;
}
const { hide, details } = getShellBridgeAssistantDetails(candidate, userId);
if (hide) {
const command = typeof details?.command === 'string' ? details.command.trim() : '';
return command.length > 0 ? command : null;
}
}
return null;
};
@@ -11,6 +11,7 @@ import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
import type { TurnChangedFile, TurnGroupingContext } from '../lib/turns/types';
import { cn } from '@/lib/utils';
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
@@ -278,6 +279,8 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
);
};
const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' };
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const [copiedOutput, setCopiedOutput] = React.useState(false);
@@ -335,9 +338,14 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
</div>
{command ? (
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/90 font-mono">
{command}
</pre>
<div className="typography-meta mt-1.5 overflow-x-auto font-mono">
<WorkerHighlightedCode
language="bash"
code={command}
codeStyle={SHELL_CODE_TAG_STYLE}
wrap
/>
</div>
) : null}
{hasOutput ? (
@@ -363,9 +371,14 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
</button>
</div>
{expanded ? (
<pre className="typography-meta mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-words text-foreground/85 font-mono">
{output}
</pre>
<div className="typography-meta mt-1.5 max-h-56 overflow-auto font-mono text-foreground/85">
<WorkerHighlightedCode
language="bash"
code={output}
codeStyle={SHELL_CODE_TAG_STYLE}
wrap
/>
</div>
) : null}
</div>
) : null}
@@ -100,6 +100,18 @@ export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolea
if (readPartText(leftPart) !== readPartText(rightPart)) {
return false;
}
// Shell-mode user messages carry their live command state in an
// injected `shellAction` payload on a synthetic text part; without
// comparing it, a running→completed transition never re-renders.
const leftShell = (leftPart as { shellAction?: { command?: unknown; output?: unknown; status?: unknown } }).shellAction;
const rightShell = (rightPart as { shellAction?: { command?: unknown; output?: unknown; status?: unknown } }).shellAction;
if (leftShell || rightShell) {
if (leftShell?.command !== rightShell?.command
|| leftShell?.output !== rightShell?.output
|| leftShell?.status !== rightShell?.status) {
return false;
}
}
}
}
+28 -1
View File
@@ -2512,11 +2512,37 @@ export function dropCachedSessionMessageRecordsSnapshots(
}
}
// Shell-mode bridge messages (single bash tool part parented to a synthetic
// shell-marker user message) are hidden from the timeline and rendered inside
// the user row, so they never go through the live streaming-tail path. Their
// part updates (output chunks, running→completed) must not be suspended, or
// the shell card freezes until the next full snapshot rebuild.
const USER_SHELL_MARKER = "The following tool was executed by the user"
const isSuspendExemptShellBridge = (state: State, info: Message, parts: Part[] | undefined): boolean => {
if (!parts || parts.length !== 1) return false
const part = parts[0] as { type?: unknown; tool?: unknown }
if (part?.type !== "tool" || typeof part.tool !== "string" || part.tool.toLowerCase() !== "bash") return false
const parentID = (info as { parentID?: unknown }).parentID
if (typeof parentID !== "string" || parentID.length === 0) return false
const parentParts = state.part[parentID]
if (!parentParts) return false
return parentParts.some((parentPart) => {
if (parentPart?.type !== "text") return false
if ((parentPart as { synthetic?: boolean }).synthetic !== true) return false
const text = (parentPart as { text?: unknown }).text
return typeof text === "string" && text.trim().startsWith(USER_SHELL_MARKER)
})
}
const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => {
for (const record of snapshot.list) {
if (snapshot.suspendPartUpdates) {
const suspendedID = snapshot.suspendedPartUpdatesMessageID
if (!suspendedID || record.info.id === suspendedID) {
if (
(!suspendedID || record.info.id === suspendedID)
&& !isSuspendExemptShellBridge(state, record.info, state.part[record.info.id])
) {
continue
}
}
@@ -2594,6 +2620,7 @@ export function buildSessionMessageRecordsSnapshot(
const shouldSuspendParts = suspendPartUpdates
&& previousRecord
&& (!suspendedPartUpdatesMessageID || message.id === suspendedPartUpdatesMessageID)
&& !isSuspendExemptShellBridge(state, message, state.part[message.id])
const parts = shouldSuspendParts
? previousRecord.parts
: (state.part[message.id] ?? EMPTY_PARTS)