From a1badccddd7fb972290a2e1e29785f3a11a7f1f7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 14 Jul 2026 00:59:07 +0300 Subject: [PATCH] feat(chat): prompt navigator list preview, prompt filtering, shell status fix (#2211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../ui/src/components/chat/ChatContainer.tsx | 67 +++++- .../ui/src/components/chat/MessageList.tsx | 74 +----- .../chat/components/PromptNavigatorRail.tsx | 211 ++++++++++++++++-- .../ui/src/components/chat/lib/shellBridge.ts | 113 ++++++++++ .../components/chat/message/MessageBody.tsx | 25 ++- .../components/chat/message/renderCompare.ts | 12 + packages/ui/src/sync/sync-context.tsx | 29 ++- 7 files changed, 434 insertions(+), 97 deletions(-) create mode 100644 packages/ui/src/components/chat/lib/shellBridge.ts diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 0d7153a8..a48d352f 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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>(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()); + // 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()); const promptPreviewsByTurnId = React.useMemo(() => { const next = new Map(); - 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) => { if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) { return; @@ -318,11 +375,11 @@ const ChatViewport = React.memo(({ - {showPromptNavigator ? ( + {showPromptNavigator && promptTurnIds.length >= 2 ? ( (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 { diff --git a/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx b/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx index 09e8e297..d61ec9eb 100644 --- a/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx +++ b/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx @@ -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(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) => { + 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(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(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({ {highlightedPrompt && highlightedIndex !== null ? (
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} > - - {highlightedPrompt.preview.trim() || emptyPreviewLabel} - - {highlightedPrompt.turnId === activeTurnId ? ( - - {currentPromptLabel} - - ) : null} +
+ {/* The list glides so the highlighted row stays + centered while scrubbing the rail. */} +
+ {prompts.slice(panelSliceStart, panelSliceEnd).map((prompt, slot) => { + const index = panelSliceStart + slot; + const isActive = prompt.turnId === activeTurnId; + const isHighlighted = highlightedIndex === index; + return ( +
{ + cancelScheduledHide(); + if (highlightedIndexRef.current !== index) { + ensureWindowContains(index); + setHighlightedIndex(index); + } + }} + onClick={() => handleSelect(index)} + > +
+ + {prompt.preview.trim() || emptyPreviewLabel} + +
+
+ ); + })} +
+
) : null} diff --git a/packages/ui/src/components/chat/lib/shellBridge.ts b/packages/ui/src/components/chat/lib/shellBridge.ts new file mode 100644 index 00000000..116e79cf --- /dev/null +++ b/packages/ui/src/components/chat/lib/shellBridge.ts @@ -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; +}; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 1bc16a6f..9843ed11 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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 }) {command ? ( -
-                    {command}
-                
+
+ +
) : null} {hasOutput ? ( @@ -363,9 +371,14 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) {expanded ? ( -
-                            {output}
-                        
+
+ +
) : null} ) : null} diff --git a/packages/ui/src/components/chat/message/renderCompare.ts b/packages/ui/src/components/chat/message/renderCompare.ts index ccd8768a..94b2a7e5 100644 --- a/packages/ui/src/components/chat/message/renderCompare.ts +++ b/packages/ui/src/components/chat/message/renderCompare.ts @@ -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; + } + } } } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 57645f42..0e5a301b 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -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)