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:
committed by
GitHub
parent
68f1c1efe3
commit
a1badccddd
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user