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}
|
||||
|
||||
Reference in New Issue
Block a user