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}