fix(chat): clamp text selection menu Y position to the viewport

Selecting text that crosses the top scroll edge could push the
floating selection menu above the viewport because only the X
position was clamped, not Y. Add getDesktopClampedY alongside the
existing X clamp in selectionMenuPosition.ts, measure the menu's
height the same way its width is measured, and re-clamp Y in
showMenu, the layout effect, and the resize handler.

Ported by hand: TextSelectionMenu.tsx changed significantly on main
(comment mode, glass surfaces, header drag-zone push-down) since this
fix was written, so main's file was kept and the fix re-applied on
top of it.

Closes #2257
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:46:12 +03:00
1264 changed files with 107942 additions and 30321 deletions
@@ -0,0 +1,46 @@
import React from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
}));
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
const {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} = await import('./appLinkConfirmation');
describe('AppLinkConfirmDialog', () => {
beforeEach(() => {
if (getAppLinkConfirmationSnapshot()) {
settleAppLinkConfirmation('cancel');
}
});
test('keeps cancel visible and focused beside both open choices', () => {
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
const markup = renderToStaticMarkup(
<I18nProvider>
<AppLinkConfirmDialog />
</I18nProvider>,
);
expect(markup).toContain('>Cancel</button>');
expect(markup).toContain('autofocus=""');
expect(markup).toContain('>Open once</button>');
expect(markup).toContain('>Trust and open</button>');
settleAppLinkConfirmation('cancel');
});
});
@@ -0,0 +1,77 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useI18n } from '@/lib/i18n';
import { getUrlScheme } from '@/lib/url';
import {
getAppLinkConfirmationSnapshot,
settleAppLinkConfirmation,
subscribeAppLinkConfirmation,
type AppLinkConfirmationChoice,
} from './appLinkConfirmation';
/**
* App-level dialog confirming application deep links (obsidian://, vscode://,
* ...) rendered in chat markdown before the OS is asked to open them.
* Dismissing via the close button, Escape, or the backdrop cancels the open.
*/
export const AppLinkConfirmDialog = () => {
const { t } = useI18n();
const request = React.useSyncExternalStore(
subscribeAppLinkConfirmation,
getAppLinkConfirmationSnapshot,
getAppLinkConfirmationSnapshot,
);
const url = request?.url ?? '';
const scheme = getUrlScheme(url) ?? '';
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
settleAppLinkConfirmation(choice);
}, []);
return (
<Dialog
open={Boolean(request)}
onOpenChange={(open: boolean) => {
if (!open) {
settle('cancel');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
<DialogDescription>
{scheme
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
: t('chat.appLink.confirm.descriptionPlain')}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
{url}
</div>
<DialogFooter>
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
{t('chat.appLink.confirm.cancel')}
</Button>
<Button variant="outline" onClick={() => settle('trust')}>
{t('chat.appLink.confirm.trustAndOpen')}
</Button>
<Button variant="default" onClick={() => settle('open')}>
{t('chat.appLink.confirm.open')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -207
View File
@@ -12,8 +12,8 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
import { filterVisibleParts, normalizeParts } from './message/partUtils';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { isHiddenUserMessage } from './message/hiddenUserMessage';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -131,8 +131,6 @@ interface ChatMessageProps {
info: Message;
parts: Part[];
};
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: () => void;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
@@ -147,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
message,
previousMessage,
nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
@@ -202,6 +198,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
const chatSurfaceMode = useChatSurfaceMode();
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
@@ -460,13 +457,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
const assistantTextParts = React.useMemo(() => {
if (isUser) {
return [];
}
return visibleParts.filter((part) => part.type === 'text');
}, [isUser, visibleParts]);
const toolParts = React.useMemo(() => {
if (isUser) {
return [];
@@ -548,19 +538,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const shouldHideUserMessage = isUser && displayParts.length === 0;
// Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
const shouldCoordinateRendering = React.useMemo(() => {
if (isUser) {
return false;
}
if (assistantTextParts.length === 0 || toolParts.length === 0) {
return hasOpenStep;
}
return true;
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
const themeVariant = currentTheme?.metadata.variant;
const isDarkTheme = React.useMemo(() => {
if (themeVariant) {
@@ -703,67 +680,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}
if (errorName === 'SessionRetry') {
return {
text: `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``,
variant: 'info' as const,
text: `Opencode failed to send a message. Retry attempt info: ${detail}`,
};
}
if (isLikelyProviderAuthFailure(detail)) {
return {
text: PROVIDER_AUTH_FAILURE_MESSAGE,
variant: 'error' as const,
};
}
if (detail.trim().toLowerCase() === 'aborted') {
return {
text: 'The running turn was stopped before OpenCode could send the next message.',
variant: 'info' as const,
};
}
return {
text: `Opencode failed to send message with error:\n\`${detail}\``,
variant: 'error' as const,
text: `Opencode failed to send message with error: ${detail}`,
};
}, [isUser, message.info]);
const assistantErrorText = assistantError?.text;
const assistantErrorVariant = assistantError?.variant;
const messageTextContent = React.useMemo(() => {
if (isUser) {
const shellOutputs = displayParts
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
.map((part) => {
const output = part.shellAction?.output;
return typeof output === 'string' ? output.trim() : '';
})
.filter((output) => output.length > 0);
if (shellOutputs.length > 0) {
return shellOutputs.join('\n\n');
}
const shellCommands = displayParts
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
.map((part) => {
const command = part.shellAction?.command;
return typeof command === 'string' ? command.trim() : '';
})
.filter((command) => command.length > 0);
if (shellCommands.length > 0) {
return shellCommands.join('\n');
}
const textParts = displayParts
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
.map((part) => {
const text = part.text || part.content || '';
return text.trim();
})
.filter((text) => text.length > 0);
const combined = textParts.join('\n');
return combined.replace(/\n\s*\n+/g, '\n');
return flattenUserTextParts(displayParts);
}
if (assistantErrorText && assistantErrorText.trim().length > 0) {
@@ -853,35 +792,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
});
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
const resolvedAnimationHandlers = animationHandlers ?? null;
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
const animationCompletedRef = React.useRef(false);
const hasRequestedReservationRef = React.useRef(false);
const animationStartNotifiedRef = React.useRef(false);
const hasTriggeredReservationOnceRef = React.useRef(false);
const hasEverStreamedRef = React.useRef(false);
React.useEffect(() => {
animationCompletedRef.current = false;
hasRequestedReservationRef.current = false;
animationStartNotifiedRef.current = false;
hasTriggeredReservationOnceRef.current = false;
hasAnnouncedAuxiliaryScrollRef.current = false;
hasEverStreamedRef.current = false;
}, [message.info.id]);
const handleAuxiliaryContentComplete = React.useCallback(() => {
if (isUser) {
return;
}
if (hasAnnouncedAuxiliaryScrollRef.current) {
return;
}
hasAnnouncedAuxiliaryScrollRef.current = true;
onContentChange?.('structural');
}, [isUser, onContentChange]);
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
@@ -904,114 +820,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
hasEverStreamedRef.current = true;
}
const hasReasoningParts = React.useMemo(() => {
if (isUser) {
return false;
}
return visibleParts.some((part) => part.type === 'reasoning');
}, [isUser, visibleParts]);
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
return;
}
if (!shouldReserveAnimationSpace) {
if (hasRequestedReservationRef.current) {
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
resolvedAnimationHandlers.onReasoningBlock();
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
resolvedAnimationHandlers.onReservationCancelled();
}
hasRequestedReservationRef.current = false;
}
return;
}
if (hasTriggeredReservationOnceRef.current) {
return;
}
hasTriggeredReservationOnceRef.current = true;
resolvedAnimationHandlers.onStreamingCandidate();
hasRequestedReservationRef.current = true;
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onAnimationStart) {
return;
}
if (!allowAnimation) {
return;
}
if (animationStartNotifiedRef.current) {
return;
}
resolvedAnimationHandlers.onAnimationStart();
animationStartNotifiedRef.current = true;
}, [resolvedAnimationHandlers, allowAnimation]);
React.useEffect(() => {
if (isUser) {
return;
}
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
if (!handler) {
return;
}
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
if (!shouldTrackHeight) {
return;
}
const element = messageContainerRef.current;
if (!element) {
return;
}
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
handler(element.getBoundingClientRect().height);
return;
}
let rafId: number | null = null;
const notifyHeight = (height: number) => {
if (typeof window === 'undefined') {
handler(height);
return;
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
handler(height);
});
};
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
notifyHeight(entry.contentRect.height);
});
observer.observe(element);
notifyHeight(element.getBoundingClientRect().height);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
observer.disconnect();
};
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
if (shouldHideUserMessage) {
return null;
@@ -1044,7 +853,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
respectReducedMotion
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
{/* peek: the action row under the bubble is suppressed, so
reserve its gap to the next message here, OUTSIDE the
bubble background. */}
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
<div
style={{
backgroundColor: 'var(--chat-user-message-bg)',
@@ -1070,13 +882,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1084,7 +894,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
contextPinPending={pinPending}
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
stickyUserHeaderEnabled={stickyUserHeader}
/>
@@ -1107,13 +916,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1121,7 +928,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
contextPinPending={pinPending}
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
userActionsMode="external-actions"
stickyUserHeaderEnabled={stickyUserHeader}
/>
@@ -1154,17 +960,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={shouldShowHeader}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
showReasoningTraces={showReasoningTraces}
agentMention={agentMention}
turnGroupingContext={turnGroupingContext}
errorMessage={assistantErrorText}
errorVariant={assistantErrorVariant}
reviewTransferDirection={reviewTransferDirection}
footerProviderID={headerProviderID}
footerModelName={headerModelName}
@@ -1,7 +1,6 @@
import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessages } from '@/sync/sync-context';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -11,6 +10,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -65,8 +65,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}, ref) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '');
const hasMessagesInCurrentSession = sessionMessages.length > 0;
const hasSession = Boolean(currentSessionId);
const hasNewSessionDraft = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const canStartSessionCommand = hasSession || hasNewSessionDraft;
@@ -84,7 +82,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const keyboardNavigationRef = React.useRef(false);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false);
@@ -139,7 +137,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
}));
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
...(hasSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
@@ -152,6 +150,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -195,10 +197,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
];
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
const allowInitCommand = !hasMessagesInCurrentSession;
const filtered = (searchQuery
const filtered = searchQuery
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
: allCommands;
filtered.sort((a, b) => {
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
@@ -211,9 +212,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
setCommands(filtered);
} catch {
const allowInitCommand = !hasMessagesInCurrentSession;
const builtInCommands: CommandInfo[] = [
...(hasSession && !hasMessagesInCurrentSession
...(hasSession
? [{ id: 'openchamber:init', name: 'init', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.initDescription'), isBuiltIn: true }]
: []
),
@@ -226,6 +226,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -268,12 +272,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
),
];
const filtered = (searchQuery
const filtered = searchQuery
? builtInCommands.filter(cmd =>
fuzzyMatch(cmd.name, searchQuery) ||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
)
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
: builtInCommands;
setCommands(filtered);
} finally {
@@ -282,7 +286,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
};
loadCommands();
}, [searchQuery, hasMessagesInCurrentSession, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
}, [searchQuery, hasSession, canStartSessionCommand, canUseReviewHandoffFlow, commandsWithMetadata, skills, t]);
React.useEffect(() => {
setSelectedIndex(0);
@@ -376,6 +380,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const isSystem = command.isBuiltIn;
const isOpenChamberBadge = command.isOpenChamber;
return (
<AutocompleteRowTooltip description={command.description} active={!isMobile && index === selectedIndex}>
<div
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -471,13 +476,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</span>
)}
</div>
{command.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{command.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{commands.length === 0 && (
@@ -0,0 +1,294 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The bar that sits in the composer stack: pending-changes accessory, abort
// status, and the todos dropdown. Deliberately a separate component from
// StatusRow — that one is the floating assistant-status chip above the
// composer, and sharing markup meant every restyle of the chip (glass,
// placement) silently restyled this bar and its dropdown too.
type TodoItem = Todo & { id?: string };
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
const statusConfig = {
in_progress: { textClassName: "text-foreground" },
pending: { textClassName: "text-foreground" },
completed: { textClassName: "text-muted-foreground line-through" },
cancelled: { textClassName: "text-muted-foreground line-through" },
};
const priorityClassName = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabelKey = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
// lookups treat them as candidate keys and every call site falls back to a
// default entry when the value is outside the known set.
const knownStatus = (status: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
status as keyof typeof statusConfig;
const knownPriority = (priority: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
priority as keyof typeof priorityClassName;
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
// SAFETY: the label keys are literal members of the i18n dictionary; the
// lookup narrows an open SDK string with a known fallback, and t() accepts
// only the generated key union.
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
// SAFETY: same literal-member narrowing as statusKey above.
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey)}
</TooltipContent>
</Tooltip>
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
)}
>
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface ComposerStatusBarProps {
showTodos?: boolean;
leftAccessory?: React.ReactNode;
}
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
showTodos = true,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((todo) => todo.status === "in_progress") ||
visibleTodos.find((todo) => todo.status === "pending") ||
null
);
}, [visibleTodos]);
const progress = React.useMemo(() => {
const total = todos.filter((todo) => todo.status !== "cancelled").length;
const completed = todos.filter((todo) => todo.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasLeftAccessory = Boolean(leftAccessory);
const hasContent = hasTodoContent || hasLeftAccessory;
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{!isCompact && activeTodo ? (
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
if (!hasContent) {
return null;
}
return (
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: abort status | pending-changes accessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{leftAccessory ?? null}
</div>
{/* Right: todos dropdown */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
{todoTrigger}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150",
)}
>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
+29 -13
View File
@@ -2,18 +2,30 @@ import React from 'react';
import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import {
useWorkerHighlightedLines,
type WorkerHighlightedLinesResult,
} from '@/components/code/useWorkerHighlightedLines';
import { parseDiffToUnified } from './message/toolRenderers';
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
// back to plain text while loading or on failure.
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
html !== undefined ? (
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<span className="whitespace-pre-wrap break-all">{content}</span>
);
// Keep the line's layout stable while a cold worker request finishes. Plain
// text appears only if highlighting fails, avoiding a visible color flash.
interface CodeLineContentProps {
content: string;
html: string | undefined;
status: WorkerHighlightedLinesResult['status'];
}
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
if (status === 'ready' && html !== undefined) {
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
}
if (status === 'loading') {
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
}
return <span className="whitespace-pre-wrap break-all">{content}</span>;
};
interface DiffPreviewProps {
diff: string;
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
<div>
{hunk.lines.map((line, lineIdx) => {
const html = highlighted?.[lineCursor];
const html = highlighted.lines?.[lineCursor];
lineCursor += 1;
return (
<div
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line.content} html={html} />
<CodeLineContent content={line.content} html={html} status={highlighted.status} />
</div>
</div>
);
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
{lineIdx + 1}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
<CodeLineContent
content={line || ' '}
html={highlighted.lines?.[lineIdx]}
status={highlighted.status}
/>
</div>
</div>
))}
@@ -2,6 +2,7 @@ import React, { useRef, memo } from 'react';
import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
@@ -833,7 +834,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
<button
type="button"
onClick={() => {
useUIStore.getState().navigateToDiagram(filePath);
const directory = useDirectoryStore.getState().currentDirectory;
if (directory) {
useUIStore.getState().openContextFile(directory, filePath);
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
@@ -14,6 +14,9 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -80,7 +83,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const normalizedSearchQuery = (searchQuery ?? '').trim();
const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) {
@@ -93,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
].filter((value): value is string => typeof value === 'string' && value.length > 0);
const seen = new Set<string>();
const queryLower = normalizedSearchQuery.toLowerCase();
const mapped = ordered
.filter((filePath) => {
if (seen.has(filePath)) return false;
seen.add(filePath);
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
if (!queryLower) return true;
return relative.toLowerCase().includes(queryLower);
return matchesRankQuery([relative], normalizedSearchQuery);
})
.slice(0, 6)
.map((filePath) => {
@@ -123,9 +124,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
() => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2),
[agents, normalizedSearchQuery.length],
);
const visibleDirectories = directories;
const visibleRecentFiles = recentFiles;
const visibleFiles = files;
const visibleResults = React.useMemo(
() => rankFileMentionResults(files, directories, normalizedSearchQuery, 20),
[files, directories, normalizedSearchQuery],
);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
@@ -151,13 +154,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
if (!normalizedQueryLower) {
if (!serverQuery) {
setFiles([]);
return;
}
@@ -166,7 +165,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, {
searchFiles(currentDirectory, serverQuery, 80, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'file',
@@ -177,7 +176,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
const recentSet = new Set(recentFiles.map((file) => file.path));
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
setFiles(hits.filter((hit) => !recentSet.has(hit.path)));
})
.catch(() => {
if (!cancelled) {
@@ -209,13 +208,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
if (!normalizedQueryLower) {
if (!serverQuery) {
setDirectories([]);
return;
}
@@ -224,14 +219,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 20, {
searchFiles(currentDirectory, serverQuery, 20, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'directory',
})
.then((hits) => {
if (!cancelled) {
setDirectories(hits.slice(0, 10));
setDirectories(hits);
}
})
.catch(() => {
@@ -260,28 +255,22 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
const filtered = visibleAgents
const subagents = visibleAgents
.filter((agent) => agent.mode && agent.mode !== 'primary')
.filter((agent) => {
if (!normalizedQuery) return true;
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
return haystack.includes(normalizedQuery);
})
.map((agent) => ({
name: agent.name,
description: agent.description,
mode: agent.mode,
}))
.sort((a, b) => a.name.localeCompare(b.name));
setAgents(filtered);
setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description]));
}, [getVisibleAgents, searchQuery]);
React.useEffect(() => {
setSelectedIndex(0);
setOverflowMap({});
setMarqueeDurations({});
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
}, [visibleResults, visibleRecentFiles.length, visibleAgents.length]);
React.useEffect(() => {
selectedIndexRef.current = selectedIndex;
@@ -331,7 +320,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
window.removeEventListener('resize', updateOverflow);
};
}, [visibleFiles, visibleDirectories]);
}, [visibleResults]);
React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex];
@@ -375,7 +364,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length;
const total = visibleAgents.length + visibleRecentFiles.length + visibleResults.length;
if (total === 0) {
return;
}
@@ -399,24 +388,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
return;
}
const dirIndex = safeIndex - visibleAgents.length;
if (dirIndex < visibleDirectories.length) {
const dir = visibleDirectories[dirIndex];
if (dir) {
handleFileSelect(dir);
}
return;
}
const fileIndex = dirIndex - visibleDirectories.length;
const selectedFile = fileIndex < visibleRecentFiles.length
? visibleRecentFiles[fileIndex]
: visibleFiles[fileIndex - visibleRecentFiles.length];
const recentIndex = safeIndex - visibleAgents.length;
const selectedFile = recentIndex < visibleRecentFiles.length
? visibleRecentFiles[recentIndex]
: visibleResults[recentIndex - visibleRecentFiles.length];
if (selectedFile) {
handleFileSelect(selectedFile);
}
}
}
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
}), [visibleResults, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase();
@@ -458,6 +439,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{visibleAgents.map((agent, index) => {
const isSelected = selectedIndex === index;
return (
<AutocompleteRowTooltip description={agent.description} active={!isMobile && isSelected}>
<div
key={`agent-${agent.name}`}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -470,11 +452,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
>
<div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div>
{agent.description && !isMobile ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
@@ -482,38 +462,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
</div>
)}
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleDirectories.map((dir, index) => {
const rowIndex = visibleAgents.length + index;
const relativePath = dir.relativePath || dir.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
return (
<div
key={`dir-${dir.path}`}
ref={(el) => { itemRefs.current[rowIndex] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
isSelected && "bg-interactive-selection"
)}
onClick={() => handleFileSelect(dir)}
onMouseMove={() => setSelectedIndex(rowIndex)}
>
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
{displayPath}
</span>
</div>
);
})}
{visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
{visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleRecentFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + index;
const rowIndex = visibleAgents.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -561,11 +514,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div>
);
})}
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
{visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
<div className="my-1 border-t border-border/60" />
)}
{visibleFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index;
{visibleResults.map((file, index) => {
const rowIndex = visibleAgents.length + visibleRecentFiles.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -582,7 +535,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onClick={() => handleFileSelect(file)}
onMouseMove={() => setSelectedIndex(rowIndex)}
>
{getFileIcon(file)}
{file.kind === 'directory'
? <Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
: getFileIcon(file)}
<span
ref={(el) => { labelRefs.current[rowIndex] = el; }}
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
@@ -613,12 +568,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
);
return (
<React.Fragment key={file.path}>
<React.Fragment key={`${file.kind}-${file.path}`}>
{item}
</React.Fragment>
);
})}
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
{visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
{t('chat.fileMentionAutocomplete.empty')}
</div>
@@ -0,0 +1,294 @@
import React from 'react';
import { toast } from 'sonner';
import { Icon } from '@/components/icon/Icon';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
import {
acquireRuntimeUrlAuthToken,
refreshRuntimeUrlAuthToken,
subscribeRuntimeUrlAuthToken,
} from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { ToolPopupContent } from './message/types';
import {
extractMarkdownImageCandidates,
MAX_MARKDOWN_IMAGE_COUNT,
type MarkdownImageCandidate,
} from './markdown/markdownCore';
import {
getPreparedMarkdownImageUrl,
isLocalMarkdownImageSource,
prepareLocalMarkdownImages,
resolveMarkdownImageSource,
resolveWorkspaceMarkdownImageSource,
type PreparedMarkdownImage,
} from './markdown/markdownImageAssets';
const useAssetAuth = (enabled: boolean): { ready: boolean; nonce: number } => {
const [ready, setReady] = React.useState(false);
const [nonce, setNonce] = React.useState(0);
const apiBaseUrl = getRuntimeApiBaseUrl();
React.useEffect(() => {
if (!enabled) {
setReady(false);
return;
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
const release = acquireRuntimeUrlAuthToken(apiBaseUrl);
const unsubscribe = subscribeRuntimeUrlAuthToken(() => {
if (!cancelled) setNonce((current) => current + 1);
});
const refresh = () => {
void refreshRuntimeUrlAuthToken(apiBaseUrl)
.then(() => {
if (!cancelled) setReady(true);
})
.catch(() => {
if (!cancelled) retryTimer = setTimeout(refresh, 1000);
});
};
refresh();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
release();
unsubscribe();
};
}, [apiBaseUrl, enabled]);
return { ready: !enabled || ready, nonce };
};
const MarkdownImageThumbnail: React.FC<{
candidate: MarkdownImageCandidate;
preparation?: PreparedMarkdownImage;
directory: string;
assetAuthReady: boolean;
assetAuthNonce: number;
useWorkspaceFsBridge: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({
candidate,
preparation,
directory,
assetAuthReady,
assetAuthNonce,
useWorkspaceFsBridge,
onShowPopup,
}) => {
const { t } = useI18n();
const thumbnailRef = React.useRef<HTMLButtonElement>(null);
const [shouldLoad, setShouldLoad] = React.useState(false);
const [image, setImage] = React.useState<{ url: string; status: 'loading' | 'ready' | 'error' }>({
url: '',
status: 'loading',
});
const local = isLocalMarkdownImageSource(candidate.source);
React.useEffect(() => {
const thumbnail = thumbnailRef.current;
if (!thumbnail || shouldLoad) return;
if (typeof IntersectionObserver === 'undefined') {
setShouldLoad(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
setShouldLoad(true);
observer.disconnect();
}, { rootMargin: '200px' });
observer.observe(thumbnail);
return () => observer.disconnect();
}, [shouldLoad]);
React.useEffect(() => {
if (!shouldLoad || (local && !useWorkspaceFsBridge && !preparation)) return;
if (local && useWorkspaceFsBridge) {
const controller = new AbortController();
setImage({ url: '', status: 'loading' });
void resolveWorkspaceMarkdownImageSource(candidate.source, directory, controller.signal).then((url) => {
if (controller.signal.aborted) return;
setImage({ url, status: 'loading' });
}).catch(() => {
if (controller.signal.aborted) return;
setImage({ url: '', status: 'error' });
});
return () => controller.abort();
}
if (local) {
if (preparation?.status !== 'ready') {
setImage({ url: '', status: 'error' });
return;
}
if (!assetAuthReady) return;
setImage({ url: getPreparedMarkdownImageUrl(preparation, directory), status: 'loading' });
return;
}
const controller = new AbortController();
setImage({ url: '', status: 'loading' });
void resolveMarkdownImageSource(candidate.source, controller.signal).then((url) => {
if (controller.signal.aborted) return;
setImage({ url, status: 'loading' });
}).catch(() => {
if (controller.signal.aborted) return;
setImage({ url: '', status: 'error' });
});
return () => controller.abort();
}, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad, useWorkspaceFsBridge]);
const openPreview = React.useCallback(() => {
if (image.status === 'error') {
toast.error(t('filesView.error.previewUnavailable'));
return;
}
if (image.status !== 'ready' || !onShowPopup) return;
onShowPopup({
open: true,
title: candidate.filename,
content: '',
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
image: { url: image.url, filename: candidate.filename },
});
}, [candidate.filename, image, onShowPopup, t]);
return (
<button
ref={thumbnailRef}
type="button"
className="w-[100px] shrink-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
aria-label={candidate.filename}
disabled={image.status === 'loading'}
onClick={openPreview}
data-openchamber-markdown-image-action="true"
data-openchamber-markdown-image-source={candidate.source}
data-openchamber-markdown-image-filename={candidate.filename}
>
<span className="flex h-[72px] w-[100px] items-center justify-center overflow-hidden rounded-lg border border-border/40 bg-muted/10">
{image.url && image.status !== 'error' ? (
<img
src={image.url}
alt={candidate.filename}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onLoad={() => setImage((current) => ({ ...current, status: 'ready' }))}
onError={() => setImage({ url: '', status: 'error' })}
data-openchamber-markdown-image="true"
data-openchamber-markdown-image-thumbnail="true"
data-openchamber-markdown-image-state={image.status}
/>
) : (
<Icon name="file-image" className="h-5 w-5 text-muted-foreground" />
)}
</span>
<span
className="mt-1 flex w-[100px] items-center justify-center gap-1 text-muted-foreground"
title={candidate.filename}
data-openchamber-markdown-image-caption="true"
>
<Icon name="file-image" className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate typography-meta">{candidate.filename}</span>
</span>
</button>
);
};
export const MarkdownImageGallery: React.FC<{
sessionId?: string;
messageId: string;
contents: readonly string[];
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({ sessionId, messageId, contents, onShowPopup }) => {
const directory = useEffectiveDirectory() ?? '';
const galleryRef = React.useRef<HTMLDivElement>(null);
const [shouldPrepare, setShouldPrepare] = React.useState(false);
const [prepared, setPrepared] = React.useState<Map<string, PreparedMarkdownImage> | null>(null);
const [prepareEpoch, setPrepareEpoch] = React.useState(0);
const useWorkspaceFsBridge = isVSCodeRuntime();
const candidates = React.useMemo(
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
[contents],
);
const serverPreparationSources = React.useMemo(
() => useWorkspaceFsBridge
? []
: candidates
.filter((candidate) => isLocalMarkdownImageSource(candidate.source))
.map((candidate) => candidate.source),
[candidates, useWorkspaceFsBridge],
);
React.useEffect(() => {
if (serverPreparationSources.length === 0 || shouldPrepare) return;
const gallery = galleryRef.current;
if (!gallery || typeof IntersectionObserver === 'undefined') {
setShouldPrepare(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
setShouldPrepare(true);
observer.disconnect();
}, { rootMargin: '200px' });
observer.observe(gallery);
return () => observer.disconnect();
}, [serverPreparationSources.length, shouldPrepare]);
React.useEffect(() => {
if (!shouldPrepare || !sessionId || serverPreparationSources.length === 0) return;
const controller = new AbortController();
void prepareLocalMarkdownImages({
sources: serverPreparationSources,
directory,
sessionId,
messageId,
signal: controller.signal,
}).then((result) => {
if (controller.signal.aborted) return;
setPrepared(result);
}).catch(() => {
if (!controller.signal.aborted) {
setPrepared(new Map(serverPreparationSources.map((source) => [source, { status: 'error' }])));
}
});
return () => controller.abort();
}, [directory, messageId, prepareEpoch, serverPreparationSources, sessionId, shouldPrepare]);
React.useEffect(() => {
const nextExpiry = Math.min(...[...(prepared?.values() ?? [])]
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
if (!Number.isFinite(nextExpiry)) return;
const timer = setTimeout(() => setPrepareEpoch((current) => current + 1), Math.max(0, nextExpiry - Date.now()));
return () => clearTimeout(timer);
}, [prepared]);
const visibleCandidates = candidates.filter((candidate) => prepared?.get(candidate.source)?.status !== 'missing');
const hasPreparedAssets = [...(prepared?.values() ?? [])].some((value) => value.status === 'ready');
const assetAuth = useAssetAuth(hasPreparedAssets);
if (visibleCandidates.length === 0) return null;
return (
<div
ref={galleryRef}
className="mt-3 flex max-w-full gap-2 overflow-x-auto pb-1"
data-openchamber-markdown-image-gallery="true"
>
{visibleCandidates.map((candidate) => (
<MarkdownImageThumbnail
key={candidate.source}
candidate={candidate}
preparation={prepared?.get(candidate.source)}
directory={directory}
assetAuthReady={assetAuth.ready}
assetAuthNonce={assetAuth.nonce}
useWorkspaceFsBridge={useWorkspaceFsBridge}
onShowPopup={onShowPopup}
/>
))}
</div>
);
};
@@ -17,6 +17,10 @@ const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
);
const MarkdownImageGalleryLazy = lazyWithChunkRecovery(() =>
import('./MarkdownImageGallery').then((m) => ({ default: m.MarkdownImageGallery }))
);
const fallback = <div className="break-words w-full min-w-0" />;
const fallbackContentClassName = (variant: unknown): string => {
@@ -43,8 +47,18 @@ export const MarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof Ma
</React.Suspense>
);
export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy>> = (props) => (
<React.Suspense fallback={<MobileMarkdownFallback {...props} />}>
type SimpleMarkdownRendererProps = React.ComponentPropsWithoutRef<typeof SimpleMarkdownRendererLazy> & {
fallbackContent?: React.ReactNode;
};
export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ fallbackContent, ...props }) => (
<React.Suspense fallback={fallbackContent ?? <MobileMarkdownFallback {...props} />}>
<SimpleMarkdownRendererLazy {...props} />
</React.Suspense>
);
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
<React.Suspense fallback={null}>
<MarkdownImageGalleryLazy {...props} />
</React.Suspense>
);
@@ -0,0 +1,562 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { TextPart } from '@opencode-ai/sdk/v2';
type OperationCounts = {
innerHTMLWrites: number;
spriteIconInnerHTMLWrites: number;
querySelectorAllCalls: number;
appendCalls: number;
replaceCalls: number;
removeCalls: number;
getBoundingClientRectCalls: number;
viewBoxWrites: number;
resizeObserverCreates: number;
resizeObserverObserveCalls: number;
geometrySequence: Array<'read' | 'write'>;
};
type FixtureMetrics = OperationCounts & {
renderers: number;
markdownBlocks: number;
mermaidBlocks: number;
mermaidRenderedCount: number;
mermaidSvgCount: number;
};
const fixture = [
'# Synthetic mount fixture',
'',
'A paragraph with **bold text**, a table, and a stable link.',
'',
'| name | value |',
'| --- | ---: |',
'| alpha | 1 |',
'| beta | 2 |',
'',
'```typescript',
'const answer = 42;',
'console.log(answer);',
'```',
'',
'```mermaid',
'graph TD',
' A[Start] --> B[Finish]',
'```',
'',
'```mermaid',
'graph LR',
' Client[Client] --> Server[Server]',
'```',
].join('\n');
const fixtureWorkload = {
rendererCount: 3,
domBlocksPerRenderer: 1,
mermaidBlocksPerRenderer: 2,
};
let windowInstance: Window;
let previousGlobals: Map<string, PropertyDescriptor | undefined>;
let activeCounts: OperationCounts | null = null;
let animationFrameQueue: FrameRequestCallback[] = [];
let notifyResize: ((entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) | null = null;
let MarkdownRenderer: React.ComponentType<{
content: string;
messageId: string;
part?: TextPart;
isAnimated?: boolean;
isStreaming?: boolean;
enableFileReferences?: boolean;
}>;
let clearDetachedMarkdownDomCache: () => void;
let detachedMarkdownDomCacheStats: () => { sessions: number; entries: number };
const makeCounts = (): OperationCounts => ({
innerHTMLWrites: 0,
spriteIconInnerHTMLWrites: 0,
querySelectorAllCalls: 0,
appendCalls: 0,
replaceCalls: 0,
removeCalls: 0,
getBoundingClientRectCalls: 0,
viewBoxWrites: 0,
resizeObserverCreates: 0,
resizeObserverObserveCalls: 0,
geometrySequence: [],
});
const installGlobal = (name: string, value: Window[keyof Window]): void => {
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
const waitForSettledEffects = async (): Promise<void> => {
await new Promise<void>((resolve) => setTimeout(resolve, 25));
await Promise.resolve();
};
const flushAnimationFrame = async (): Promise<void> => {
const callbacks = animationFrameQueue;
animationFrameQueue = [];
await act(async () => {
for (const callback of callbacks) callback(windowInstance.performance.now());
await Promise.resolve();
});
};
const flushDeferredMermaidInitialization = async (): Promise<void> => {
await flushAnimationFrame();
await flushAnimationFrame();
};
const mountFixture = async (rendererCount: number): Promise<{
root: Root;
host: HTMLDivElement;
operations: OperationCounts;
counts: FixtureMetrics;
}> => {
const counts = makeCounts();
activeCounts = counts;
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
await act(async () => {
root.render(
<>
{Array.from({ length: rendererCount }, (_, index) => (
<MarkdownRenderer
key={`fixture-${index}`}
content={fixture}
messageId={`fixture-message-${index}`}
isAnimated={false}
enableFileReferences={false}
/>
))}
</>,
);
await waitForSettledEffects();
});
await act(async () => waitForSettledEffects());
const mermaidBlocks = host.querySelectorAll('[data-markdown="mermaid-block"]').length;
const mermaidRenderedCount = host.querySelectorAll('[data-mermaid-render]').length;
const mermaidSvgCount = host.querySelectorAll('[data-markdown="mermaid"] svg').length;
return {
root,
host,
operations: counts,
counts: {
...counts,
renderers: rendererCount,
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
mermaidBlocks,
mermaidRenderedCount,
mermaidSvgCount,
},
};
};
const runFixture = async (rendererCount: number): Promise<FixtureMetrics> => {
const { root, host, operations } = await mountFixture(rendererCount);
await flushDeferredMermaidInitialization();
const counts: FixtureMetrics = {
...operations,
renderers: rendererCount,
markdownBlocks: host.querySelectorAll('[data-md-block]').length,
mermaidBlocks: host.querySelectorAll('[data-markdown="mermaid-block"]').length,
mermaidRenderedCount: host.querySelectorAll('[data-mermaid-render]').length,
mermaidSvgCount: host.querySelectorAll('[data-markdown="mermaid"] svg').length,
};
await act(async () => root.unmount());
return counts;
};
const initializePerformanceDom = async (): Promise<void> => {
windowInstance = new Window({ url: 'http://localhost/' });
windowInstance.document.write('<!doctype html><html><head></head><body></body></html>');
windowInstance.document.close();
previousGlobals = new Map();
installGlobal('window', windowInstance);
installGlobal('document', windowInstance.document);
installGlobal('navigator', windowInstance.navigator);
installGlobal('customElements', windowInstance.customElements);
for (const name of ['Document', 'Element', 'HTMLElement', 'SVGElement', 'Node', 'Text', 'NodeFilter', 'MutationObserver', 'DOMParser', 'XMLSerializer', 'HTMLAnchorElement', 'HTMLButtonElement']) {
// SAFETY: these names are the DOM constructors installed by this happy-dom Window.
const globalValue = windowInstance[name as keyof Window];
if (globalValue === undefined) throw new Error(`happy-dom global is unavailable: ${name}`);
installGlobal(name, globalValue);
}
Object.defineProperty(windowInstance, 'matchMedia', { configurable: true, value: () => ({ matches: false, media: '', onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false }) });
Object.defineProperty(windowInstance, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => {
animationFrameQueue.push(callback);
return animationFrameQueue.length;
} });
Object.defineProperty(windowInstance, 'cancelAnimationFrame', { configurable: true, value: () => undefined });
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const elementPrototype = Element.prototype;
const nodePrototype = Node.prototype;
const documentPrototype = Document.prototype;
const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
if (!innerHTMLDescriptor?.set || !innerHTMLDescriptor.get) throw new Error('happy-dom innerHTML descriptor unavailable');
Object.defineProperty(Element.prototype, 'innerHTML', {
configurable: true,
get: innerHTMLDescriptor.get,
set(value: string) {
if (activeCounts) {
activeCounts.innerHTMLWrites += 1;
if (value.includes('href="#oc-')) activeCounts.spriteIconInnerHTMLWrites += 1;
}
innerHTMLDescriptor.set?.call(this, value);
},
});
const originalQuerySelectorAll = elementPrototype.querySelectorAll;
Object.defineProperty(elementPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
return originalQuerySelectorAll.call(this, selectors);
} });
const originalDocumentQuerySelectorAll = documentPrototype.querySelectorAll;
Object.defineProperty(documentPrototype, 'querySelectorAll', { configurable: true, value: function (selectors: string): NodeListOf<Element> {
if (activeCounts) activeCounts.querySelectorAllCalls += 1;
return originalDocumentQuerySelectorAll.call(this, selectors);
} });
const originalAppendChild = nodePrototype.appendChild;
Object.defineProperty(nodePrototype, 'appendChild', { configurable: true, value: function (node: Node): Node {
if (activeCounts) activeCounts.appendCalls += 1;
return originalAppendChild.call(this, node);
} });
const originalReplaceWith = elementPrototype.replaceWith;
Object.defineProperty(elementPrototype, 'replaceWith', { configurable: true, value: function (...nodes: (Node | string)[]): void {
if (activeCounts) activeCounts.replaceCalls += 1;
return originalReplaceWith.apply(this, nodes);
} });
const originalRemove = elementPrototype.remove;
Object.defineProperty(elementPrototype, 'remove', { configurable: true, value: function (): void {
if (activeCounts) activeCounts.removeCalls += 1;
return originalRemove.call(this);
} });
const originalGetBoundingClientRect = elementPrototype.getBoundingClientRect;
Object.defineProperty(elementPrototype, 'getBoundingClientRect', { configurable: true, value: function (): DOMRect {
if (activeCounts) {
activeCounts.getBoundingClientRectCalls += 1;
activeCounts.geometrySequence.push('read');
}
return originalGetBoundingClientRect.call(this);
} });
const svgSetAttribute = SVGElement.prototype.setAttribute;
Object.defineProperty(SVGElement.prototype, 'setAttribute', { configurable: true, value: function (name: string, value: string): void {
if (name === 'viewBox' && activeCounts && this.closest('[data-markdown="mermaid"]')) {
activeCounts.viewBoxWrites += 1;
activeCounts.geometrySequence.push('write');
}
return svgSetAttribute.call(this, name, value);
} });
class CountingResizeObserver {
constructor(callback: (entries: Array<{ target: Element; contentRect: { width: number; height: number } }>) => void) {
if (activeCounts) activeCounts.resizeObserverCreates += 1;
notifyResize = callback;
}
observe(): void {
if (activeCounts) activeCounts.resizeObserverObserveCalls += 1;
}
unobserve(): void {}
disconnect(): void {}
}
installGlobal('ResizeObserver', CountingResizeObserver);
const fakeState = {
openContextPreview: () => undefined,
codeBlockLineWrap: false,
mermaidRenderingMode: 'svg',
};
type UIStateSelection = typeof fakeState[keyof typeof fakeState];
const { mock } = await import('bun:test');
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }));
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => null }));
mock.module('@/stores/useUIStore', () => ({ useUIStore: Object.assign((selector: (state: typeof fakeState) => UIStateSelection) => selector(fakeState), { getState: () => fakeState }) }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '', normalizeFilePath: (value: string) => value, isAbsoluteFilePath: (value: string) => value.startsWith('/') }));
mock.module('@/lib/clipboard', () => ({ copyTextToClipboard: async () => undefined }));
mock.module('beautiful-mermaid', () => ({
renderMermaidASCII: () => 'diagram',
renderMermaidSVG: () => '<svg viewBox="0 0 240 120" width="240" height="120"><path d="M0 0h1v1z" /></svg>',
}));
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
mock.module('./markdown/markdown-worker', () => ({
highlightCodeInWorker: async () => null,
highlightLinesInWorker: async () => null,
highlightTokensInWorker: async () => null,
}));
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: React.ReactNode }) => children }));
const imported = await import('./MarkdownRendererImpl');
MarkdownRenderer = imported.MarkdownRenderer;
const { detachedMarkdownDomCache } = await import('./markdown/detachedMarkdownDomCache');
clearDetachedMarkdownDomCache = () => detachedMarkdownDomCache.clear();
detachedMarkdownDomCacheStats = () => detachedMarkdownDomCache.stats();
};
await initializePerformanceDom();
afterAll(() => {
for (const [name, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
describe('MarkdownRenderer DOM mount performance contract', () => {
test('builds Markdown sprite controls without parsing SVG markup', async () => {
const mounted = await mountFixture(1);
const spriteControlCount = mounted.host.querySelectorAll('[data-md-action] use[href^="#oc-"]').length;
const spriteIconInnerHTMLWrites = mounted.operations.spriteIconInnerHTMLWrites;
await act(async () => mounted.root.unmount());
expect(spriteControlCount).toBeGreaterThan(0);
expect(spriteIconInnerHTMLWrites).toBe(0);
});
test('reuses settled Markdown DOM without parsing or decorating it again', async () => {
clearDetachedMarkdownDomCache();
const content = '# Cached viewport\n\nA settled paragraph.';
const part: TextPart = {
id: 'part-cache',
sessionID: 'session-cache',
messageID: 'message-cache',
type: 'text',
text: content,
time: { start: 0, end: 1 },
};
const host = document.createElement('div');
document.body.replaceChildren(host);
const render = (root: Root) => root.render(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={part}
isAnimated={false}
enableFileReferences={false}
/>,
);
const firstCounts = makeCounts();
activeCounts = firstCounts;
const firstRoot = createRoot(host);
await act(async () => {
render(firstRoot);
await waitForSettledEffects();
});
const originalBlock = host.querySelector('[data-md-block]');
expect(originalBlock).not.toBeNull();
expect(firstCounts.innerHTMLWrites).toBeGreaterThan(0);
await act(async () => firstRoot.unmount());
const secondCounts = makeCounts();
activeCounts = secondCounts;
const secondRoot = createRoot(host);
await act(async () => {
render(secondRoot);
await waitForSettledEffects();
});
expect(host.querySelector('[data-md-block]')).toBe(originalBlock);
expect(secondCounts.innerHTMLWrites).toBe(0);
await act(async () => secondRoot.unmount());
clearDetachedMarkdownDomCache();
});
test('does not cache streaming, unfinished, or Mermaid DOM', async () => {
clearDetachedMarkdownDomCache();
const host = document.createElement('div');
document.body.replaceChildren(host);
const renderScoped = (
root: Root,
content: string,
partId: string,
isStreaming = false,
) => root.render(
<MarkdownRenderer
content={content}
messageId="message-cache"
part={{
id: partId,
sessionID: 'session-cache',
messageID: 'message-cache',
type: 'text',
text: content,
time: { start: 0, end: 1 },
}}
isAnimated={false}
isStreaming={isStreaming}
enableFileReferences={false}
/>,
);
const streamingRoot = createRoot(host);
await act(async () => {
renderScoped(streamingRoot, 'streaming content', 'part-streaming', true);
await waitForSettledEffects();
});
await act(async () => streamingRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
const unfinalizedRoot = createRoot(host);
await act(async () => {
unfinalizedRoot.render(
<MarkdownRenderer
content="unfinalized content"
messageId="message-unfinalized"
part={{
id: 'part-unfinalized',
sessionID: 'session-cache',
messageID: 'message-unfinalized',
type: 'text',
text: 'unfinalized content',
}}
isAnimated={false}
enableFileReferences={false}
/>,
);
await waitForSettledEffects();
});
await act(async () => unfinalizedRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
const mermaidRoot = createRoot(host);
await act(async () => {
renderScoped(mermaidRoot, '```mermaid\ngraph TD\nA --> B\n```', 'part-mermaid');
await waitForSettledEffects();
});
await act(async () => mermaidRoot.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
clearDetachedMarkdownDomCache();
});
test('does not detach Markdown DOM that intersects the active selection', async () => {
clearDetachedMarkdownDomCache();
const content = 'selected content';
const host = document.createElement('div');
document.body.replaceChildren(host);
const root = createRoot(host);
await act(async () => {
root.render(
<MarkdownRenderer
content={content}
messageId="message-selected"
part={{
id: 'part-selected',
sessionID: 'session-selected',
messageID: 'message-selected',
type: 'text',
text: content,
time: { start: 0, end: 1 },
}}
isAnimated={false}
enableFileReferences={false}
/>,
);
await waitForSettledEffects();
});
const markdown = host.querySelector<HTMLElement>('[data-markdown-content]');
if (!markdown) throw new Error('Expected mounted Markdown content');
const originalGetSelection = window.getSelection;
Object.defineProperty(window, 'getSelection', {
configurable: true,
value: () => ({
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => ({ intersectsNode: (node: Node) => node === markdown }),
}),
});
try {
await act(async () => root.unmount());
expect(detachedMarkdownDomCacheStats().entries).toBe(0);
} finally {
Object.defineProperty(window, 'getSelection', { configurable: true, value: originalGetSelection });
clearDetachedMarkdownDomCache();
}
});
test('defers and batches Mermaid controller initialization after Markdown mount', async () => {
const mounted = await mountFixture(fixtureWorkload.rendererCount);
const critical = mounted.counts;
expect(critical.getBoundingClientRectCalls).toBe(0);
expect(critical.viewBoxWrites).toBe(0);
expect(critical.resizeObserverCreates).toBe(0);
expect(mounted.host.querySelectorAll('[data-markdown="mermaid"] svg')).toHaveLength(6);
await flushDeferredMermaidInitialization();
const metrics = {
...mounted.operations,
renderers: fixtureWorkload.rendererCount,
markdownBlocks: mounted.host.querySelectorAll('[data-md-block]').length,
mermaidBlocks: mounted.host.querySelectorAll('[data-markdown="mermaid-block"]').length,
mermaidRenderedCount: mounted.host.querySelectorAll('[data-mermaid-render]').length,
mermaidSvgCount: mounted.host.querySelectorAll('[data-markdown="mermaid"] svg').length,
};
expect(metrics.renderers).toBe(3);
expect(metrics.markdownBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.domBlocksPerRenderer);
expect(metrics.mermaidBlocks).toBe(fixtureWorkload.rendererCount * fixtureWorkload.mermaidBlocksPerRenderer);
expect(metrics.mermaidRenderedCount).toBeGreaterThan(0);
expect(metrics.innerHTMLWrites).toBeGreaterThan(0);
expect(metrics.querySelectorAllCalls).toBeGreaterThan(0);
expect(metrics.appendCalls).toBeGreaterThan(0);
expect(metrics.getBoundingClientRectCalls).toBe(metrics.mermaidRenderedCount);
expect(metrics.viewBoxWrites).toBe(metrics.mermaidRenderedCount);
expect(metrics.resizeObserverCreates).toBe(1);
expect(metrics.resizeObserverObserveCalls).toBe(metrics.mermaidRenderedCount);
expect(metrics.geometrySequence.lastIndexOf('read')).toBeLessThan(metrics.geometrySequence.indexOf('write'));
const viewport = mounted.host.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]');
if (!viewport || !notifyResize) throw new Error('Expected initialized Mermaid viewport and shared observer');
const readsBeforeResize = mounted.operations.getBoundingClientRectCalls;
const writesBeforeResize = mounted.operations.viewBoxWrites;
notifyResize([{ target: viewport, contentRect: { width: 320, height: 180 } }]);
expect(mounted.operations.getBoundingClientRectCalls).toBe(readsBeforeResize);
expect(mounted.operations.viewBoxWrites).toBe(writesBeforeResize + 1);
console.log(JSON.stringify({ fixture: fixtureWorkload, baseline: metrics }));
await act(async () => mounted.root.unmount());
});
test('cancels deferred Mermaid initialization when the renderer unmounts first', async () => {
const mounted = await mountFixture(1);
await act(async () => mounted.root.unmount());
await flushDeferredMermaidInitialization();
expect(mounted.operations.getBoundingClientRectCalls).toBe(0);
expect(mounted.operations.viewBoxWrites).toBe(0);
expect(mounted.operations.resizeObserverCreates).toBe(0);
});
test('keeps DOM operation fanout linear when renderer count doubles', async () => {
const three = await runFixture(3);
const six = await runFixture(6);
expect(six.mermaidBlocks).toBe(three.mermaidBlocks * 2);
expect(six.mermaidRenderedCount).toBe(three.mermaidRenderedCount * 2);
expect(six.innerHTMLWrites).toBeLessThanOrEqual(three.innerHTMLWrites * 2 + 6);
expect(six.querySelectorAllCalls).toBeLessThanOrEqual(three.querySelectorAllCalls * 2 + 12);
expect(six.appendCalls).toBeLessThanOrEqual(three.appendCalls * 2 + 12);
expect(six.getBoundingClientRectCalls).toBe(three.getBoundingClientRectCalls * 2);
expect(six.viewBoxWrites).toBe(three.viewBoxWrites * 2);
expect(three.resizeObserverCreates).toBe(1);
expect(six.resizeObserverCreates).toBe(1);
expect(six.resizeObserverObserveCalls).toBe(three.resizeObserverObserveCalls * 2);
});
});
@@ -1,9 +1,377 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { parseFileReference, type ParsedFileReference } from './fileReferenceParser';
import { localPathFromFileUrl, parseFileReference, type ParsedFileReference } from './fileReferenceParser';
const parse = (value: string): ParsedFileReference | null => parseFileReference(value);
type FakeElement = {
childNodes: FakeElement[];
children: FakeElement[];
parentNode: FakeElement | null;
attributes: Map<string, string>;
style: { display: string; setProperty: () => void };
innerHTML: string;
setAttribute: (name: string, value: string) => void;
getAttribute: (name: string) => string | null;
appendChild: (child: FakeElement) => FakeElement;
replaceWith: (replacement: FakeElement) => void;
remove: () => void;
querySelector: (selector: string) => FakeElement | null;
querySelectorAll: <T>(selector: string) => T[];
addEventListener: () => void;
removeEventListener: () => void;
contains: (child: FakeElement) => boolean;
isEqualNode: () => boolean;
};
type FakeDocument = { createElement: () => FakeElement };
type FakeJsxProps = {
ref?: { current: FakeElement | null };
children?: FakeElement | FakeElement[];
className?: string;
'data-markdown-content'?: boolean;
};
let syncRenderCalls = 0;
let morphCalls = 0;
let decorateCalls = 0;
let mermaidRegistryCreates = 0;
let mermaidRegistryCleanups = 0;
let cachedRendererBlocks: Array<{ id: string; html: string }> | null = null;
let renderedRendererBlocks: Array<{ id: string; html: string }> = [];
let renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
let currentContextVersion = 0;
const layoutEffects: Array<() => void> = [];
const passiveEffects: Array<() => void | (() => void)> = [];
let hookCursor = 0;
let hookStates: Array<{ current: null } | undefined> = [];
let activeFakeDocument: FakeDocument | null = null;
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
void ownerDocument;
let html = '';
const element: FakeElement = {
childNodes: [],
children: [],
parentNode: null,
attributes: new Map(),
style: { display: '', setProperty: () => undefined },
get innerHTML() {
return html;
},
set innerHTML(value: string) {
html = value;
},
setAttribute(name, value) {
this.attributes.set(name, value);
},
getAttribute(name) {
return this.attributes.get(name) ?? null;
},
appendChild(child) {
child.parentNode = this;
this.childNodes.push(child);
this.children.push(child);
return child;
},
replaceWith(replacement) {
if (!this.parentNode) return;
const parent = this.parentNode;
const index = parent.children.indexOf(this);
if (index < 0) return;
replacement.parentNode = parent;
parent.children[index] = replacement;
parent.childNodes[index] = replacement;
this.parentNode = null;
},
remove() {
if (!this.parentNode) return;
const parent = this.parentNode;
parent.children = parent.children.filter((child) => child !== this);
parent.childNodes = parent.childNodes.filter((child) => child !== this);
this.parentNode = null;
},
querySelector(selector) {
if (selector === '[data-markdown-content]') {
return this.children.find((child) => child.getAttribute('data-markdown-content') === '') ?? null;
}
if (selector === '[data-markdown="mermaid-block"]' && html.includes('data-markdown="mermaid-block"')) {
return this;
}
for (const child of this.children) {
const match = child.querySelector(selector);
if (match) return match;
}
return null;
},
querySelectorAll: () => [],
addEventListener: () => undefined,
removeEventListener: () => undefined,
contains(child) {
return child === this || this.children.some((candidate) => candidate.contains(child));
},
isEqualNode: () => false,
};
return element;
};
const installRendererDom = () => {
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousMutationObserver = Object.getOwnPropertyDescriptor(globalThis, 'MutationObserver');
const documentStub: FakeDocument = { createElement: () => makeFakeElement(documentStub) };
activeFakeDocument = documentStub;
Object.defineProperty(globalThis, 'document', { configurable: true, value: documentStub });
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
matchMedia: () => ({ matches: false }),
setTimeout,
clearTimeout,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
},
});
Object.defineProperty(globalThis, 'MutationObserver', {
configurable: true,
value: class {
observe() {}
disconnect() {}
},
});
return () => {
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
else Reflect.deleteProperty(globalThis, 'document');
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousMutationObserver) Object.defineProperty(globalThis, 'MutationObserver', previousMutationObserver);
else Reflect.deleteProperty(globalThis, 'MutationObserver');
activeFakeDocument = null;
};
};
const rendererThemes = [{
metadata: { id: 'renderer-test' },
colors: {
surface: { elevated: '#fff', foreground: '#000', mutedForeground: '#666', muted: '#eee' },
interactive: { border: '#ccc' },
primary: { base: '#00f' },
},
}, {
metadata: { id: 'renderer-test-next' },
colors: {
surface: { elevated: '#eee', foreground: '#111', mutedForeground: '#555', muted: '#ddd' },
interactive: { border: '#bbb' },
primary: { base: '#f00' },
},
}];
let rendererThemeIndex = 0;
const rendererTheme = () => rendererThemes[rendererThemeIndex] ?? rendererThemes[0];
const rendererUiState = {
codeBlockLineWrap: false,
mermaidRenderingMode: 'svg',
setCodeBlockLineWrap: () => undefined,
openContextPreview: () => undefined,
};
const fakeReact = {
useCallback: <T>(callback: T): T => {
hookCursor += 1;
return callback;
},
useEffect: (effect: () => void | (() => void)) => { passiveEffects.push(effect); },
useLayoutEffect: (effect: () => void) => { layoutEffects.push(effect); },
useMemo: <T>(factory: () => T): T => {
hookCursor += 1;
return factory();
},
useRef: <T>(current: T) => {
void current;
const index = hookCursor;
hookCursor += 1;
if (!hookStates[index]) hookStates[index] = { current: null };
// SAFETY: this test hook preserves one mutable ref slot per hook index.
return hookStates[index] as { current: T };
},
memo: <T>(component: T): T => component,
};
const fakeJsx = (_type: string, props: FakeJsxProps | null, ...children: FakeElement[]): FakeElement => {
const ref = props?.ref;
// SAFETY: the renderer test installs the typed fake document before JSX is
// evaluated; this branch only supplies its fake element factory.
const fakeDocument = activeFakeDocument;
if (!fakeDocument) throw new Error('Renderer fake document is not installed');
const element = ref?.current ?? makeFakeElement(fakeDocument);
if (!ref?.current) {
element.childNodes.length = 0;
element.children.length = 0;
}
if (props) {
if (ref) ref.current = element;
if (props.className) element.setAttribute('class', props.className);
if (props['data-markdown-content']) element.setAttribute('data-markdown-content', '');
}
const jsxChildren = props?.children;
const allChildren = jsxChildren === undefined ? children : Array.isArray(jsxChildren) ? jsxChildren : [jsxChildren];
for (const child of allChildren) {
if (child) element.appendChild(child);
}
return element;
};
mock.module('react', () => ({ default: fakeReact }));
mock.module('react/jsx-runtime', () => ({ jsx: fakeJsx, jsxs: fakeJsx, Fragment: 'fragment' }));
mock.module('react/jsx-dev-runtime', () => ({ jsxDEV: fakeJsx, Fragment: 'fragment' }));
mock.module('beautiful-mermaid', () => ({
renderMermaidASCII: () => '',
renderMermaidSVG: (_source: string, colors: { bg: string }) => colors.bg,
}));
mock.module('@/lib/utils', () => ({ cn: (...values: string[]) => values.filter(Boolean).join(' ') }));
mock.module('@/lib/i18n', () => ({ useI18n: () => ({ t: (key: string) => `${key}:${currentContextVersion}` }) }));
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
mock.module('@/lib/url', () => ({
getUrlScheme: () => null,
isAppLinkUrl: () => false,
isExternalHttpUrl: () => false,
openConfirmedAppLinkUrl: async () => false,
openExternalUrl: async () => undefined,
}));
mock.module('@/contexts/useThemeSystem', () => ({ useOptionalThemeSystem: () => ({ currentTheme: rendererTheme() }) }));
mock.module('@/lib/theme/themes', () => ({ getDefaultTheme: () => rendererTheme() }));
mock.module('./message/FadeInOnReveal', () => ({ FadeInOnReveal: ({ children }: { children: FakeElement | FakeElement[] }) => children }));
type RendererUiSelectorResult = boolean | string | (() => void);
const fakeUseUIStore = Object.assign(
(selector: (state: typeof rendererUiState) => RendererUiSelectorResult) => selector(rendererUiState),
{ getState: () => rendererUiState },
);
mock.module('@/stores/useUIStore', () => ({ useUIStore: fakeUseUIStore }));
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
mock.module('@/lib/path-utils', () => ({ getDirectoryForFilePath: () => '', isFilePathWithinDirectory: () => true, toAbsoluteFilePath: () => '' }));
mock.module('./markdown/markdownCore', () => ({
getCachedMarkdownBlocks: () => cachedRendererBlocks,
renderMarkdownBlocks: () => renderMarkdownBlocksForTest(),
renderMarkdownSync: () => {
syncRenderCalls += 1;
return '<p>cold</p>';
},
}));
mock.module('./markdown/markdownTheme', () => ({ ensureMarkdownShikiTheme: () => undefined }));
mock.module('./markdown/markdownSyntaxVars', () => ({ getMarkdownSyntaxVars: () => ({}) }));
mock.module('./markdown/detachedMarkdownDomCache', () => ({
detachedMarkdownDomCache: {
take: () => null,
store: () => undefined,
},
}));
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
type TestDecorateContext = {
labels: { copy: string };
codeBlockLineWrap: boolean;
renderMermaid: (source: string) => { svg?: string };
};
mock.module('./markdown/decorate', () => ({
attachMarkdownInteractions: () => () => undefined,
applyMarkdownCodeBlockWrapState: () => undefined,
decorateMarkdown: (root: FakeElement, ctx: TestDecorateContext) => {
decorateCalls += 1;
if (root.getAttribute('data-test-decoration-marker') === 'true') return;
root.setAttribute('data-test-decoration-marker', 'true');
root.setAttribute(
'data-test-decoration',
`${ctx.labels.copy}|${ctx.codeBlockLineWrap}|${ctx.renderMermaid('test').svg ?? ''}`,
);
},
getMarkdownCodeText: () => '',
}));
mock.module('./markdown/textPosition', () => ({ findTextPosition: () => null }));
mock.module('./markdown/mermaidViewer', () => ({
createMermaidViewerRegistry: () => {
mermaidRegistryCreates += 1;
return {
refresh: () => undefined,
cleanup: () => { mermaidRegistryCleanups += 1; },
};
},
MERMAID_BLOCK_SELECTOR: '[data-markdown="mermaid-block"]',
shouldRefreshMermaidViewers: (container: Pick<FakeElement, 'querySelector'>) => container.querySelector('[data-markdown="mermaid-block"]') !== null,
}));
mock.module('@/stores/utils/streamDebug', () => ({ streamPerfCount: () => undefined, streamPerfObserve: () => undefined }));
mock.module('morphdom', () => ({ default: () => { morphCalls += 1; } }));
const { MarkdownRenderer } = await import('./MarkdownRendererImpl');
const resetRendererTestState = () => {
cachedRendererBlocks = null;
renderedRendererBlocks = [];
renderMarkdownBlocksForTest = async () => renderedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
hookCursor = 0;
hookStates = [];
layoutEffects.length = 0;
passiveEffects.length = 0;
currentContextVersion = 0;
rendererThemeIndex = 0;
rendererUiState.codeBlockLineWrap = false;
};
const beginRendererRender = () => {
hookCursor = 0;
return renderMarkdownForTest();
};
const rendererRoot = (value: ReturnType<typeof renderMarkdownForTest>): FakeElement => {
if (!(value instanceof Object) || !('childNodes' in value) || !('getAttribute' in value)) {
throw new Error('Renderer test did not return its fake JSX root');
}
// SAFETY: the structural check confirms this ReactNode is the object
// returned by the mocked JSX runtime.
const candidate = value as object;
// SAFETY: the mocked JSX runtime creates the complete FakeElement shape.
return candidate as FakeElement;
};
const runRendererLayoutEffects = () => {
const pending = layoutEffects.splice(0);
for (const effect of pending) effect();
};
const runRendererPassiveEffects = () => passiveEffects.splice(0).map((effect) => effect());
const findBlock = (root: FakeElement, id: string): FakeElement | null => {
if (root.getAttribute('data-md-id') === id) return root;
for (const child of root.children) {
const match = findBlock(child, id);
if (match) return match;
}
return null;
};
const renderMarkdownForTest = () => MarkdownRenderer({
content: 'cached markdown',
messageId: 'message-1',
isAnimated: false,
isStreaming: false,
});
const withRendererDom = async (run: () => void | Promise<void>): Promise<void> => {
const restoreDom = installRendererDom();
const previousThemeIndex = rendererThemeIndex;
try {
await run();
} finally {
rendererThemeIndex = previousThemeIndex;
restoreDom();
}
};
describe('parseFileReference', () => {
test('returns null for empty or whitespace input', () => {
expect(parse('')).toBeNull();
@@ -72,11 +440,7 @@ describe('parseFileReference', () => {
});
test('preserves line:col form (does not interpret as range)', () => {
expect(parse('src/foo.ts:42:8')).toEqual({
path: 'src/foo.ts',
line: 42,
column: 8,
});
expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 });
});
test('preserves hash form', () => {
@@ -96,3 +460,134 @@ describe('parseFileReference', () => {
expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 });
});
});
describe('localPathFromFileUrl', () => {
test('converts local file URLs to absolute paths', () => {
expect(localPathFromFileUrl('file:///private/tmp/report%20viewer.html')).toBe('/private/tmp/report viewer.html');
expect(localPathFromFileUrl('file://localhost/private/tmp/REPORT.md')).toBe('/private/tmp/REPORT.md');
expect(localPathFromFileUrl('file:///C:/Users/test/report.html')).toBe('C:/Users/test/report.html');
});
test('rejects non-file URLs and remote file hosts', () => {
expect(localPathFromFileUrl('https://example.com/report.html')).toBeNull();
expect(localPathFromFileUrl('file://remote-host/share/report.html')).toBeNull();
expect(localPathFromFileUrl('file:///tmp/bad%ZZpath')).toBeNull();
});
});
describe('MarkdownRenderer warm settled path', () => {
test('installs cached blocks without sync fallback and skips same-ID morph', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{ id: 'full:cached', html: '<p>cached</p>' }];
renderedRendererBlocks = cachedRendererBlocks;
syncRenderCalls = 0;
morphCalls = 0;
decorateCalls = 0;
// SAFETY: the test JSX adapter returns the fake element assigned to the
// renderer container ref and exposes the DOM members used below.
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(syncRenderCalls).toBe(0);
const block = findBlock(root, 'full:cached');
expect(block).not.toBeNull();
expect(block?.innerHTML).toBe('<p>cached</p>');
expect(block?.getAttribute('data-md-block')).toBe('');
expect(block?.getAttribute('data-md-id')).toBe('full:cached');
expect(block?.style.display).toBe('contents');
expect(decorateCalls).toBe(1);
runRendererPassiveEffects();
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
test('recreates the Mermaid registry after StrictMode-like cleanup without remounting blocks', () => {
return withRendererDom(() => {
resetRendererTestState();
const mermaidHtml = '<div data-markdown="mermaid-block"><svg></svg></div>';
cachedRendererBlocks = [{ id: 'full:mermaid', html: mermaidHtml }];
renderedRendererBlocks = cachedRendererBlocks;
mermaidRegistryCreates = 0;
mermaidRegistryCleanups = 0;
morphCalls = 0;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(1);
const cleanups = runRendererPassiveEffects();
for (const cleanup of cleanups) cleanup?.();
expect(mermaidRegistryCleanups).toBe(1);
beginRendererRender();
runRendererLayoutEffects();
expect(mermaidRegistryCreates).toBe(2);
expect(findBlock(root, 'full:mermaid')).not.toBeNull();
expect(morphCalls).toBe(0);
});
});
test('redecorates a same-ID block when decoration context changes before async completion', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{
id: 'full:context',
html: '<div data-markdown="mermaid-block"><p>cached</p></div>',
}];
renderedRendererBlocks = cachedRendererBlocks;
const root = rendererRoot(beginRendererRender());
runRendererLayoutEffects();
const block = findBlock(root, 'full:context');
const firstDecorationId = block?.getAttribute('data-md-decoration-id');
expect(firstDecorationId).not.toBeNull();
const firstDecorateCalls = decorateCalls;
rendererThemeIndex = 1;
currentContextVersion = 1;
rendererUiState.codeBlockLineWrap = true;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
await Promise.resolve();
expect(decorateCalls).toBeGreaterThan(firstDecorateCalls);
expect(syncRenderCalls).toBe(0);
expect(morphCalls).toBe(0);
const updatedBlock = findBlock(root, 'full:context');
expect(updatedBlock?.getAttribute('data-md-decoration-id')).not.toBe(firstDecorationId);
expect(updatedBlock?.getAttribute('data-test-decoration')).toContain(':1|true|#eee');
expect(updatedBlock?.getAttribute('data-test-decoration-marker')).toBe('true');
expect(mermaidRegistryCleanups).toBeGreaterThan(0);
expect(mermaidRegistryCreates).toBeGreaterThan(1);
});
});
test('rejects an older async render after a newer layout commit', async () => {
await withRendererDom(async () => {
resetRendererTestState();
cachedRendererBlocks = [{ id: 'full:initial', html: '<p>initial</p>' }];
let resolveOldRender: ((blocks: Array<{ id: string; html: string }>) => void) | undefined;
const oldRender = new Promise<Array<{ id: string; html: string }>>((resolve) => {
resolveOldRender = resolve;
});
renderMarkdownBlocksForTest = () => oldRender;
beginRendererRender();
runRendererLayoutEffects();
runRendererPassiveEffects();
cachedRendererBlocks = [{ id: 'full:new', html: '<p>new</p>' }];
beginRendererRender();
runRendererLayoutEffects();
expect(resolveOldRender).toBeDefined();
resolveOldRender?.([{ id: 'full:old-late', html: '<p>old late</p>' }]);
await Promise.resolve();
expect(morphCalls).toBe(0);
});
});
});
@@ -4,11 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { Theme } from '@/types/theme';
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
import { attachAppLinkInteractions } from './appLinkInteractions';
import type { ToolPopupContent } from './message/types';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import { useUIStore } from '@/stores/useUIStore';
@@ -19,8 +20,14 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import {
getCachedMarkdownBlocks,
renderMarkdownBlocks,
renderMarkdownSync,
type MarkdownImageMode,
} from './markdown/markdownCore';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
attachMarkdownInteractions,
applyMarkdownCodeBlockWrapState,
@@ -36,11 +43,15 @@ import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMerma
import {
BLOCK_PATH_TOKEN_RE,
isAbsoluteReferencePath,
localPathFromFileUrl,
normalizeReferencePath,
parseFileReference,
type ParsedFileReference,
} from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
import { getRuntimeKey } from '@/lib/runtime-switch';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -53,7 +64,7 @@ const useCurrentMermaidTheme = () => {
: fallbackLight);
};
const useExternalLinkInteractions = ({
const useLinkInteractions = ({
containerRef,
enabled,
}: {
@@ -61,48 +72,16 @@ const useExternalLinkInteractions = ({
enabled?: boolean;
}) => {
React.useEffect(() => {
if (enabled === false) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(href);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
return attachAppLinkInteractions(container, {
allowExternalHttp: enabled !== false,
openAppLink: (href) => void openAppLinkWithConfirmation(href),
openExternalHttp: (href) => void openExternalUrl(href),
});
}, [containerRef, enabled]);
};
@@ -149,19 +128,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
// output. The regex is defined in `./fileReferenceParser`; the inline-code
// pipeline reads full text content rather than using this regex.
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 80;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
@@ -244,6 +213,10 @@ const unwrapBlockCodePathTokens = (container: HTMLElement): void => {
const extractPathCandidateFromElement = (element: HTMLElement): string => {
if (element.tagName.toLowerCase() === 'a') {
const href = element.getAttribute('href')?.trim();
const fileUrlPath = href ? localPathFromFileUrl(href) : null;
if (fileUrlPath) {
return fileUrlPath;
}
if (href && isLikelyFilePath(href)) {
return href;
}
@@ -355,61 +328,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
};
};
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const normalizedPath = normalizePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};
@@ -434,6 +352,13 @@ const useFileReferenceInteractions = ({
if (!container) {
return;
}
// Wait for the real directory: annotating against an empty/fallback
// directory issues stat probes under the wrong cache key (and the wrong
// server directory), and the pass reruns anyway once the directory
// resolves — every link ended up verified twice.
if (enabled && !effectiveDirectory) {
return;
}
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
// On mobile surfaces, file-reference highlighting is disabled entirely — not
@@ -487,6 +412,19 @@ const useFileReferenceInteractions = ({
};
const annotateFileLinks = () => {
annotationWriteDepth += 1;
try {
annotateFileLinksInner();
} finally {
// Let the mutation events from our own writes flush before the
// observer starts listening for real content changes again.
queueMicrotask(() => {
annotationWriteDepth -= 1;
});
}
};
const annotateFileLinksInner = () => {
if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container);
}
@@ -515,7 +453,7 @@ const useFileReferenceInteractions = ({
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
const existsPromise = canGrantOutsideFile
? Promise.resolve(true)
: fileReferenceExists(resolved.resolvedPath);
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
void existsPromise.then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
@@ -615,7 +553,12 @@ const useFileReferenceInteractions = ({
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
// Our own annotation writes (path-token wrapping, attribute updates) fire
// childList mutations too; observing them re-ran the whole pass — every
// link was scanned and verified twice per render.
let annotationWriteDepth = 0;
const observer = new MutationObserver(() => {
if (annotationWriteDepth > 0) return;
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
});
observer.observe(container, {
@@ -748,6 +691,19 @@ const useMermaidInlineInteractions = ({
// so a stable diagram is laid out once and served from cache thereafter.
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
const MERMAID_RENDER_CACHE_MAX = 100;
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
let nextMarkdownDecorationId = 0;
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
if (existing) return existing;
const id = `decoration-${nextMarkdownDecorationId}`;
nextMarkdownDecorationId += 1;
MARKDOWN_DECORATION_IDS.set(ctx, id);
return id;
};
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
const existing = MERMAID_RENDER_CACHE.get(key);
@@ -829,22 +785,31 @@ const useMorphdomMarkdown = ({
containerRef,
text,
streaming,
cacheKey,
imageMode = 'inline',
syntaxVars,
ctx,
domCacheKey,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
text: string;
streaming: boolean;
cacheKey: string;
imageMode?: MarkdownImageMode;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
domCacheKey?: DetachedMarkdownDomKey | null;
}) => {
React.useEffect(() => {
ensureMarkdownShikiTheme();
}, []);
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
const renderRevisionRef = React.useRef(0);
// Only DOM that was actually restored or completed by the async pipeline is
// eligible for capture. A fallback from an earlier content revision is not.
const mountedDomRef = React.useRef<{
key: DetachedMarkdownDomKey;
copiedLabel: string;
} | null>(null);
const refreshMermaidViewers = React.useCallback(() => {
const container = containerRef.current;
if (!container) {
@@ -860,6 +825,63 @@ const useMorphdomMarkdown = ({
mermaidViewerRef.current.refresh();
}, [containerRef]);
React.useLayoutEffect(() => {
renderRevisionRef.current += 1;
mountedDomRef.current = null;
}, [ctx, imageMode, streaming, text]);
React.useLayoutEffect(() => {
if (!domCacheKey) return;
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target || target.childNodes.length > 0) return;
const cached = detachedMarkdownDomCache.take(domCacheKey);
if (cached) {
target.appendChild(cached);
const decorationId = getMarkdownDecorationId(ctx);
for (const block of Array.from(target.children)) {
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
}
for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
mountedDomRef.current = {
key: domCacheKey,
copiedLabel: ctx.labels.copied,
};
streamPerfCount('ui.markdown_renderer.dom_cache.hit');
}
}, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
// Restoration follows the cache identity above, but capture must only happen
// when this renderer lifecycle ends. Combining both in one keyed effect would
// detach the live DOM on ordinary content, theme, or locale updates.
React.useLayoutEffect(() => {
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return;
return () => {
const mountedDom = mountedDomRef.current;
if (!mountedDom) return;
// Viewer controllers and transient interaction state belong to the
// current renderer instance and must not cross the cache boundary.
if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
if (target.querySelector('[data-md-copy-pending]')) return;
const selection = window.getSelection();
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) return;
const openMenu = target.querySelector<HTMLElement>('[data-md-menu]:not(.hidden)');
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[data-md-action]'))
.some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
if (openMenu || copiedButton) return;
const fragment = document.createDocumentFragment();
fragment.append(...Array.from(target.childNodes));
detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
streamPerfCount('ui.markdown_renderer.dom_cache.capture');
};
}, [containerRef]);
// Synchronous first paint: while the async parse is in-flight, show escaped
// plain text immediately so there is no blank frame on initial mount. Only
// runs when the target is empty — subsequent updates keep the prior rich DOM
@@ -869,25 +891,40 @@ const useMorphdomMarkdown = ({
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
if (!target) return;
const decorationId = getMarkdownDecorationId(ctx);
if (text && target.childNodes.length === 0) {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
// `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text);
// Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates
// its own DOM before morphing, so without this the first paint shows bare
// <pre>/tables that "snap" into their decorated form a tick later. Matching
// the structure here keeps the async morph to syntax colors only.
decorateMarkdown(block, ctx);
target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) {
refreshMermaidViewers();
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
if (cachedBlocks) {
let hasMermaidBlock = false;
for (const cachedBlock of cachedBlocks) {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
block.innerHTML = cachedBlock.html;
decorateMarkdown(block, ctx);
block.setAttribute('data-md-id', cachedBlock.id);
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
target.appendChild(block);
}
if (hasMermaidBlock) refreshMermaidViewers();
} else {
const block = document.createElement('div');
block.setAttribute('data-md-block', '');
block.style.display = 'contents';
block.innerHTML = renderMarkdownSync(text, imageMode);
decorateMarkdown(block, ctx);
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
target.appendChild(block);
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
}
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
// but the viewer registry does not, so recreate it without reinstalling
// or re-decorating ordinary blocks.
refreshMermaidViewers();
}
}, [containerRef, text, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -899,27 +936,70 @@ const useMorphdomMarkdown = ({
if (!container) return;
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
const renderRevision = renderRevisionRef.current;
const decorationId = getMarkdownDecorationId(ctx);
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
if (!active) return;
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
if (!active || renderRevisionRef.current !== renderRevision) return;
const existing = Array.from(target.children) as HTMLElement[];
// Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
// to the trailing (growing) block instead of the whole message.
let enteredThisPass = 0;
blocks.forEach((block, index) => {
let el = existing[index];
let isNewBlock = false;
if (!el) {
el = document.createElement('div');
el.setAttribute('data-md-block', '');
el.style.display = 'contents';
target.appendChild(el);
isNewBlock = true;
}
if (el.getAttribute('data-md-id') === block.id) {
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
if (hasMermaidBlock) {
mermaidViewerRef.current?.cleanup();
mermaidViewerRef.current = null;
}
const replacement = document.createElement('div');
replacement.setAttribute('data-md-block', '');
replacement.style.display = 'contents';
replacement.innerHTML = block.html;
decorateMarkdown(replacement, ctx);
replacement.setAttribute('data-md-id', block.id);
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
el.replaceWith(replacement);
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
}
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
return;
}
if (el.getAttribute('data-md-id') === block.id) return;
const temp = document.createElement('div');
temp.innerHTML = block.html;
decorateMarkdown(temp, ctx);
if (isNewBlock && streaming && index > 0) {
// A freshly committed block enters with a short reveal. The class
// goes on the block's children — the wrapper is display:contents
// and cannot animate — and the transform never changes layout, so
// row measurement stays exact. Skipped for the first block so a
// full initial render does not shimmer. Several blocks committed
// in one tick cascade with a small stagger instead of popping in
// together.
const delayMs = Math.min(enteredThisPass, 4) * 55;
enteredThisPass += 1;
for (const child of Array.from(temp.children)) {
child.classList.add('oc-md-block-enter');
if (delayMs > 0 && child instanceof HTMLElement) {
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
}
}
}
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, {
@@ -927,12 +1007,12 @@ const useMorphdomMarkdown = ({
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
});
el.setAttribute('data-md-id', block.id);
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
refreshMermaidViewers();
}
});
// Remove any trailing block elements no longer present.
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
let removedMermaidBlock = false;
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
@@ -945,13 +1025,15 @@ const useMorphdomMarkdown = ({
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
refreshMermaidViewers();
}
mountedDomRef.current = domCacheKey
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
: null;
});
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, streaming, text]);
React.useEffect(() => {
const container = containerRef.current;
@@ -1028,13 +1110,49 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
useLinkInteractions({ containerRef });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
const { locale } = useI18n();
const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
const settledPart = part
&& (part.type === 'text' || part.type === 'reasoning')
&& part.time?.end !== undefined
? part
: null;
const runtimeKey = getRuntimeKey();
// Memoized on scalar identities, not the part object: sync-store reducers
// recreate part objects on unrelated updates, and an object-identity dep
// re-ran the async render pipeline for identical content.
const settledSessionID = settledPart?.sessionID;
const settledMessageID = settledPart?.messageID;
const settledPartID = settledPart?.id;
const domCacheKey = React.useMemo<DetachedMarkdownDomKey | null>(() => {
// Streaming, unfinished, oversized, and identity-less Markdown continues
// through the normal rendering pipeline and never retains detached DOM.
if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
// content.length is a cheap fingerprint: an edited or reverted part that
// re-materializes under the same id must not restore the old DOM.
return {
scope: `${runtimeKey}\0${settledSessionID}`,
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
locale,
directory: effectiveDirectory,
};
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
// Identity for the fade-in wrapper: a new part/message restarts the animation.
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
useMorphdomMarkdown({
containerRef,
text: content,
streaming: live,
imageMode,
syntaxVars,
ctx,
domCacheKey,
});
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
@@ -1044,7 +1162,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
if (isAnimated) {
return (
<FadeInOnReveal key={cacheKey} skipAnimation={skipFadeIn}>
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
{markdownContent}
</FadeInOnReveal>
);
@@ -1071,6 +1189,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
content: string;
className?: string;
variant?: MarkdownVariant;
// App links remain confirmed even where ordinary HTTP link handling is off.
disableLinkSafety?: boolean;
stripFrontmatter?: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
@@ -1112,7 +1231,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences,
});
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
@@ -1121,7 +1240,6 @@ const SimpleMarkdownRendererImpl: React.FC<{
containerRef,
text: renderedContent,
streaming: false,
cacheKey: `simple:${variant}`,
syntaxVars,
ctx,
});
File diff suppressed because it is too large Load Diff
+117 -122
View File
@@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { cn, fuzzyMatch } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -57,6 +58,28 @@ type MobileVariantTarget = { providerId: string; modelId: string };
const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`;
const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6;
const AgentDescriptionTooltip: React.FC<{
description?: string;
children: React.ReactElement;
}> = ({ description, children }) => {
if (!description) {
return children;
}
return (
<Tooltip delayDuration={450}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<span className="typography-meta text-muted-foreground">{description}</span>
</TooltipContent>
</Tooltip>
);
};
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
if (!Array.isArray(value)) {
return null;
@@ -301,7 +324,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentVariant = useConfigStore((state) => state.currentVariant);
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
const currentVariant = currentVariantSelection.override ?? undefined;
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
@@ -309,6 +334,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const setModel = useConfigStore((state) => state.setModel);
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride);
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
const setAgent = useConfigStore((state) => state.setAgent);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
@@ -506,13 +532,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const sortedAndFilteredAgents = React.useMemo(() => {
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
if (!agentSearchQuery.trim()) {
return sorted;
}
return sorted.filter((agent) =>
fuzzyMatch(agent.name, agentSearchQuery) ||
(agent.description && fuzzyMatch(agent.description, agentSearchQuery))
);
return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]);
}, [selectableDesktopAgents, agentSearchQuery]);
const defaultAgentName = React.useMemo(() => {
@@ -558,38 +578,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return result;
}, [providers, hiddenModels]);
const normalizeModelSearchValue = React.useCallback((value: string) => {
const lower = value.toLowerCase().trim();
const compact = lower.replace(/[^a-z0-9]/g, '');
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
return { lower, compact, tokens };
}, []);
const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
const normalizedQuery = normalizeModelSearchValue(query);
if (!normalizedQuery.lower) {
return true;
}
const normalizedCandidate = normalizeModelSearchValue(candidate);
if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
return true;
}
if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
return true;
}
if (normalizedQuery.tokens.length === 0) {
return false;
}
return normalizedQuery.tokens.every((queryToken) =>
normalizedCandidate.tokens.some((candidateToken) =>
candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
)
);
}, [normalizeModelSearchValue]);
const matchesModelSearch = React.useCallback(
(candidate: string, query: string) => matchesRankQuery([candidate], query),
[],
);
const currentModelForMetadata = currentModelId
? models.find((model: ProviderModel) => model.id === currentModelId)
@@ -641,7 +633,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
];
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
const explicitAgentSwitchRef = React.useRef<string | null>(null);
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
@@ -704,6 +695,30 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return variants ? Object.keys(variants) : [];
}, [providers]);
const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) return undefined;
let currentInherited: string | undefined;
if (currentProviderId === providerId && currentModelId === modelId) {
currentInherited = currentVariantSelection.inherited
?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined
? effectiveCurrentVariant
: undefined);
}
const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName;
const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined;
const agentVariant = (
agent?.model?.providerID === providerId
&& agent.model.modelID === modelId
) ? agent.variant : undefined;
const candidates = currentSessionId
? [agentVariant, settingsDefaultVariant, currentInherited]
: [currentInherited, agentVariant, settingsDefaultVariant];
return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate));
}, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]);
const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => {
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
@@ -722,10 +737,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return currentVariant;
}
if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) {
return settingsDefaultVariant;
}
return undefined;
}, [
currentAgentName,
@@ -735,7 +746,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentVariant,
getAgentModelVariantForSession,
getModelVariantOptions,
settingsDefaultVariant,
uiAgentName,
]);
@@ -759,7 +769,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
manualVariantSelectionRef.current = true;
setCurrentVariant(variant);
setCurrentVariantOverride(
variant ?? null,
resolveInheritedVariantForModel(providerId, modelId, agentNameOverride),
);
addRecentEffort(providerId, modelId, variant);
const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName();
@@ -770,9 +783,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
addRecentEffort,
currentSessionId,
getModelVariantOptions,
resolveInheritedVariantForModel,
resolveLiveAgentName,
saveAgentModelVariantForSession,
setCurrentVariant,
setCurrentVariantOverride,
]);
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => {
@@ -893,25 +908,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
: null;
if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
if (savedModel) {
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
if (result === 'applied') {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
return 'waiting';
}
} else if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
}
if (savedSessionModel) {
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
if (result === 'applied') {
if (savedAgentName && currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
@@ -925,16 +944,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
continue;
}
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') {
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
return 'resolved';
}
if (result === 'provider-missing') {
@@ -1032,9 +1050,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
prevAgentNameRef.current = currentAgentName;
if (currentAgentName && currentSessionId) {
const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName;
explicitAgentSwitchRef.current = null;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 50);
abortController.signal.addEventListener('abort', () => {
@@ -1047,33 +1062,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return;
}
const selectedAgent = shouldPreferAgentModel
? agents.find((agent) => agent.name === currentAgentName)
: undefined;
if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) {
const result = tryApplyModelSelection(
selectedAgent.model.providerID,
selectedAgent.model.modelID,
currentAgentName,
);
if (result === 'applied' || result === 'provider-missing') {
if (result === 'applied') {
saveSessionModelSelection(
currentSessionId,
selectedAgent.model.providerID,
selectedAgent.model.modelID,
);
saveAgentModelForSession(
currentSessionId,
currentAgentName,
selectedAgent.model.providerID,
selectedAgent.model.modelID,
);
}
return;
}
}
const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName);
if (persistedChoice) {
@@ -1099,12 +1087,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
abortController.abort();
};
}, [
agents,
currentAgentName,
currentSessionId,
getAgentModelForSession,
saveAgentModelForSession,
saveSessionModelSelection,
tryApplyModelSelection,
contextHydrated,
]);
@@ -1129,18 +1114,21 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
if (currentVariant && !availableVariants.includes(currentVariant)) {
setCurrentVariant(undefined);
setCurrentVariantOverride(
null,
resolveInheritedVariantForModel(currentProviderId, currentModelId),
);
return;
}
// Draft state (no session yet): seed from settings default, but don't override
// user selection while drafting.
if (!currentSessionId) {
if (!currentVariant && !manualVariantSelectionRef.current) {
if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) {
const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
? settingsDefaultVariant
: undefined;
setCurrentVariant(desired);
setCurrentVariantOverride(desired ?? null, desired);
}
return;
}
@@ -1152,13 +1140,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentModelId,
);
const resolvedSaved = savedVariant && availableVariants.includes(savedVariant)
? savedVariant
: settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant)
? settingsDefaultVariant
: undefined;
setCurrentVariant(resolvedSaved);
const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId);
if (savedVariant && availableVariants.includes(savedVariant)) {
setCurrentVariantOverride(savedVariant, inheritedVariant);
} else if (currentVariantSelection.override === null) {
setCurrentVariantOverride(null, inheritedVariant);
} else {
setCurrentVariant(inheritedVariant);
}
manualVariantSelectionRef.current = false;
}, [
availableVariants,
@@ -1168,8 +1157,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentProviderId,
currentModelId,
currentVariant,
currentVariantSelection.override,
effectiveCurrentVariant,
getAgentModelVariantForSession,
resolveInheritedVariantForModel,
setCurrentVariant,
setCurrentVariantOverride,
settingsDefaultVariant,
]);
@@ -1185,7 +1178,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
try {
explicitAgentSwitchRef.current = agentName;
setAgent(agentName);
addRecentAgent(agentName);
if (options?.closeModelSelector ?? true) {
@@ -2256,7 +2248,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
: 'Default';
return (
<span className={cn('typography-micro whitespace-nowrap', wasAdjusted ? 'text-foreground' : 'text-muted-foreground')}>
<span className={cn(
'typography-micro whitespace-nowrap',
isHighlighted
? (wasAdjusted ? 'text-interactive-selection-foreground' : 'text-interactive-selection-foreground/70')
: (wasAdjusted ? 'text-foreground' : 'text-muted-foreground'),
)}>
Thinking: {displayLabel}
</span>
);
@@ -2316,9 +2313,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col"
side="top"
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden"
align="end"
alignOffset={-40}
constrainToMain
collisionAvoidance={{ side: 'none', align: 'shift' }}
onKeyDownCapture={handleModelShortcutKeyDownCapture}
>
<div className="p-1 border-b border-border/40">
@@ -2375,6 +2375,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
);
}}
maxHeightClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1"
tooltipsEnabled={agentMenuOpen}
onEscape={() => setAgentMenuOpen(false)}
/>
@@ -2618,7 +2619,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
@@ -2708,7 +2709,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<DropdownMenuContent side="top" align="end" alignOffset={-40} constrainToMain collisionAvoidance={{ side: 'none', align: 'shift' }} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col overflow-hidden">
<div className="p-2 border-b border-border/40">
<div className="relative">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -2724,7 +2725,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
/>
</div>
</div>
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(var(--available-height)-4rem))] flex-1">
<div className="p-1">
{!agentSearchQuery.trim() && defaultAgentName && (
<>
@@ -2746,12 +2747,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
) : (
sortedAndFilteredAgents.map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<AgentDescriptionTooltip key={agent.name} description={agent.description}>
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
@@ -2759,13 +2759,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
</DropdownMenuItem>
</AgentDescriptionTooltip>
))
)}
</div>
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
>
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')}
</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
@@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon";
import { DiffPreview, WritePreview } from './DiffPreview';
import { useI18n } from '@/lib/i18n';
import { getVisiblePermissionPatterns } from './permissionCardPatterns';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
// Newest pending card owns the keyboard; older cards wait their turn.
const activePermissionCardIds: string[] = [];
const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = {
margin: 0,
@@ -66,6 +70,14 @@ const getToolIcon = (toolName: string) => {
return <Icon name="global" className={iconClass} />;
}
if (tool === 'linear' || tool.startsWith('linear_')) {
return <Icon name="linear" className={iconClass} />;
}
if (tool === 'cloudflare' || tool.startsWith('cloudflare_') || tool === 'claudflare' || tool.startsWith('claudflare_')) {
return <Icon name="cloudflare" className={iconClass} />;
}
return <Icon name="tools" className={iconClass} />;
};
@@ -118,6 +130,33 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
}
};
const handleResponseRef = React.useRef(handleResponse);
handleResponseRef.current = handleResponse;
React.useEffect(() => {
if (hasResponded) return;
activePermissionCardIds.push(permission.id);
const handleKeyDown = (event: KeyboardEvent) => {
if (activePermissionCardIds.at(-1) !== permission.id) return;
if (!event.altKey || event.metaKey || event.ctrlKey) return;
const response = event.key === 'Enter'
? (event.shiftKey ? 'always' as const : 'once' as const)
: event.key === 'Backspace' && !event.shiftKey
? 'reject' as const
: null;
if (!response) return;
event.preventDefault();
event.stopPropagation();
void handleResponseRef.current(response);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
const index = activePermissionCardIds.lastIndexOf(permission.id);
if (index !== -1) activePermissionCardIds.splice(index, 1);
};
}, [hasResponded, permission.id]);
if (hasResponded) {
return null;
}
@@ -372,6 +411,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="check" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Allow Once
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+enter')}</kbd>
</button>
{permission.always.length > 0 ? (
@@ -428,6 +468,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="time" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Always Allow
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+shift+enter')}</kbd>
</button>
)}
@@ -451,6 +492,7 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
>
<Icon name="close" className="h-3.5 w-3.5 sm:h-3 sm:w-3 flex-shrink-0" />
Deny
<kbd className="ml-1 hidden sm:inline typography-micro opacity-60">{formatShortcutForDisplay('alt+backspace')}</kbd>
</button>
{isResponding && (
@@ -15,6 +15,7 @@ import * as sessionActions from '@/sync/session-actions';
import { useI18n } from '@/lib/i18n';
import { serializeQuestionAsJson, serializeQuestionAsMarkdown } from './questionSerializers';
import { QUESTION_CUSTOM_TEXTAREA_MIN_HEIGHT, getQuestionCustomTextareaHeight } from './questionTextareaSizing';
import { QuestionMarkdown } from './QuestionMarkdown';
interface QuestionCardProps {
question: QuestionRequest;
@@ -423,7 +424,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
</div>
) : activeQuestion ? (
<>
<div className="typography-meta font-medium text-foreground mb-1.5">{activeQuestion.question}</div>
<QuestionMarkdown
content={activeQuestion.question}
size="meta"
className="font-medium text-foreground mb-1.5"
/>
{isMultiple ? (
<div className="typography-micro text-muted-foreground mb-1.5">{t('chat.questionCard.selectMultiple')}</div>
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { QuestionMarkdown } from './QuestionMarkdown';
// The markdown renderer is lazy, so a synchronous server render always emits the
// Suspense fallback QuestionMarkdown supplies. That fallback is the surface that
// has to keep the exact question text and the question typography classes.
describe('QuestionMarkdown', () => {
test('renders the question content verbatim', () => {
const content = 'Choose **one** from `mode`: [details](https://example.com)';
const html = renderToStaticMarkup(<QuestionMarkdown content={content} size="meta" />);
expect(html).toBe(
`<div class="question-markdown typography-meta whitespace-pre-wrap">${content}</div>`,
);
});
test('applies meta typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Meta" size="meta" className="font-medium text-foreground" />,
);
expect(html).toContain('class="question-markdown typography-meta font-medium text-foreground whitespace-pre-wrap"');
});
test('applies micro typography and caller classes', () => {
const html = renderToStaticMarkup(
<QuestionMarkdown content="Micro" size="micro" className="text-muted-foreground" />,
);
expect(html).toContain('class="question-markdown typography-micro text-muted-foreground whitespace-pre-wrap"');
});
});
@@ -0,0 +1,23 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from './MarkdownRenderer';
interface QuestionMarkdownProps {
content: string;
size: 'meta' | 'micro';
className?: string;
}
export function QuestionMarkdown({ content, size, className }: QuestionMarkdownProps) {
const classes = cn('question-markdown', size === 'meta' ? 'typography-meta' : 'typography-micro', className);
return (
<SimpleMarkdownRenderer
content={content}
variant="tool"
className={classes}
fallbackContent={<div className={cn(classes, 'whitespace-pre-wrap')}>{content}</div>}
/>
);
}
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation';
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -50,12 +51,13 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
const liveGoal = goal && goal.status !== 'complete' ? goal : null;
const isEngaged = armed || Boolean(liveGoal);
const colorClass = (() => {
if (goal?.status === 'complete') return 'text-[var(--status-success)]';
if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]';
if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]';
return '';
})();
// One mapping for every goal surface. This button used to carry its own,
// which painted `paused` the same info colour as `active` — so a paused goal
// was indistinguishable from a running one — and `blocked` as an error rather
// than a warning. `armed` is not a goal status, so it keeps its own case.
const iconColor = goal
? sessionGoalStatusColor[goal.status]
: (armed ? 'var(--status-info)' : undefined);
const label = goal
? t('chat.goal.button.manageAria')
@@ -74,7 +76,8 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
const button = (
<button
type="button"
className={cn(footerIconButtonClass, colorClass)}
className={footerIconButtonClass}
style={iconColor ? { color: iconColor } : undefined}
onClick={handleClick}
// Same guard as PermissionAutoAcceptButton, but only for the ARM
// toggle: arming happens mid-typing (the next message IS the
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
interface SkillInfo {
name: string;
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
}, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false);
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const isProject = skill.scope === 'project';
const source = skill.source || 'opencode';
return (
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
<div
key={`${skill.name}-${skill.scope}`}
ref={(el) => {
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
{source}
</span>
</div>
{skill.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{skill.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
};
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
+19 -327
View File
@@ -1,141 +1,25 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The floating assistant-status chip that hovers above the composer while the
// agent works ("Claude is working…"). ONLY that. The composer's
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
// to share this component, and every restyle of this chip (glass, placement)
// silently dragged the composer bar and its dropdown along with it.
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
textClassName: "text-foreground",
},
pending: {
textClassName: "text-foreground",
},
completed: {
textClassName: "text-muted-foreground line-through",
},
cancelled: {
textClassName: "text-muted-foreground line-through",
},
};
const priorityClassName: Record<TodoPriority, string> = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
};
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps {
// Working state
isWorking?: boolean;
statusText?: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean;
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
leftAccessory?: React.ReactNode;
}
export const StatusRow: React.FC<StatusRowProps> = ({
@@ -143,186 +27,36 @@ export const StatusRow: React.FC<StatusRowProps> = ({
statusText = null,
isGenericStatus,
isWaitingForPermission,
wasAborted,
abortActive,
retryInfo,
showAbort,
onAbort,
showAbortStatus,
showAssistantStatus = true,
showTodos = true,
agentName,
modelName,
providerId,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
const shouldRenderPlaceholder = !abortActive;
const hasContent = isWorking;
// Find the current active todo (first in_progress, or first pending)
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((t) => t.status === "in_progress") ||
visibleTodos.find((t) => t.status === "pending") ||
null
);
}, [visibleTodos]);
// Calculate progress
const progress = React.useMemo(() => {
const total = todos.filter((t) => t.status !== "cancelled").length;
const completed = todos.filter((t) => t.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
const hasLeftAccessory = Boolean(leftAccessory);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<Icon name="close-circle" aria-hidden="true"/>
</button>
) : null;
// Todo trigger button
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
// Don't render if nothing to show
if (!hasContent) {
return null;
}
return (
<div
// Mobile: breathing room between the last message and the agent status
// line — without it the "<model> is running…" row sits flush against
// the message above.
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
// The row renders inside the composer-anchored overlay, which owns the
// distance to the input and the horizontal column (the same ones the
// scroll-to-bottom pill uses).
style={STATUS_ROW_CONTAINER_STYLE}
>
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAssistantStatus && showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true"/>
{t('chat.statusRow.aborted')}
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
{/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */}
{/* The glass chip lives here, not on the container: the root above is
an inline-size query container, whose width ignores its children
a shrink-to-fit wrapper around it always collapsed to zero. */}
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
{shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
@@ -334,50 +68,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
modelName={modelName}
providerId={providerId}
/>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -2,7 +2,6 @@ import React from 'react';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { StatusRow } from './StatusRow';
@@ -12,15 +11,6 @@ import { StatusRow } from './StatusRow';
* labels while still limiting subscriptions to the active assistant message.
*/
export const StatusRowContainer: React.FC = React.memo(() => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const abortRecord = useSessionUIStore(
React.useCallback((state) => {
if (!currentSessionId) {
return null;
}
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
}, [currentSessionId]),
);
const { activeModel, working } = useAssistantStatus();
const currentAgentName = useConfigStore((state) => state.currentAgentName);
const providers = useConfigStore((state) => state.providers);
@@ -35,19 +25,14 @@ export const StatusRowContainer: React.FC = React.memo(() => {
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
return (
<StatusRow
isWorking={working.isWorking}
statusText={working.statusText}
isGenericStatus={working.isGenericStatus}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive}
abortActive={working.abortActive}
retryInfo={working.retryInfo}
showAssistantStatus
showTodos={false}
agentName={currentAgentName}
modelName={modelDisplayName}
providerId={activeModel?.providerId ?? null}
@@ -223,20 +223,48 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
if (!currentSessionId) return null;
const turnActions = (
<>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
void onScrollByTurnOffset?.(-1);
onOpenChange(false);
}}
>
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
onResumeToLatest?.();
onOpenChange(false);
}}
>
{t('chat.timeline.actions.latest')}
</button>
</>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogContent className="max-w-2xl max-h-[70vh] max-md:max-h-[85dvh] flex flex-col overflow-y-auto">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Icon name="time" className="h-5 w-5" />
{t('chat.timeline.title')}
</DialogTitle>
<DialogDescription>
{t('chat.timeline.description')}
</DialogDescription>
{!isMobile && (
<DialogDescription>
{t('chat.timeline.description')}
</DialogDescription>
)}
</DialogHeader>
<div className="relative mt-2">
<div className="relative mt-2 shrink-0">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
autoFocus
@@ -249,7 +277,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
{canLoadEarlier && onLoadEarlier && (
<div className="flex justify-center py-1">
<div className="flex shrink-0 justify-center py-1">
<Button
type="button"
variant="link"
@@ -266,7 +294,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</div>
)}
<div ref={listRef} className="flex-1 overflow-y-auto">
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
{filteredMessages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
@@ -312,7 +340,7 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={cn(
"typography-meta w-16 flex-shrink-0 text-right tabular-nums",
"typography-meta min-w-16 flex-shrink-0 text-right tabular-nums whitespace-nowrap",
isSelected ? "text-interactive-selection-foreground/70" : "text-muted-foreground"
)}>
{messageTime}
@@ -373,45 +401,31 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
)}
</div>
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
void onScrollByTurnOffset?.(-1);
onOpenChange(false);
}}
>
{t('chat.timeline.actions.previousTurn')}
</button>
<span className="text-muted-foreground/50">/</span>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
onResumeToLatest?.();
onOpenChange(false);
}}
>
{t('chat.timeline.actions.latest')}
</button>
{isMobile ? (
<div className="mt-2 flex shrink-0 items-center justify-center gap-2 border-t border-border/60 pt-2">
{turnActions}
</div>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('chat.timeline.help.clickMessage')}</span>
) : (
<div className="mt-4 p-3 bg-muted/30 rounded-lg shrink-0">
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('chat.timeline.actions.title')}</p>
<div className="mb-2 flex items-center gap-2">
{turnActions}
</div>
<div className="flex items-center gap-2">
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('chat.timeline.help.clickMessage')}</span>
</div>
<div className="flex items-center gap-2">
<Icon name="arrow-go-back" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.undoToPoint')}</span>
</div>
<div className="flex items-center gap-2">
<Icon name="git-branch" className="h-4 w-4 flex-shrink-0" />
<span>{t('chat.timeline.help.createSessionFromHere')}</span>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,252 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/2903
*
* Busy embedded session-chat panels were rendering only the working-status row
* ("…is running command") because ChatContainer gated message reads on the
* same visibility flag used to keep the composer from stealing focus. When the
* iframe booted inactive (or a visibility postMessage was lost),
* useSessionMessageRecords returned [] while session status stayed busy so
* the empty-state branch was skipped and the transcript showed status only.
*
* Idle sessions hit the empty state instead (#2892). Same root cause.
*
* Fix: embedded session-chat keeps `messagesEnabled={true}` so history stays
* subscribed while `active={embeddedBackgroundWorkEnabled}` still gates
* composer focus and background work.
*/
import { describe, expect, mock, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
mock.module('sonner', () => ({
toast: { dismiss: () => undefined, error: () => undefined, info: () => undefined, success: () => undefined },
}));
mock.module('@/components/ui', () => ({
toast: { info: () => undefined, error: () => undefined, success: () => undefined },
}));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => '/repo',
setDirectory: () => undefined,
getSdkClient: () => ({}),
getScopedSdkClient: () => ({}),
},
}));
mock.module('@/stores/permissionStore', () => ({
usePermissionStore: { getState: () => ({ isSessionAutoAccepting: () => false, hydrate: async () => undefined }) },
}));
mock.module('@/stores/useConfigStore', () => ({
useConfigStore: {
getState: () => ({ isConnected: true, hasEverConnected: true, settingsMessageStreamTransport: 'auto' }),
setState: () => undefined,
},
}));
mock.module('@/stores/useTodosPersistStore', () => ({
useTodosPersistStore: { getState: () => ({ setSessionTodos: () => undefined }) },
}));
const { useSessionMessageRecords } = await import('@/sync/sync-context');
const { ChildStoreManager } = await import('@/sync/child-store');
const { getSessionMaterializationStatus } = await import('@/sync/materialization');
import type { State } from '@/sync/types';
const __dirname = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8');
const chatContainerSource = readFileSync(join(__dirname, '..', 'ChatContainer.tsx'), 'utf-8');
const chatViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'ChatView.tsx'), 'utf-8');
const syncContextSource = readFileSync(join(__dirname, '..', '..', '..', 'sync', 'sync-context.tsx'), 'utf-8');
const SESSION_ID = 'ses_subagent_2903';
const DIRECTORY = '/repo';
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: unknown) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
class ElementStub {}
const documentStub: Record<string, unknown> = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
const container = {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: documentStub,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
documentStub.documentElement = container;
documentStub.body = container;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
return {
container: container as unknown as Element,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
const createMessage = (id: string, role: 'user' | 'assistant', created: number): Message => ({
id,
sessionID: SESSION_ID,
role,
...(role === 'assistant' ? { parentID: `u_${created}` } : {}),
time: { created },
} as Message);
const createPart = (id: string, messageID: string, text: string): Part => ({
id,
messageID,
sessionID: SESSION_ID,
type: 'text',
text,
} as Part);
/** 14-message subagent transcript, matching the issue reproduction fixture. */
const buildMaterializedSubagentSession = () => {
const messages: Message[] = [];
const part: Record<string, Part[]> = {};
for (let index = 0; index < 14; index += 1) {
const created = index + 1;
const role: 'user' | 'assistant' = created % 2 === 1 ? 'user' : 'assistant';
const id = role === 'user' ? `u_${created}` : `a_${created}`;
messages.push(createMessage(id, role, created));
part[id] = [createPart(`prt_${id}`, id, role === 'user' ? `prompt ${created}` : `output ${created}`)];
}
return { messages, part };
};
const syncContext = (globalThis as unknown as {
__openchamber_sync_context__?: React.Context<unknown>;
}).__openchamber_sync_context__;
if (!syncContext) {
throw new Error('sync context was not published on globalThis by @/sync/sync-context');
}
describe('issue #2903 busy embedded subagent status-line-only', () => {
test('cold disabled reads hide a fully materialized 14-message subagent; enabled reads return all 14', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const childStores = new ChildStoreManager();
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
const { messages, part } = buildMaterializedSubagentSession();
store.setState({
status: 'complete',
session: [{
id: SESSION_ID,
title: 'Audit Searchbar implementation',
time: { created: 1, updated: 1 },
version: '1',
directory: DIRECTORY,
} as State['session'][number]],
message: { [SESSION_ID]: messages },
part,
} as Partial<State>);
expect(getSessionMaterializationStatus(store.getState(), SESSION_ID)).toEqual({
hasMessages: true,
renderable: true,
missingPartMessageIDs: [],
});
const system = { childStores, messageLoader: {}, sdk: {}, runtimeKey: 'test', directory: DIRECTORY };
const Provider = syncContext.Provider as React.Provider<unknown>;
let inactiveCount = -1;
let activeCount = -1;
let enabled = false;
const Harness = () => {
const records = useSessionMessageRecords(SESSION_ID, DIRECTORY, { enabled });
if (enabled) {
activeCount = records.length;
} else {
inactiveCount = records.length;
}
return null;
};
try {
await act(async () => {
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
});
expect(inactiveCount).toBe(0);
enabled = true;
await act(async () => {
root.render(React.createElement(Provider, { value: system }, React.createElement(Harness)));
});
expect(activeCount).toBe(14);
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('sync gate still returns empty on cold disabled reads', () => {
const hookStart = syncContextSource.indexOf('export function useSessionMessageRecords(');
const hookBody = syncContextSource.slice(hookStart, hookStart + 1800);
expect(hookBody).toContain('if (options?.enabled === false)');
expect(hookBody).toContain('EMPTY_SESSION_MESSAGE_RECORDS');
expect(hookBody).toContain('snapshotRef.current.sessionID === sessionID ? snapshotRef.current.list');
});
test('embedded session-chat keeps message history enabled while visibility gates active', () => {
expect(appSource).toContain('messagesEnabled={true}');
expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}');
expect(appSource).toContain('const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(false);');
expect(chatViewSource).toContain('messagesEnabled?: boolean');
expect(chatContainerSource).toContain('messagesEnabled: messagesEnabledProp');
expect(chatContainerSource).toContain('const messagesEnabled = messagesEnabledProp ?? active;');
expect(chatContainerSource).toContain('enabled: messagesEnabled');
expect(chatContainerSource.includes('enabled: active')).toBe(false);
expect(chatContainerSource).toContain('if (!messagesEnabled || !currentSessionId) return;');
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
});
test('the empty and idle branch leaves the status row to the busy path', () => {
// A busy session with no messages yet must fall through to the viewport so
// StatusRowContainer is the only thing on screen. The idle branch returns
// before it and must not render one of its own. The empty state itself no
// longer lives here: the draft surface owns it since the draft transition
// animation landed.
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
expect(chatContainerSource).toContain('<StatusRowContainer />');
const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
expect(emptyIdleReturn).toBeGreaterThan(-1);
const emptyIdleBlock = chatContainerSource.slice(
emptyIdleReturn,
emptyIdleReturn + 1600,
);
expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
});
test('visibility handshake remains as defense-in-depth for background work', () => {
expect(appSource).toContain('requestEmbeddedSessionVisibility();');
expect(appSource).toContain('EMBEDDED_VISIBILITY_UPDATE');
});
});
@@ -0,0 +1,33 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
*
* Restoring persisted agent/model pairs used to switch agents before checking
* whether each model still existed. Several stale pairs could therefore keep
* changing the active agent on every effect pass until React hit its nested
* update limit. The API error belongs in the assistant message; an invalid
* persisted pair must not mutate the current selection while it is rendered.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
describe('issue #3036 stale persisted models', () => {
test('changes the agent only after its persisted model is accepted', () => {
const candidateLoop = modelControlsSource.slice(
modelControlsSource.indexOf('for (const agent of agents)'),
modelControlsSource.indexOf("return 'continue';"),
);
const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
expect(applyIndex).toBeGreaterThanOrEqual(0);
expect(acceptedIndex).toBeGreaterThan(applyIndex);
expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
});
});
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} from './appLinkConfirmation';
describe('app link confirmation', () => {
beforeEach(() => {
useAppLinkTrustStore.setState({ trustedSchemes: [] });
const pending = getAppLinkConfirmationSnapshot();
if (pending) {
settleAppLinkConfirmation('cancel');
}
});
test('opens trusted schemes without asking', async () => {
useAppLinkTrustStore.getState().trustScheme('obsidian');
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
});
test('asks once and trusts the scheme when the user chooses trust', async () => {
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
settleAppLinkConfirmation('trust');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
});
test('cancel opens nothing and keeps the scheme untrusted', async () => {
const pending = openAppLinkWithConfirmation('notion://note/xyz');
settleAppLinkConfirmation('cancel');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
});
test('a newer request cancels the pending one', async () => {
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
const firstChoice = first.then(
() => 'settled',
() => 'settled',
);
const second = openAppLinkWithConfirmation('linear://open/1');
expect(await firstChoice).toBe('settled');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
settleAppLinkConfirmation('open');
await second;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
});
});
@@ -0,0 +1,71 @@
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
type PendingAppLinkRequest = {
url: string;
resolve: (choice: AppLinkConfirmationChoice) => void;
};
let pendingRequest: PendingAppLinkRequest | null = null;
const listeners = new Set<() => void>();
const emitChange = (): void => {
for (const listener of listeners) {
listener();
}
};
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
/**
* Ask the user (via the app-level confirmation dialog) whether an application
* deep link may be opened. Resolves immediately when the scheme was trusted
* earlier. Only one request is active at a time; a new request cancels the
* pending one.
*/
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
const scheme = getUrlScheme(url);
if (!scheme) {
return Promise.resolve();
}
const trustStore = useAppLinkTrustStore.getState();
if (trustStore.isSchemeTrusted(scheme)) {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
if (pendingRequest) {
pendingRequest.resolve('cancel');
}
return new Promise<AppLinkConfirmationChoice>((resolve) => {
pendingRequest = { url, resolve };
emitChange();
}).then((choice) => {
if (choice === 'trust') {
useAppLinkTrustStore.getState().trustScheme(scheme);
}
if (choice === 'open' || choice === 'trust') {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
});
};
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
const request = pendingRequest;
pendingRequest = null;
emitChange();
request?.resolve(choice);
};
export const subscribeAppLinkConfirmation = subscribe;
export const getAppLinkConfirmationSnapshot = getSnapshot;
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'bun:test';
import { attachAppLinkInteractions } from './appLinkInteractions';
const TestElement = class Element {};
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
class TestAnchor extends HTMLAnchorElement {
constructor(private readonly rawHref: string) {
super();
}
getAttribute(name: string): string | null {
return name === 'href' ? this.rawHref : null;
}
closest(): TestAnchor {
return this;
}
}
class TestContainer {
listeners = new Map<string, EventListener>();
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
// SAFETY: dispatch constructs every mouse field read by the production listener.
this.listeners.set(name, (event) => listener(event as MouseEvent));
}
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
void listener;
this.listeners.delete(name);
}
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
const event = new Event(name, { cancelable: true });
Object.defineProperties(event, {
target: { value: new TestAnchor(href) },
button: { value: init.button ?? 0 },
metaKey: { value: init.metaKey ?? false },
ctrlKey: { value: init.ctrlKey ?? false },
altKey: { value: init.altKey ?? false },
shiftKey: { value: init.shiftKey ?? false },
});
this.listeners.get(name)?.(event);
return event;
}
}
const setup = (allowExternalHttp = true) => {
const container = new TestContainer();
const appLinks: string[] = [];
const httpLinks: string[] = [];
const cleanup = attachAppLinkInteractions(container, {
allowExternalHttp,
openAppLink: (url) => appLinks.push(url),
openExternalHttp: (url) => httpLinks.push(url),
});
return { container, appLinks, httpLinks, cleanup };
};
describe('app link interactions', () => {
test('confirms plain, modifier, and middle-click activations', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
expect(appLinks).toEqual([href, href, href]);
});
test('blocks drag activation without opening immediately', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
expect(appLinks).toEqual([]);
});
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
const enabled = setup();
const disabled = setup(false);
const href = 'https://example.com';
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
expect(enabled.httpLinks).toEqual([href]);
expect(disabled.httpLinks).toEqual([]);
});
});
@@ -0,0 +1,75 @@
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
type AppLinkInteractionOptions = {
allowExternalHttp: boolean;
openAppLink: (url: string) => void;
openExternalHttp: (url: string) => void;
};
type LinkInteractionContainer = {
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
};
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
const target = event.target;
if (!(target instanceof Element)) return null;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return null;
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
return anchor;
};
const interceptAppLink = (
event: MouseEvent | DragEvent,
openAppLink?: (url: string) => void,
): boolean => {
if (event.defaultPrevented) return false;
const anchor = findLink(event);
const href = anchor?.getAttribute('href') ?? '';
if (!isAppLinkUrl(href)) return false;
event.preventDefault();
event.stopPropagation();
openAppLink?.(href);
return true;
};
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
event.button === 0
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& !event.shiftKey
);
export const attachAppLinkInteractions = (
container: LinkInteractionContainer,
options: AppLinkInteractionOptions,
): (() => void) => {
const handleClick = (event: MouseEvent) => {
if (interceptAppLink(event, options.openAppLink)) return;
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
const href = findLink(event)?.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) return;
event.preventDefault();
event.stopPropagation();
options.openExternalHttp(href);
};
const handleAuxClick = (event: MouseEvent) => {
if (event.button === 1) interceptAppLink(event, options.openAppLink);
};
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
interceptAppLink(event);
};
container.addEventListener('click', handleClick);
container.addEventListener('auxclick', handleAuxClick);
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
return () => {
container.removeEventListener('click', handleClick);
container.removeEventListener('auxclick', handleAuxClick);
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
};
};
@@ -0,0 +1,477 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSync } from '@/sync/use-sync';
import {
useSessionMessageRecords,
useSessionRenderable,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
} from '@/sync/sync-context';
import { useStreamingStore } from '@/sync/streaming';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
import type { BtwPanelState } from './useBtwPanelState';
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
import ChatMessage from '../ChatMessage';
import { PermissionCard } from '../PermissionCard';
import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/**
* The `/btw` peek panel.
*
* Rendered from inside the composer form, so the sheet docks exactly above
* the main composer (`absolute bottom-full` on the composer column) on both
* desktop and mobile the main composer IS the btw input, so nothing may
* cover it. Identity is derived from the parent session's metadata (see
* `useBtwPanelState`), so the panel belongs to one parent session only.
*
* Three exits: collapse (panel minimizes to the composer chip, the composer
* returns to the main session), promote (the fork becomes a normal session
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
parentSessionId,
panel,
}) => {
const { t } = useI18n();
if (panel.btwSessionId && panel.btwDirectory) {
return (
<BtwSheet
sessionRef={{
parentSessionId,
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
);
}
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
</BtwFrame>
);
}
return null;
};
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
const { t } = useI18n();
return React.useCallback(() => {
if (!sessionRef) return;
void destroyBtwSession(sessionRef).then((ok) => {
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
});
}, [sessionRef, t]);
};
type BtwSessionData = {
messageRecords: Array<{ info: Message; parts: Part[] }>;
sessionIsWorking: boolean;
streamingMessageId: string | null;
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
isEmpty: boolean;
};
/**
* Live session data for the fork, all keyed by the fork's own ids. Only the
* fork's tail (messages after the inherited-history boundary) is shown.
*/
const useBtwSessionData = (
sessionId: string,
directory: string,
boundaryMessageID: string | null,
): BtwSessionData => {
const sync = useSync();
const renderable = useSessionRenderable(sessionId, directory);
React.useEffect(() => {
if (!renderable) {
void sync.ensureSessionRenderable(sessionId, false, directory);
}
}, [directory, renderable, sessionId, sync]);
const messageRecords = useSessionMessageRecords(sessionId, directory);
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
const streamingMessageId = useStreamingStore(
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
);
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
[streamingMessageId],
),
);
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
const tailRecords = React.useMemo(
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
[boundaryMessageID, messageRecords],
);
const sessionIsWorking = React.useMemo(() => {
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
}
const statusType = status.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
}
// SAFETY: reads only the optional `time.completed` field, which the
// SDK Message union does not expose uniformly; a missing value means
// the assistant turn has not completed.
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
return Boolean(
lastMessage
&& lastMessage.role === 'assistant'
&& typeof lastMessage.time?.completed !== 'number',
);
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
return {
messageRecords: tailRecords,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
sessionPermissions,
sessionQuestions,
isEmpty: tailRecords.length === 0,
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
};
/**
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
* without changing the record count, so following the tail needs a
* ResizeObserver on the content wrapper data-driven effects alone would
* stop following mid-stream.
*/
const useAutoScroll = (
bodyRef: React.RefObject<HTMLDivElement | null>,
contentRef: React.RefObject<HTMLDivElement | null>,
contentReady: boolean,
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
const stickToBottomRef = React.useRef(true);
// `contentReady` is a dependency because the refs are only attached once
// the empty state gives way to the message list; an effect keyed on the
// refs alone would run against `null` and never re-attach the observer.
React.useEffect(() => {
if (!contentReady) return;
const content = contentRef.current;
const element = bodyRef.current;
if (element && stickToBottomRef.current) {
element.scrollTop = element.scrollHeight;
}
if (!content || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
const body = bodyRef.current;
if (body && stickToBottomRef.current) {
body.scrollTop = body.scrollHeight;
}
});
observer.observe(content);
return () => observer.disconnect();
}, [bodyRef, contentReady, contentRef]);
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}, []);
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
aria-label="btw"
>
<div className="oc-glass-popover w-full overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]">
<div className="flex items-center gap-2 px-3 py-1.5">
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
aria-label={titleClickLabel}
title={titleClickLabel}
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
>
{headerSpinner ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
</span>
)}
<div className="min-w-0 flex-1" />
{actions}
</div>
{children ? (
<>
{children}
<div className="h-2" />
</>
) : null}
</div>
</div>
);
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
const actions = (
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handlePromote}
aria-label={t('chat.btw.promoteAria')}
title={t('chat.btw.promoteAria')}
>
<Icon name="external-link" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handleDestroy}
aria-label={t('chat.btw.destroyAria')}
title={t('chat.btw.destroyAria')}
>
<Icon name="close" className="size-4" />
</Button>
</div>
);
if (collapsed) {
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
/>
);
}
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
titleClickLabel={toggleLabel}
/>
);
};
/**
* Collapsed mode: only the header strip stays docked above the composer. The
* fork keeps running in the background; a spinner replaces the header icon
* while it is busy so activity stays visible without the message list.
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
collapsed
headerSpinner={isBusy}
/>
);
};
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
// With the on-screen keyboard open the composer (this panel's anchor)
// rises, and a vh-based cap would push the panel under the app header.
// Same protection as the composer autocomplete popups: clamp the scroll
// body to the space actually available above the anchor. The hook measures
// room for the scroll body itself, but the panel header and bottom spacer
// sit inside the same frame above/below it — reserve their height too.
const BTW_FRAME_CHROME_PX = 48;
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
const mobileMaxHeight = availableMaxHeight !== undefined
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
bodyRef={bodyRef}
contentRef={contentRef}
onBodyScroll={handleBodyScroll}
maxHeight={mobileMaxHeight}
/>
</ChatSurfaceProvider>
</BtwFrame>
);
};
const BtwMessages: React.FC<{
data: BtwSessionData;
bodyRef: React.RefObject<HTMLDivElement | null>;
contentRef: React.RefObject<HTMLDivElement | null>;
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
maxHeight?: number;
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
const { t } = useI18n();
if (data.isEmpty) {
return (
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
);
}
return (
<ScrollShadow
ref={bodyRef}
onScroll={onBodyScroll}
size={32}
data-scroll-shadow="true"
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
style={maxHeight !== undefined ? { maxHeight } : undefined}
>
<div ref={contentRef}>
{data.messageRecords.map((record, index) => (
<ChatMessage
key={record.info.id}
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
}
/>
))}
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
<div>
{data.sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{data.sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
) : null}
{/* Always reserve this row so the content does not shift down
by a line when the indicator disappears. */}
<div
className={cn(
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
!data.sessionIsWorking && 'invisible',
)}
aria-hidden={!data.sessionIsWorking}
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('chat.btw.working')}</span>
</div>
</div>
</ScrollShadow>
);
};
@@ -0,0 +1,57 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSession } from '@/sync/sync-context';
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
export type BtwPanelState = {
/** The session the composer is in — the one `/btw` would fork. */
parentSession: Session | null;
/** The active fork for this parent, or null when no panel should exist. */
btwSessionId: string | null;
btwSession: Session | null;
/** The fork's directory identity (may be canonicalized by the server). */
btwDirectory: string | null;
/** Last message id inherited from the parent; the panel shows what's after it. */
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
};
/**
* Derive the `/btw` panel identity for one parent session from authoritative
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
* fork itself are present in the live stores, so a fork deleted anywhere
* (sidebar, another client) makes the panel disappear without extra tracking.
*/
export function useBtwPanelState(
parentSessionId: string | null | undefined,
directory: string | undefined,
): BtwPanelState {
const parentSession = useSession(parentSessionId, directory);
const linkedBtwSessionId = getBtwSessionID(parentSession);
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
const uiState = useBtwStore(
React.useCallback(
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
[parentSessionId],
),
);
const destroying = Boolean(uiState?.destroying);
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
return {
parentSession: parentSession ?? null,
btwSessionId,
btwSession: btwSessionId ? btwSession : null,
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the parent's directory as the fallback.
btwDirectory: btwSessionId
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
: null,
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
};
}
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
import { withReviewSessionMarker } from '@/lib/sessionReviewMetadata';
const session = (parentID?: string): Session => ({
id: 'session',
@@ -27,4 +28,14 @@ describe('resolveChatPromptReadOnly', () => {
expect(resolveChatPromptReadOnly(session(), true, true)).toBe(true);
expect(resolveChatPromptReadOnly(session(), true, false)).toBe(false);
});
test('treats a marked code review as an independent session even with a stale parent ID', () => {
const reviewSession = {
...session('original'),
metadata: withReviewSessionMarker({}, 'original'),
} as Session;
expect(resolveChatPromptReadOnly(reviewSession, false, false)).toBe(false);
expect(resolveChatPromptReadOnly(reviewSession, true, true)).toBe(true);
});
});
@@ -1,10 +1,18 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { isReviewSession } from '@/lib/sessionReviewMetadata';
export const resolveChatPromptReadOnly = (
session: Session | null | undefined,
allowPromptingSubagentSessions: boolean,
readOnly: boolean,
): boolean => {
// Review sessions are independent conversations even if an older server or
// cached record still carries parentID. Their explicit metadata is the
// authority; only the surface itself may make them read-only.
if (isReviewSession(session)) {
return readOnly;
}
if (session?.parentID) {
return !allowPromptingSubagentSessions;
}
@@ -1,5 +1,11 @@
import React from 'react';
export type ChatSurfaceMode = 'default' | 'mini-chat';
/**
* 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions).
* 'peek' is a read-only glance surface (the /btw panel): messages render with
* no per-message controls at all no user action row, no assistant action
* buttons, no turn footer.
*/
export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek';
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
@@ -1,33 +1,85 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
/**
* Compact one-line mirror of the status row for the pill: same label, none of
* the status row's animation machinery (which does not survive being squeezed
* into a 32px chip).
*/
const PillWorkingStatus: React.FC = () => {
const { t } = useI18n();
const { activeModel, working } = useAssistantStatus();
const providers = useConfigStore((state) => state.providers);
const modelName = React.useMemo(() => {
if (!activeModel) return null;
const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
if (!working.isWorking || !working.statusText) return null;
const status = working.statusText;
const label = modelName && modelName.trim().length > 0
? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
: status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
{label}
<span className="animate-pulse"> </span>
</span>
);
};
interface ScrollToBottomButtonProps {
visible: boolean;
/** The session is still streaming: the pill carries the status label
while the floating status row is hidden away from the live edge. */
working?: boolean;
onClick: () => void;
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, working = false, onClick }) => {
const { t } = useI18n();
return (
<div
className={cn(
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
visible ? 'opacity-100' : 'opacity-0',
)}
>
<Button
variant="outline"
size="sm"
onClick={onClick}
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label={t('chat.scrollToBottom.aria')}
>
<Icon name="arrow-down" className="h-4 w-4" />
</Button>
{/* The same column that centres the composer, so the pill's left
edge lines up exactly with the input frame. */}
<div className="chat-input-column">
{/* The soft shadow lives on this wrapper, away from the glass
button's backdrop-filter: sharing one element made the
shadow intermittently drop after hide/show cycles. */}
<div className="inline-flex max-w-full rounded-full shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.10)] dark:shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.35)]">
<button
type="button"
onClick={onClick}
aria-label={t('chat.scrollToBottom.aria')}
className={cn(
// Glass material with a hairline real border — much
// lighter than the oc-glass-floating stack.
'oc-glass-popover inline-flex h-8 max-w-full items-center rounded-full [corner-shape:round] text-left',
'border border-black/[0.06] dark:border-white/[0.08]',
visible ? 'pointer-events-auto' : 'pointer-events-none',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground">
<Icon name="arrow-down" className="h-4 w-4" />
</span>
{working && visible ? <PillWorkingStatus /> : null}
</button>
</div>
</div>
</div>
);
};
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
interface DiffStats {
additions: number;
@@ -21,7 +20,6 @@ interface TurnActivityProps {
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
@@ -18,13 +18,13 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
data-scroll-spy-id={turn.turnId}
>
{stickyUserHeader ? (
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] pb-4 sm:pb-8 [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
/>
</div>
) : (
@@ -7,6 +7,16 @@ everything between typing and sending.
own state and wires these modules together; it should not grow logic that
belongs to one of them.
`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft
becomes its first session. Draft-only UI first fades for 120ms while the editor
stays in place. The parent then moves the editor to its final session position
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
transitions. `session-ui-store.ts` marks sessions materialized from a submitted
draft, so selecting an existing session while a draft is open switches without
animation. Do not restore separate draft and session composer branches:
remounting the editor loses focus and interrupts the transition. Keep the
existing mobile fixed-position rules unchanged.
## Layers
| Directory | Owns |
@@ -50,6 +60,15 @@ copy.
exactly what gets sent, so nothing downstream serializes a rich document model
back into a prompt.
The document is not, however, the string it was given: CodeMirror normalizes
line endings, so a `\r\n` pair becomes one break and the document ends up
shorter than the inserted string. **Never derive a caret position from the
length of text you are inserting** — a caret past the end makes `dispatch`
throw, the transaction never applies, and the un-normalized text stays in React
state to crash again on the next restore. Every edit that moves the caret goes
through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the
change instead of the string.
The composer previously painted a transparent `<textarea>` over a mirror
`<div>`. That restricted highlighting to styles which do not change glyph
advance width — colour, background, underline — because anything else made the
@@ -61,20 +80,54 @@ question of design, not of feasibility.
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
keeps typing on the drawn-selection code path, and removing it makes
CodeMirror enforce cursor association on the native selection, which iOS
answers with severe input lag. Every device also layers
`composerNativeSelectionExtension` (`editor/theme.ts`) on top: it re-shows
answers with severe input lag. **That much is not platform-specific and must
not be undone.** What differs is who paints the selection, and
`composerSelectionExtension` (`editor/theme.ts`) picks that per platform.
When CodeMirror 6.43.9's iOS predicate does not match,
`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows
the native selection, and — only while a range is selected — the native caret,
hiding the painted layers those replace. The native selection is the one that
shows for two reasons: the painted layer sits behind the content, so tokens
with their own background (inline code, fences) cover it completely; and
iOS's selection drag handles attach to the visible native selection and take
their colour from the caret, so a transparent caret means invisible handles.
The range-only caret scoping is load-bearing — a native caret visible while
typing makes WebKit re-render its caret UI after every keystroke, felt as
severe input lag. The selection tint comes from `--primary`, not the selection
token:
themes define `--interactive-selection` with its own alpha, so a translucent
mix of it is nearly invisible.
with their own background (inline code, fences) cover it completely; and the
platform's selection drag handles attach to the visible native selection and
take their colour from the caret, so a transparent caret means invisible
handles. The range-only caret scoping is load-bearing — a native caret visible
while typing makes the browser re-render its caret UI after every keystroke,
felt as severe input lag.
When CodeMirror 6.43.9's exact iOS predicate matches,
`composerIOSSelectionExtension` leaves selection-handle geometry and appearance
to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at
`z-index: -1`; the extension raises that layer above the content so opaque
token backgrounds cannot cover them, and leaves it transparent to touch.
The handle dots extend 8px past their range; matching scroller padding and
negative margin expand the clip area without moving the text or changing the
composer height. iOS still paints its taller system selection overlay even
when CSS makes `::selection` transparent. The extension therefore suppresses
CodeMirror's synthetic selection rectangles on iOS while leaving its handles,
cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system
highlight and themed rectangle overlap with visibly different heights.
Do not add a second custom layer or custom handles here: overlapping translucent
rectangles make selection darker at their seams and imitated handles drift from
the geometry WebKit actually manipulates. What iOS avoids is installing the
native-selection workaround above: explicitly restoring native paint and caret
makes WebKit re-measure them after every decoration redraw, and the composer
rebuilds every decoration on every keystroke. That cost is felt worst during
IME composition.
The non-iOS native selection tint comes from `--primary`, not the selection
token: themes define `--interactive-selection` with its own alpha, so mixing it
with transparent again is nearly invisible. The iOS system overlay owns its
visible selection fill.
The content element keeps the existing correction policy: on in the mobile UI,
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
Android's insert-period-on-double-space only when its value is exactly `off`.
`editor/autocorrect.ts` uses the HTML standard's
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
to keep desktop word correction off while avoiding that CodeMirror-only
revert. Its platform checks deliberately match CodeMirror's own browser flags.
`composerLanguage.ts` retokenizes the whole document on every change. The
composer holds a prompt, not a source file: it is short enough that a full pass
@@ -88,10 +141,14 @@ and the send path reading the same grammar.
drawn caret through a class it only writes while applying an update, so the
selection has to be the update that follows the focus.
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
inline comments and context into OpenCode's one-primary-plus-parts shape. The
oldest queued message becomes primary; **inline comments attach to the last
body the user authored** rather than becoming their own part; PR instructions
precede the PR diff.
context drafts and linked references into OpenCode's one-primary-plus-parts
shape. The oldest queued message becomes primary. **Every attached context
item (inline comments, terminal selections, browser annotations, PR context,
linked issue/PR) becomes its own synthetic text part carrying structured
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
metadata back to render context blocks. PR instructions precede the PR diff.
Queueing a message leaves context drafts in their store on purpose — the send
that later delivers the queue consumes them.
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
session) identity. Writes are debounced while typing but forced at every edge
where the page may stop running, because a pending timer is not a saved
@@ -101,6 +158,9 @@ and the send path reading the same grammar.
- `state/useDraftTarget.ts` — the draft can target a directory that does not
exist yet (a worktree being created). It must survive not appearing in the
branch list, or the selector snaps back to the project root mid-creation.
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
state and registers its application shortcuts locally. The selectors only
consume their shared prefix while the draft target UI is mounted.
## Mobile
@@ -4,6 +4,7 @@ import {
appendInlineText,
appendWithLineBreaks,
buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink,
withInlineInsertionBoundaries,
} from '../text';
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
});
});
describe('getMarkdownAutoPairEdit', () => {
test('completes a fenced block with the caret on the middle line', () => {
expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({
from: 2,
to: 2,
insert: '`\n\n```',
selectionStart: 4,
selectionEnd: 4,
});
});
test('completes a fence at the start of any line', () => {
expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({
from: 8,
to: 8,
insert: '`\n\n```',
selectionStart: 10,
selectionEnd: 10,
});
});
test('does not complete two backticks in the middle of a line', () => {
expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull();
});
test('wraps selected text and keeps the text selected', () => {
expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({
from: 1,
to: 4,
insert: '*ell*',
selectionStart: 2,
selectionEnd: 5,
});
});
});
@@ -34,9 +34,11 @@ import {
import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import type { ComposerAutoCorrect } from './autocorrect';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import { replaceWithCaret } from './documentEdits';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
import { composerEditorTheme, composerSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown';
export interface ComposerSelection {
@@ -63,8 +65,8 @@ export interface ComposerEditorHandle {
selectAll(): void;
/** Replace the current selection, leaving the caret after the insertion. */
insertText(text: string): void;
/** Replace an explicit range; the caret lands at `caret` or after the text. */
replaceRange(from: number, to: number, text: string, caret?: number): void;
/** Replace a range; selection defaults to a caret after the inserted text. */
replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
/** Viewport coordinates of the caret, for positioning popups. */
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
/** The scrollable element, for measuring and scroll compensation. */
@@ -89,8 +91,11 @@ export interface ComposerEditorProps {
placeholder?: string;
editable?: boolean;
spellCheck?: boolean;
/** Mobile keyboards; ignored on desktop. */
autoCorrect?: boolean;
/**
* The content element's autocorrect keyword. See `autocorrect.ts` for the
* case-sensitive CodeMirror workaround.
*/
autoCorrect?: ComposerAutoCorrect;
autoCapitalize?: 'none' | 'sentences';
/** Fill the available height instead of growing with the content. */
fillContainer?: boolean;
@@ -157,7 +162,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
placeholder,
editable = true,
spellCheck = false,
autoCorrect = false,
autoCorrect = 'off',
autoCapitalize = 'none',
fillContainer = false,
maxLines = 8,
@@ -234,14 +239,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
doc: handlersRef.current.value,
extensions: [
history(),
// `drawSelection()` must stay even though the native
// selection is what actually shows (see the theme's
// comment on `composerNativeSelectionExtension`):
// removing it makes CodeMirror enforce cursor
// association on the native selection, which iOS
// answers with severe input lag.
// `drawSelection()` must stay on every platform.
// `composerSelectionExtension()` changes only who
// paints the selection; removing `drawSelection()`
// makes CodeMirror enforce cursor association on the
// native selection, which iOS answers with severe lag.
drawSelection(),
composerNativeSelectionExtension,
composerSelectionExtension(),
EditorView.lineWrapping,
// Highest precedence: the composer's own keys must win
// over CodeMirror's defaults (Enter sends, ArrowUp
@@ -288,7 +292,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
}),
EditorView.contentAttributes.of({
spellcheck: String(handlersRef.current.spellCheck ?? false),
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
autocorrect: handlersRef.current.autoCorrect ?? 'off',
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
...(handlersRef.current['aria-label']
? { 'aria-label': handlersRef.current['aria-label'] }
@@ -344,17 +348,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was
// replaced. Every rewrite that reaches here appends or
// replaces wholesale; keeping the old caret instead left it
// stranded before the inserted text, and the next insertion
// or keystroke landed inside the previous one.
selection: { anchor: value.length },
});
// Skip every controlled writeback while the browser is composing.
// A stale value echo can differ from CodeMirror's newer document,
// and replacing it would interrupt the IME session and move the caret.
if (view.compositionStarted) return;
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was replaced.
// Every rewrite that reaches here appends or replaces wholesale;
// keeping the old caret instead left it stranded before the
// inserted text, and the next insertion or keystroke landed inside
// the previous one.
view.dispatch(replaceWithCaret(view.state, 0, current.length, value));
// A large insert can push the caret below the fold, and a
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
// heights are still estimates during the update, and the
@@ -451,7 +456,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view) return;
const content = view.contentDOM;
content.setAttribute('spellcheck', String(spellCheck));
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
content.setAttribute('autocorrect', autoCorrect);
content.setAttribute('autocapitalize', autoCapitalize);
}, [autoCapitalize, autoCorrect, spellCheck]);
@@ -508,17 +513,18 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
if (!view || !text) return;
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
...replaceWithCaret(view.state, from, to, text),
userEvent: 'input.type',
});
},
replaceRange(from, to, text, caret) {
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
const view = viewRef.current;
if (!view) return;
const caret = selectionStart === undefined
? undefined
: { anchor: selectionStart, head: selectionEnd ?? selectionStart };
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret ?? from + text.length },
...replaceWithCaret(view.state, from, to, text, caret),
userEvent: 'input.type',
});
},
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { composerAutoCorrect, type ComposerAutoCorrect } from '../autocorrect';
const platform = (overrides: Partial<Navigator>): Navigator => ({
maxTouchPoints: 0,
platform: '',
userAgent: '',
vendor: '',
...overrides,
} as Navigator);
const codeMirrorKeepsDoubleSpacePeriod = (
autoCorrect: ComposerAutoCorrect,
): boolean => autoCorrect !== 'off';
const affectedPlatforms: Array<[string, Navigator]> = [
['macOS', platform({ platform: 'MacIntel' })],
['iPhone', platform({
platform: 'iPhone',
userAgent: 'Mozilla/5.0 Mobile/15E148 Safari/604.1',
vendor: 'Apple Computer, Inc.',
})],
['iPadOS touch detection', platform({
maxTouchPoints: 5,
userAgent: 'Mozilla/5.0 Version/17.4 Safari/605.1.15',
vendor: 'Apple Computer, Inc.',
})],
['Android', platform({
platform: 'Linux armv8l',
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)',
})],
];
const unaffectedPlatforms: Array<[string, Navigator]> = [
['Windows', platform({ platform: 'Win32' })],
['Linux', platform({ platform: 'Linux x86_64' })],
];
describe('composerAutoCorrect', () => {
test('matches the pinned CodeMirror period-revert guard', () => {
const source = readFileSync(
fileURLToPath(import.meta.resolve('@codemirror/view')),
'utf8',
);
const semantics = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, '');
expect(/getAttribute\(["']autocorrect["']\)==["']off["']/.test(semantics)).toBe(true);
expect(semantics).toContain(
'constios=safari&&(/Mobile\\/\\w+/.test(nav.userAgent)||nav.maxTouchPoints>2)',
);
expect(semantics).toContain('mac:ios||/Mac/.test(nav.platform)');
expect(semantics).toContain('android:/Android\\b/.test(nav.userAgent)');
});
for (const [name, navigator] of affectedPlatforms) {
test(`preserves the ${name} platform period without enabling autocorrect`, () => {
const autoCorrect = composerAutoCorrect({ isMobile: false, navigator });
expect(autoCorrect.toLowerCase()).toBe('off');
// @codemirror/view 6.39.13 reverts the native period only for exact "off".
expect(codeMirrorKeepsDoubleSpacePeriod(autoCorrect)).toBe(true);
});
}
for (const [name, navigator] of unaffectedPlatforms) {
test(`leaves desktop correction off on ${name}`, () => {
expect(composerAutoCorrect({ isMobile: false, navigator })).toBe('off');
});
}
test('uses CodeMirror platform detection rather than a macOS user agent', () => {
expect(composerAutoCorrect({
isMobile: false,
navigator: platform({
platform: 'Linux x86_64',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
}),
})).toBe('off');
});
test('preserves the existing mobile autocorrect policy', () => {
expect(composerAutoCorrect({
isMobile: true,
navigator: platform({ platform: 'Win32' }),
})).toBe('on');
});
});
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { replaceWithCaret } from '../documentEdits';
const apply = (doc: string, from: number, to: number, insert: string, caret?: { anchor: number; head: number }) => {
const state = EditorState.create({ doc });
const next = state.update(replaceWithCaret(state, from, to, insert, caret)).state;
return { text: next.doc.toString(), selection: next.selection.main };
};
describe('replaceWithCaret', () => {
test('puts the caret at the end of a wholesale replacement', () => {
const { text, selection } = apply('old', 0, 3, 'a new draft');
expect(text).toBe('a new draft');
expect(selection.anchor).toBe(11);
expect(selection.head).toBe(11);
});
// Issue #3013: CodeMirror collapses `\r\n` into one line break, so a caret
// taken from the JS string length falls outside the document and dispatch
// throws `RangeError: Selection points outside of document`.
test('keeps the caret inside the document when CRLF is normalized away', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny');
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
test('survives a draft made only of CRLF breaks', () => {
const { text, selection } = apply('a', 0, 1, '\r\n\r\n\r\n');
expect(text).toBe('\n\n\n');
expect(selection.anchor).toBe(3);
});
test('places the caret after text inserted at the selection', () => {
const { text, selection } = apply('hello world', 5, 5, ',\r\n there');
expect(text).toBe('hello,\n there world');
expect(selection.anchor).toBe(13);
});
test('honours an explicit caret', () => {
const { selection } = apply('hello', 0, 5, 'goodbye', { anchor: 2, head: 4 });
expect(selection.anchor).toBe(2);
expect(selection.head).toBe(4);
});
test('clamps an explicit caret that the normalized document cannot hold', () => {
const { text, selection } = apply('a', 0, 1, 'x\r\ny', { anchor: 4, head: 4 });
expect(text).toBe('x\ny');
expect(selection.anchor).toBe(3);
});
});
@@ -1,16 +1,29 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { EditorState, type Extension } from '@codemirror/state';
import {
COMPOSER_EDITOR_THEME_SPEC,
IOS_SELECTION_THEME_SPEC,
NATIVE_SELECTION_THEME_SPEC,
composerEditorTheme,
composerIOSSelectionExtension,
composerNativeSelectionExtension,
composerSelectionExtension,
isCodeMirrorIOSNavigator,
} from '../theme';
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
function installationError(extension: Extension): string | null {
try {
EditorState.create({ extensions: [extension] });
return null;
} catch (error) {
return String(error);
}
}
describe('composerEditorTheme', () => {
/**
* EditorView.theme compiles its selectors when this module is imported and
@@ -20,13 +33,7 @@ describe('composerEditorTheme', () => {
* surfaces only in the running app, where it takes the composer down.
*/
test('its selectors compile and the theme can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerEditorTheme] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
expect(installationError(composerEditorTheme)).toBeNull();
});
/**
@@ -46,6 +53,14 @@ describe('composerEditorTheme', () => {
expect(rule.borderLeftColor.startsWith('var(--')).toBe(true);
});
test('the drawn caret is wide enough to remain prominent', () => {
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
const rule = (COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[cursorRule!];
expect(rule.borderLeftWidth).toBe('2px');
expect(rule.transform).toBe('scaleY(1.15)');
expect(rule.transformOrigin).toBe('center');
});
/**
* CodeMirror's own `.cm-cursor` rule and its `&dark` override are one and
* two classes deep respectively; a bare `.cm-cursor` selector loses to the
@@ -93,6 +108,10 @@ describe('composerEditorTheme', () => {
expect(rule.background.includes('transparent')).toBe(true);
}
});
test('the common theme does not re-show the native selection', () => {
expect(selectors.some((selector) => selector.includes('::selection'))).toBe(false);
});
});
describe('composerNativeSelectionTheme', () => {
@@ -100,21 +119,16 @@ describe('composerNativeSelectionTheme', () => {
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
/**
* Every device layers this over `drawSelection()`: the native selection
* paints over token backgrounds (the painted layer is hidden behind them)
* and iOS attaches its selection handles to it. `drawSelection()` must
* NOT be removed for that: without it CodeMirror starts enforcing cursor
* association on the native selection while typing in wrapped text, and
* iOS answers those programmatic selection moves with severe input lag.
* Every device except iOS layers this over `drawSelection()`: the native
* selection paints over token backgrounds (the painted layer is hidden
* behind them) and the platform attaches its selection handles to it.
* `drawSelection()` must NOT be removed for that: without it CodeMirror
* starts enforcing cursor association on the native selection while typing
* in wrapped text, and iOS answers those programmatic selection moves with
* severe input lag.
*/
test('it compiles and can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerNativeSelectionExtension] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
expect(installationError(composerNativeSelectionExtension)).toBeNull();
});
/**
@@ -142,9 +156,9 @@ describe('composerNativeSelectionTheme', () => {
});
/**
* iOS colours its selection drag handles from the caret colour. With
* `drawSelection()`'s `caret-color: transparent !important` in effect the
* handles are drawn invisibly. The native caret must come back with
* A platform showing native handles colours them from the caret colour.
* With `drawSelection()`'s `caret-color: transparent !important` in effect
* the handles are drawn invisibly. The native caret must come back with
* enough weight to win, and the drawn cursor layer must go so there are
* not two carets.
*
@@ -187,3 +201,106 @@ describe('composerNativeSelectionTheme', () => {
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
});
});
describe('composerIOSSelectionExtension', () => {
const layerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller > .cm-selectionLayer'];
const scrollerRule = IOS_SELECTION_THEME_SPEC['& .cm-scroller'];
const selectionBackgroundRule = IOS_SELECTION_THEME_SPEC['& .cm-selectionBackground'];
test('it compiles and can be installed', () => {
expect(installationError(composerIOSSelectionExtension)).toBeNull();
});
/**
* CodeMirror renders its selection layer at `z-index: -1`, behind the
* text. Inline code and code fences have opaque backgrounds and otherwise
* cover both the selection and the iOS handles. The base value is inline,
* so raising it without `!important` silently does nothing.
*/
test('CodeMirror selection and handles are raised above token backgrounds', () => {
expect(layerRule.zIndex).toBe('100 !important');
});
/**
* The layer now sits over the content and would intercept taps and drags
* by default. It only paints; CodeMirror/WebKit still own the gestures.
*/
test('the layer does not intercept touch', () => {
expect(layerRule.pointerEvents).toBe('none');
});
/**
* A higher z-index cannot escape overflow clipping. CodeMirror's dots
* extend 8px past the range, so the scroller needs that much internal room;
* the matching negative margin keeps the text and composer height fixed.
*/
test('the scroller reserves unclipped room for both handles', () => {
expect(scrollerRule.paddingBlock).toBe('8px');
expect(scrollerRule.marginBlock).toBe('-8px');
});
test('the CodeMirror fill does not stack over the iOS system highlight', () => {
expect(selectionBackgroundRule.background).toBe('transparent !important');
});
/**
* A second custom layer was visually indistinguishable from duplicate
* native selection UI. iOS must only reposition the one layer that
* CodeMirror already uses for both selection rectangles and handles.
*/
test('it does not add a second selection implementation', () => {
expect(Object.keys(IOS_SELECTION_THEME_SPEC)).toEqual([
'& .cm-scroller',
'& .cm-scroller > .cm-selectionLayer',
'& .cm-selectionBackground',
]);
});
});
describe('composerSelectionExtension', () => {
/**
* The split is the point: iOS is the only platform that pays for a visible
* native selection during composition, and CodeMirror 6.43.9 draws its
* handles. Collapsing the two branches into one would
* either restore the latency on iOS or leave every other platform without
* discoverable range selection.
*/
test('the CodeMirror iOS path uses its handles; other platforms keep native selection', () => {
expect(composerSelectionExtension(true)).toBe(composerIOSSelectionExtension);
expect(composerSelectionExtension(false)).toBe(composerNativeSelectionExtension);
});
/**
* The composer may remove the native fallback only when CodeMirror's own
* browser predicate enables its replacement handles. This deliberately
* includes CodeMirror's vendor and touch thresholds rather than using a
* broader application-level iOS heuristic.
*/
test('the platform predicate matches CodeMirror 6.43.9', () => {
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6) Mobile/15E148 Safari/604.1',
'Apple Computer, Inc.',
5,
)).toBe(true);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Apple Computer, Inc.',
5,
)).toBe(true);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Google Inc.',
5,
)).toBe(false);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
'Apple Computer, Inc.',
0,
)).toBe(false);
expect(isCodeMirrorIOSNavigator(
'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0)',
'Apple Computer, Inc.',
5,
)).toBe(false);
});
});
@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const composerEditorSource = readFileSync(
new URL('../ComposerEditor.tsx', import.meta.url),
'utf-8',
);
const writebackEffect = (): string => {
const start = composerEditorSource.indexOf('// Controlled value:');
expect(start).toBeGreaterThan(-1);
const end = composerEditorSource.indexOf('}, [value]);', start);
expect(end).toBeGreaterThan(start);
return composerEditorSource.slice(start, end);
};
describe('composer value writeback composition guard (issue #2527)', () => {
test('checks equality, then composition, before dispatching', () => {
const effect = writebackEffect();
const equalityCheck = effect.indexOf('if (current === value) return;');
const compositionGuard = effect.indexOf('if (view.compositionStarted) return;');
const dispatch = effect.indexOf('view.dispatch(');
expect(equalityCheck).toBeGreaterThan(-1);
expect(compositionGuard).toBeGreaterThan(equalityCheck);
expect(dispatch).toBeGreaterThan(compositionGuard);
});
});
@@ -0,0 +1,24 @@
export type ComposerAutoCorrect = 'on' | 'off' | 'Off';
type PlatformNavigator = Pick<Navigator,
'maxTouchPoints' | 'platform' | 'userAgent' | 'vendor'
>;
/** Keep desktop autocorrect off without triggering CodeMirror's period revert. */
export function composerAutoCorrect(options: {
isMobile: boolean;
navigator?: PlatformNavigator;
}): ComposerAutoCorrect {
if (options.isMobile) return 'on';
const nav = options.navigator
?? (typeof navigator === 'undefined'
? { maxTouchPoints: 0, platform: '', userAgent: '', vendor: '' }
: navigator);
// These must match CodeMirror's flags because its revert checks exact "off".
const ios = /Apple Computer/.test(nav.vendor)
&& (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2);
return ios || /Mac/.test(nav.platform) || /Android\b/.test(nav.userAgent)
? 'Off'
: 'off';
}
@@ -41,7 +41,7 @@ const languageContextField = StateField.define<ComposerLanguageContext>({
},
});
export const EMPTY_CONTEXT: ComposerLanguageContext = {
const EMPTY_CONTEXT: ComposerLanguageContext = {
inputMode: 'normal',
knownAgentNames: new Set(),
confirmedMentions: new Set(),
@@ -90,8 +90,3 @@ export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEX
decorationField,
];
}
/** The context currently in effect, for callers that need to read it back. */
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
return view.state.field(languageContextField);
}
@@ -0,0 +1,33 @@
import type { EditorState, TransactionSpec } from '@codemirror/state';
/**
* Replace a document range and leave the caret inside the resulting document.
*
* CodeMirror normalizes line endings on the way in: a `\r\n` pair becomes one
* line break, so the inserted string is longer than the text it produces. A
* caret derived from the JavaScript string therefore lands past the end of the
* document and `dispatch` throws `RangeError: Selection points outside of
* document`. The transaction never applies, so the un-normalized text stays in
* React state, gets persisted as a draft, and crashes the chat again on every
* restore (issue #3013).
*
* Deriving the caret from the change set instead keeps it correct for whatever
* CodeMirror actually inserted, without this module having to know the
* normalization rules.
*/
export const replaceWithCaret = (
state: EditorState,
from: number,
to: number,
insert: string,
caret?: { anchor: number; head: number },
): TransactionSpec => {
const changes = state.changes({ from, to, insert });
const clamp = (position: number): number => Math.min(Math.max(position, 0), changes.newLength);
// What CodeMirror inserted, measured on the document rather than on the
// string: the new length minus everything the change left untouched.
const insertedLength = changes.newLength - (state.doc.length - (to - from));
const anchor = caret ? clamp(caret.anchor) : from + insertedLength;
const head = caret ? clamp(caret.head) : anchor;
return { changes, selection: { anchor, head } };
};
@@ -5,6 +5,7 @@
* language layer emits, so the composer and the message list stay in step.
*/
import type { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
/**
@@ -19,6 +20,8 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
'&.cm-focused': { outline: 'none' },
'.cm-content': {
padding: '0',
// Keep the drawn empty-document cursor inside the scroller's horizontal clip.
paddingInlineStart: '1px',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
@@ -30,7 +33,10 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
// `caret-color: transparent !important` at the highest precedence and
// draws its own `.cm-cursor` element, whose base style is a hard-coded
// `border-left: 1.2px solid black`. Styling `caret-color` here therefore
// does nothing at all — the border is what has to be coloured.
// does nothing at all — the border is what has to be coloured. A 2px
// stroke makes the insertion point remain visible against every composer
// surface without relying on a fixed colour. A slight vertical scale makes
// it extend beyond the glyphs without changing CodeMirror's line geometry.
//
// CodeMirror recolours it for dark editors through `&dark .cm-cursor`,
// which needs the theme to declare itself dark. OpenChamber themes are not
@@ -43,6 +49,9 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
// moment this module is imported.
'&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor': {
borderLeftColor: 'var(--surface-foreground)',
borderLeftWidth: '2px',
transform: 'scaleY(1.15)',
transformOrigin: 'center',
},
'.cm-line': { padding: '0' },
'.cm-scroller': {
@@ -72,23 +81,16 @@ export const COMPOSER_EDITOR_THEME_SPEC = {
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
},
// The native selection still shows through in places CodeMirror does not
// draw over, such as the placeholder. Same colour as the native-selection
// theme below, for the same reason: the selection token carries its own
// alpha and reads as nearly invisible when mixed down again.
'& ::selection': {
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
},
};
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
/**
* Every device keeps `drawSelection()` but shows the NATIVE selection through
* it, for two independent reasons:
* Outside CodeMirror's iOS branch, devices keep `drawSelection()` but show the
* NATIVE selection through it, for two independent reasons:
*
* - iOS attaches its selection handles (the draggable pins after a
* double-tap) to the *visible* native selection, and `drawSelection()`
* - Their selection drag handles (the draggable pins after a double-tap)
* attach to the *visible* native selection, and `drawSelection()`
* hides it with `.cm-line ::selection { background: transparent
* !important }`, so the handles never appear and range selection is
* undiscoverable.
@@ -97,12 +99,15 @@ export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
* the selection is invisible inside those spans. The native selection
* paints over element backgrounds.
*
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
* association on the native selection while typing in wrapped text
* programmatic selection moves that iOS answers with severe input lag (each
* one also resets the keyboard's autocorrect context). Typing must stay on
* the drawn-selection code path; only the paint changes.
* Dropping `drawSelection()` entirely is NOT an option, on any platform:
* without it CodeMirror clears the `nativeSelectionHidden` facet and starts
* enforcing cursor association on the native selection while typing in
* wrapped text programmatic selection moves that iOS answers with severe
* input lag (each one also resets the keyboard's autocorrect context). Typing
* must stay on the drawn-selection code path; only the paint changes.
*
* CodeMirror's iOS branch does NOT use this arrangement
* `composerIOSSelectionExtension` below explains why.
*
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
* they carry `!important` and one class more specificity
@@ -146,17 +151,106 @@ export const NATIVE_SELECTION_THEME_SPEC = {
},
};
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
/**
* The native-selection arrangement, installed on every device: the theme
* above plus the `.oc-native-range` marker class that scopes its caret rules
* to the moments a range is actually selected. `editorAttributes`
* The native-selection arrangement, installed outside CodeMirror's iOS branch:
* the theme above plus the `.oc-native-range` marker class that scopes its
* caret rules to the moments a range is actually selected. `editorAttributes`
* re-evaluates on every update, so the class follows the selection with no
* listener of its own.
*/
export const composerNativeSelectionExtension = [
export const composerNativeSelectionExtension: Extension = [
composerNativeSelectionTheme,
EditorView.editorAttributes.of((view) =>
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
];
/**
* When its iOS predicate matches, CodeMirror 6.43.9 draws the range handles
* into the same layer as the selection, so CodeMirror owns both their geometry
* and appearance.
*
* That layer normally renders at `z-index: -1`, behind the content. Inline
* code and code fences have opaque backgrounds and would cover both the tint
* and handles. Raising the one existing layer fixes that without introducing
* a second set of rectangles or trying to imitate WebKit's controls. The
* layer remains transparent to touch so WebKit receives selection gestures.
*
* What iOS avoids is the native-selection workaround above: explicitly
* restoring the native highlight and caret makes WebKit re-measure and repaint
* that UI after every decoration redraw. `composerLanguage.ts` rebuilds the
* whole decoration set on every keystroke, so the cost is felt worst during
* IME composition where each intermediate replacement pays for it. WebKit's
* unavoidable system selection overlay remains the only visible fill.
*/
export const IOS_SELECTION_THEME_SPEC = {
// The handles extend 8px above/below their range. The scroller clips them
// at its own edge even when the layer has a high z-index, so reserve that
// room inside the clipping box and pull the box outward by the same amount.
// Text and composer height stay where they were; only the clip area grows.
'& .cm-scroller': {
marginBlock: '-8px',
paddingBlock: '8px',
},
'& .cm-scroller > .cm-selectionLayer': {
// CodeMirror writes `z-index: -1` inline. `!important` is intentional:
// without it token backgrounds cover the selection and its handles.
zIndex: '100 !important',
pointerEvents: 'none',
},
// iOS keeps showing its taller system selection overlay even when
// ::selection is transparent. Painting CodeMirror's themed rectangles as
// well produces two visibly misaligned fills, so only the synthetic
// background is suppressed. The handles in this layer remain visible.
'& .cm-selectionBackground': {
background: 'transparent !important',
},
};
export const composerIOSSelectionExtension: Extension =
EditorView.theme(IOS_SELECTION_THEME_SPEC);
/**
* Which selection paint the composer installs. The split is the platform's,
* not a preference: iOS is the one place where restoring native selection
* paint and caret costs measurable input latency, and the only place
* CodeMirror supplies replacement drag handles.
*
* The caller can pass the policy, so the choice stays testable and is made
* once per editor rather than once per module load.
*/
export function composerSelectionExtension(
useCodeMirrorIOSHandles: boolean = usesCodeMirrorIOSSelectionHandles(),
): Extension {
return useCodeMirrorIOSHandles
? composerIOSSelectionExtension
: composerNativeSelectionExtension;
}
/**
* Mirrors @codemirror/view 6.43.9's iOS predicate. This branch may only rely
* on the drawn handles when CodeMirror itself will create them; a broader iOS
* heuristic could remove the native fallback without installing a replacement.
*/
export function isCodeMirrorIOSNavigator(
userAgent: string,
vendor: string,
maxTouchPoints: number,
): boolean {
const isIE = /Edge\/(\d+)/.test(userAgent)
|| /MSIE \d/.test(userAgent)
|| /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.test(userAgent);
if (isIE || !/Apple Computer/.test(vendor)) return false;
return /Mobile\/\w+/.test(userAgent) || maxTouchPoints > 2;
}
function usesCodeMirrorIOSSelectionHandles(): boolean {
const nav = globalThis.navigator;
if (!nav) return false;
return isCodeMirrorIOSNavigator(
nav.userAgent || '',
nav.vendor || '',
nav.maxTouchPoints ?? 0,
);
}
@@ -114,5 +114,3 @@ function matchMention(
});
return query === null ? null : { kind: 'mention', query };
}
export type { FileMentionAutocompleteInputSource };
@@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
@@ -35,6 +37,7 @@ export interface DraftTargetProject {
color?: string | null;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
kind?: 'chat' | 'project';
}
/** A project's display name, falling back to its directory name. */
@@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string }
}
export function useDraftTarget(enabled: boolean) {
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[];
const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects);
const { t } = useI18n();
const chatProject = React.useMemo<DraftTargetProject>(() => ({
id: CHAT_DRAFT_PROJECT_ID,
path: '',
label: t('layout.mainTab.chat'),
kind: 'chat',
}), [t]);
const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) {
const { git: runtimeGit } = useRuntimeAPIs();
const selectedDraftProject = React.useMemo(() => {
if (newSessionDraft?.target === 'chat') return chatProject;
const explicit = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
@@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) {
return active;
}
return projects[0] ?? null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
return configuredProjects[0] ?? chatProject;
}, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]);
const selectedDraftProjectPath = React.useMemo(
() => normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.path],
() => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.kind, selectedDraftProject?.path],
);
const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null;
const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat'
? getProjectDisplayLabel(selectedDraftProject)
: null;
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
const selectedDraftProjectBranchesFetchedAt = useGitStore(
@@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) {
if (!project) {
return;
}
if (project.kind === 'chat') {
setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true });
return;
}
if (activeProjectId !== projectId) {
setActiveProjectIdOnly(projectId);
}
@@ -17,6 +17,15 @@ import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import type { ComposerEditorHandle } from '../editor/ComposerEditor';
// Android mobile browsers are the pan-mode holdouts this pin exists for on
// the CHAT screen too: interactive-widget=resizes-content is ignored by a
// fair share of Android WebView/Chrome builds, and unlike iOS Safari they do
// not reliably reveal the focused field either — the composer just stays
// behind the keyboard. iOS keeps its browser-native reveal on the chat
// screen, so this stays Android-only there.
// Callers are browser-only React effects, so navigator always exists here.
const isAndroidBrowser = (): boolean => /Android/i.test(navigator.userAgent);
export interface MobileViewportPinOptions {
isMobile: boolean;
/** Composer expanded to fullscreen on mobile. */
@@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void {
};
}, [editorRef, formRef, isFullscreen, isMobile]);
// Draft screen with the keyboard up: anchor the normal-height composer to
// the visible bottom. The chat screen does not need this — its own
// focused-field reveal works there.
// Keyboard up: anchor the normal-height composer to the visible bottom.
// Draft screen on every mobile browser; chat screen only on Android,
// where neither viewport resizing nor the focused-field reveal can be
// relied on (iOS chat keeps the browser's own reveal).
React.useLayoutEffect(() => {
if (!isMobile || isCapacitorApp()) return;
if (!isDraftScreen || isFullscreen || !isFocused) return;
if (isFullscreen || !isFocused) return;
if (!isDraftScreen && !isAndroidBrowser()) return;
const vv = window.visualViewport;
const form = formRef.current;
if (!vv || !form) return;
@@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
import {
buildOutgoingMessage,
type OutgoingMessageDeps,
@@ -26,7 +28,6 @@ const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps
},
sanitizeAttachments: (files) => [...(files ?? [])],
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
...overrides,
});
@@ -37,7 +38,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
composerAttachments: [],
inlineComments: [],
syntheticTexts: [],
linkedIssueContext: null,
linkedIssue: null,
linkedPr: null,
...overrides,
});
@@ -130,36 +131,51 @@ describe('agent mentions', () => {
});
});
describe('inline comments', () => {
test('attach to the composer text when nothing was queued', () => {
const commentDraft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
id: 'icd-1',
sessionKey: 's1',
source: 'diff',
fileLabel: 'src/app.ts',
startLine: 3,
endLine: 5,
side: 'modified',
code: 'const x = 1;',
language: 'ts',
text: 'fix this',
createdAt: 1,
...overrides,
});
describe('context drafts', () => {
test('each becomes a synthetic part carrying structured metadata', () => {
const result = buildOutgoingMessage(input({
composerText: 'body',
inlineComments: [{}, {}],
inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })],
}), deps());
expect(result.primaryText).toBe('body\n[2 comments]');
expect(result.primaryText).toBe('body');
expect(result.additionalParts).toHaveLength(2);
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
.toEqual(contextPayloadFromDraft(commentDraft()));
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
.toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined })));
expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
expect(result.additionalParts[0].text).toContain('fix this');
});
test('attach to the last authored part when messages were queued', () => {
test('context parts precede other synthetic context', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'queued' }],
composerText: 'typed',
inlineComments: [{}],
composerText: 'body',
inlineComments: [commentDraft()],
syntheticTexts: ['conflict note'],
}), deps());
expect(result.primaryText).toBe('queued');
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text))
.toEqual(['comment', 'conflict note']);
});
test('fall back to primary when the queue produced no additional parts', () => {
const result = buildOutgoingMessage(input({
queued: [{ content: 'only queued' }],
inlineComments: [{}],
}), deps());
expect(result.primaryText).toBe('only queued\n[1 comments]');
});
test('no comments changes nothing', () => {
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
.toBe('body');
test('no drafts changes nothing', () => {
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts)
.toEqual([]);
});
});
@@ -167,26 +183,31 @@ describe('synthetic context', () => {
test('a linked PR sends its instructions before its diff', () => {
const result = buildOutgoingMessage(input({
composerText: 'review this',
linkedPr: { instructions: 'how to read it', context: 'the diff' },
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' },
}), deps());
expect(result.additionalParts.map((p) => p.text))
.toEqual(['how to read it', 'the diff']);
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
.toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' });
});
test('a linked issue is sent as context', () => {
const result = buildOutgoingMessage(input({
composerText: 'fix it',
linkedIssueContext: 'issue body',
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps());
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]);
expect(result.additionalParts).toHaveLength(1);
expect(result.additionalParts[0].text).toBe('issue body');
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
});
test('synthetic texts precede the linked references', () => {
const result = buildOutgoingMessage(input({
composerText: 'x',
syntheticTexts: ['conflict note'],
linkedIssueContext: 'issue body',
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps());
expect(result.additionalParts.map((p) => p.text))
.toEqual(['conflict note', 'issue body']);
@@ -211,7 +232,9 @@ describe('synthetic context', () => {
});
test('context alone is still worth sending', () => {
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps());
const result = buildOutgoingMessage(input({
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
}), deps());
expect(result.isEmpty).toBe(false);
});
@@ -230,8 +253,8 @@ describe('full assembly order', () => {
queued: [{ content: 'q1' }, { content: 'q2' }],
composerText: 'typed /deploy',
syntheticTexts: ['synthetic'],
linkedIssueContext: 'issue',
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
}), deps());
expect(result.primaryText).toBe('q1');
@@ -14,12 +14,16 @@
*/
import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
export interface OutgoingPart {
text: string;
attachments?: AttachedFile[];
/** Synthetic parts are context for the model, not shown as user content. */
synthetic?: boolean;
/** Structured context (see contextParts.ts), persisted with the part. */
metadata?: ContextPartMetadata;
}
export interface OutgoingMessage {
@@ -43,12 +47,12 @@ export interface OutgoingMessageInput {
/** The composer's own text, or null when this send skips it. */
composerText: string | null;
composerAttachments: readonly AttachedFile[];
/** Inline review comments, appended to the user's last authored text. */
inlineComments: readonly unknown[];
/** Context drafts (code comments, terminal selections, annotations, PR context). */
inlineComments: readonly InlineCommentDraft[];
/** Synthetic context produced elsewhere (conflict resolution, and such). */
syntheticTexts: readonly string[];
linkedIssueContext: string | null;
linkedPr: { instructions: string; context: string } | null;
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
}
/**
@@ -64,8 +68,6 @@ export interface OutgoingMessageDeps {
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
/** Skills named inline with `/name`. */
collectSkillNames: (text: string) => string[];
/** Append inline review comments to a message body. */
appendComments: (text: string, comments: readonly unknown[]) => string;
/** Instruction telling the model which skills the user named. */
buildSkillInstruction: (names: string[]) => string | null;
}
@@ -134,33 +136,29 @@ export function buildOutgoingMessage(
}
}
// Inline comments attach to the last thing the user authored, so they read
// as a continuation of it rather than as a separate turn.
if (input.inlineComments.length > 0) {
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
? additionalParts[additionalParts.length - 1]
: null;
if (lastAuthored) {
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
} else {
primaryText = deps.appendComments(primaryText, input.inlineComments);
}
// Everything below is context for the model, never plain user text. Each
// attached context item becomes its own synthetic part carrying structured
// metadata, so the timeline can render it as a context block after the
// server echoes the message back.
for (const draft of input.inlineComments) {
additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
}
// Everything below is context for the model, never user-visible content.
for (const text of input.syntheticTexts) {
additionalParts.push({ text, synthetic: true });
}
if (input.linkedIssueContext) {
additionalParts.push({ text: input.linkedIssueContext, synthetic: true });
if (input.linkedIssue) {
const { number, title, url, contextText } = input.linkedIssue;
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
}
if (input.linkedPr) {
// Instructions before context: the model is told how to read the diff
// before it is given the diff.
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
const { number, title, url, instructions, context } = input.linkedPr;
additionalParts.push({ text: instructions, synthetic: true });
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
}
const skillInstruction = deps.buildSkillInstruction(skillNames);
@@ -91,7 +91,7 @@ export function buildImagePasteInsertion(pastedText: string, citationText: strin
* A single-line URL pasted over a selection becomes a markdown link rather
* than replacing the selected text.
*/
export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
/**
* Whether a pasted URL should wrap the selection as `[selected](url)`. A URL
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
&& selected.trim().length > 0
&& !selected.includes('](');
}
const MARKDOWN_WRAP_PAIRS: Record<string, [string, string]> = {
'`': ['`', '`'],
'*': ['*', '*'],
'_': ['_', '_'],
'~': ['~', '~'],
'(': ['(', ')'],
'[': ['[', ']'],
'{': ['{', '}'],
'"': ['"', '"'],
"'": ["'", "'"],
};
/**
* Markdown source-mode conveniences handled before CodeMirror inserts a key.
* The returned text change and selection belong to one editor transaction so
* the caret cannot be applied against the previous document.
*/
export function getMarkdownAutoPairEdit(
value: string,
key: string,
selectionStart: number,
selectionEnd: number,
): {
from: number;
to: number;
insert: string;
selectionStart: number;
selectionEnd: number;
} | null {
const pair = MARKDOWN_WRAP_PAIRS[key];
if (selectionEnd > selectionStart && pair) {
const selected = value.slice(selectionStart, selectionEnd);
const [open, close] = pair;
return {
from: selectionStart,
to: selectionEnd,
insert: `${open}${selected}${close}`,
selectionStart: selectionStart + open.length,
selectionEnd: selectionEnd + open.length,
};
}
if (key === '`' && selectionStart === selectionEnd) {
const before = value.slice(0, selectionStart);
if (/(^|\n)``$/.test(before)) {
return {
from: selectionStart,
to: selectionEnd,
insert: '`\n\n```',
selectionStart: selectionStart + 2,
selectionEnd: selectionStart + 2,
};
}
}
return null;
}
@@ -0,0 +1,40 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface AutocompleteRowTooltipProps {
description?: string;
active: boolean;
children: React.ReactElement;
}
export function AutocompleteRowTooltip({ description, active, children }: AutocompleteRowTooltipProps) {
const [delayedActive, setDelayedActive] = React.useState(false);
React.useEffect(() => {
if (!active || !description) {
setDelayedActive(false);
return;
}
const timeout = window.setTimeout(() => setDelayedActive(true), 200);
return () => window.clearTimeout(timeout);
}, [active, description]);
if (!description) return children;
return (
<Tooltip delayDuration={0} open={active && delayedActive} onOpenChange={() => {}}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{active && delayedActive ? (
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<p className="typography-meta whitespace-pre-wrap">{description}</p>
</TooltipContent>
) : null}
</Tooltip>
);
}
@@ -89,7 +89,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(handlePickLocalFiles);
@@ -2,164 +2,381 @@
* Context chips above the composer.
*
* Each chip stands for context that will be attached to the next message but
* is not part of its text: review comments left in a diff, captured dev-server
* logs, preview annotations, terminal selections. They are shown so the user
* knows what is riding along and can drop any of it before sending.
* is not part of its text: review comments left in a diff, preview
* annotations, terminal selections, PR context, chat quotes. Hovering (or
* tapping) a chip opens a stacked preview of its items above the composer,
* where a comment the user wrote can be edited in place and any item removed
* before sending.
*/
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import type { IconName } from '@/components/icon/icons';
import { useI18n } from '@/lib/i18n';
import type { InlineCommentDraft, InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import {
EMPTY_INLINE_COMMENT_DRAFTS,
getInlineCommentDraftKey,
useInlineCommentDraftStore,
type InlineCommentDraft,
type InlineCommentDraftTarget,
type InlineCommentSource,
} from '@/stores/useInlineCommentDraftStore';
import type { Theme } from '@/types/theme';
export interface ComposerContextChipsProps {
/** Terminal selections, which show their own label and line range. */
terminalDrafts: readonly InlineCommentDraft[];
reviewCount: number;
prCommentCount: number;
prCheckCount: number;
previewConsoleCount: number;
previewAnnotationCount: number;
draftTarget: InlineCommentDraftTarget | null;
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
onRemoveReviewDrafts: () => void;
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void;
colors: Theme['colors'];
}
/** A chip showing how many items of one kind are attached, with a clear action. */
function CountChip(props: {
/** Chip groups: every terminal selection is its own chip; the rest group by kind. */
type ChipGroup = {
key: string;
icon: IconName;
iconClassName?: string;
label: string;
count: number;
removeLabel: string;
drafts: InlineCommentDraft[];
};
const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan', 'file-quote'];
/** Sources whose drafts carry a user-written comment that can be edited. */
const editableSource = (source: InlineCommentSource): boolean => source !== 'terminal';
/** Captured code/output kinds read better monospaced; quoted prose does not. */
const monoSource = (source: InlineCommentSource): boolean =>
source !== 'chat-quote' && source !== 'preview-annotation' && source !== 'file-quote';
const basename = (path: string): string => {
const segments = path.split('/').filter(Boolean);
return segments[segments.length - 1] ?? path;
};
const ENTRY_ACTION_CLASS = 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]';
const ENTRY_LABEL_CLASS = 'text-[10px] font-medium uppercase tracking-wide text-[var(--surface-mutedForeground)] opacity-60';
const DraftPreviewEntry: React.FC<{
draft: InlineCommentDraft;
index: number;
title: string;
editing: boolean;
onStartEdit: () => void;
onEndEdit: () => void;
onRemove: () => void;
colors: Theme['colors'];
icon?: React.ReactNode;
}) {
return (
<div
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style={{
backgroundColor: props.colors?.surface?.elevated,
borderColor: props.colors?.interactive?.border,
}}
>
{props.icon}
<span className="text-xs font-medium text-muted-foreground">{props.label}</span>
<span className="text-xs font-semibold" style={{ color: props.colors?.status?.info }}>
{props.count}
</span>
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
style={{ minHeight: 0, minWidth: 0 }}
onClick={props.onRemove}
aria-label={props.removeLabel}
title={props.removeLabel}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
);
}
export function ComposerContextChips(props: ComposerContextChipsProps) {
onSaveComment: ((text: string) => void) | null;
}> = ({ draft, index, title, editing, onStartEdit, onEndEdit, onRemove, onSaveComment }) => {
const { t } = useI18n();
const {
terminalDrafts,
reviewCount,
prCommentCount,
prCheckCount,
previewConsoleCount,
previewAnnotationCount,
draftTarget,
onRemoveDraft,
onRemoveReviewDrafts,
onRemovePreviewDrafts,
colors,
} = props;
const [editText, setEditText] = React.useState(draft.text);
const editRef = React.useRef<HTMLTextAreaElement>(null);
React.useEffect(() => {
if (!editing) return;
setEditText(draft.text);
queueMicrotask(() => {
const element = editRef.current;
if (element) {
element.focus();
element.setSelectionRange(element.value.length, element.value.length);
}
});
// The draft text at edit start is the baseline; later store updates are
// our own saves.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing]);
const commitEdit = () => {
if (onSaveComment && editText !== draft.text) {
onSaveComment(editText);
}
onEndEdit();
};
const cancelEdit = () => {
setEditText(draft.text);
onEndEdit();
};
// Keep focus in the textarea while a header button is pressed: without
// this the textarea's blur commits first, the header re-renders under the
// pointer, and the click lands on the button that replaced the pressed one
// (save punches through to edit, cancel to remove).
const keepEditorFocus = (event: React.PointerEvent) => {
if (editing) event.preventDefault();
};
return (
<div className="flex flex-wrap items-center gap-2 pb-2">
{terminalDrafts.map((draft) => (
<div
key={draft.id}
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1"
title={draft.code}
>
<Icon name="terminal" className="h-3.5 w-3.5" />
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
{t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
})}
</span>
<div>
<div className="flex items-center gap-1.5 px-3 py-1.5"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}>
<span className="text-xs font-medium text-[var(--surface-mutedForeground)]">{index + 1}.</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--surface-foreground)]" title={title}>
{title}
</span>
{onSaveComment ? (
<button
type="button"
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
onClick={() => draftTarget && onRemoveDraft(draftTarget, draft.id)}
aria-label={t('chat.chatInput.terminalContextRemove')}
title={t('chat.chatInput.terminalContextRemove')}
className={ENTRY_ACTION_CLASS}
style={{ minHeight: 0, minWidth: 0 }}
onPointerDown={keepEditorFocus}
onClick={editing ? commitEdit : onStartEdit}
aria-label={t('chat.chatInput.contextPreview.edit')}
title={t('chat.chatInput.contextPreview.edit')}
>
<Icon name="close" className="h-3 w-3" />
<Icon name={editing ? 'check' : 'pencil'} className="h-3 w-3" />
</button>
) : null}
<button
type="button"
className={ENTRY_ACTION_CLASS}
style={{ minHeight: 0, minWidth: 0 }}
onPointerDown={keepEditorFocus}
onClick={editing ? cancelEdit : onRemove}
aria-label={t('chat.chatInput.contextPreview.remove')}
title={t('chat.chatInput.contextPreview.remove')}
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
<div className="space-y-2 px-3 py-2">
{draft.code.trim() ? (
<div>
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.selectedLabel')}</div>
<div
className={
monoSource(draft.source)
? 'mt-0.5 whitespace-pre-wrap break-words font-mono text-xs text-[var(--surface-foreground)]'
: 'mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]'
}
>
{draft.code}
</div>
</div>
) : null}
{onSaveComment && (editing || draft.text.trim()) ? (
<div>
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.commentLabel')}</div>
{editing ? (
<textarea
ref={editRef}
rows={2}
value={editText}
onChange={(event) => setEditText(event.target.value)}
onBlur={commitEdit}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
commitEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
setEditText(draft.text);
onEndEdit();
}
}}
placeholder={t('chat.textSelection.comment.placeholder')}
className="mt-0.5 w-full resize-none rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-2 py-1 text-sm text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]"
style={{ minHeight: 0 }}
/>
) : (
<div className="mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]">{draft.text}</div>
)}
</div>
) : null}
</div>
</div>
);
};
export function ComposerContextChips({ draftTarget, colors }: ComposerContextChipsProps) {
const { t } = useI18n();
const draftKey = draftTarget
? getInlineCommentDraftKey(getRuntimeKey(), draftTarget.directory, draftTarget.sessionKey)
: null;
const drafts = useInlineCommentDraftStore(
React.useCallback(
(state) => (draftKey ? state.drafts[draftKey] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS),
[draftKey],
),
);
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
const [openGroupKey, setOpenGroupKey] = React.useState<string | null>(null);
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
const editingRef = React.useRef<string | null>(null);
editingRef.current = editingDraftId;
const containerRef = React.useRef<HTMLDivElement>(null);
const closeTimerRef = React.useRef<number | null>(null);
const cancelClose = React.useCallback(() => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
// Hover-away close. Suspended while a comment is being edited: entering or
// leaving edit mode reflows the panel under the pointer, and a synthetic
// mouseleave from that reflow must not tear the editor down.
const scheduleClose = React.useCallback(() => {
if (editingRef.current) return;
cancelClose();
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null;
setOpenGroupKey(null);
}, 150);
}, [cancelClose]);
React.useEffect(() => cancelClose, [cancelClose]);
// Clicking outside the chips + panel closes the preview even when a reflow
// swallowed the mouseleave (e.g. right after finishing an edit).
React.useEffect(() => {
if (!openGroupKey) return;
const handlePointerDown = (event: PointerEvent) => {
// SAFETY: a pointer event target inside the document is always a
// Node; `contains` only needs that.
if (containerRef.current?.contains(event.target as Node)) return;
setOpenGroupKey(null);
setEditingDraftId(null);
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [openGroupKey]);
const titleFor = React.useCallback((draft: InlineCommentDraft): string => {
switch (draft.source) {
case 'terminal':
return t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
});
case 'preview-annotation':
return t('chat.message.context.browserAnnotation', { page: draft.fileLabel });
case 'pr-comment':
return t('chat.message.context.prComment', { label: draft.fileLabel });
case 'pr-check':
return t('chat.message.context.prCheck', { label: draft.fileLabel });
case 'chat-quote':
return t('chat.message.context.chatQuote');
case 'file-quote':
return draft.startLine > 0 && draft.endLine > 0
? (draft.startLine === draft.endLine
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine }))
: t('chat.message.context.fileQuote', { file: basename(draft.fileLabel) });
default:
return draft.startLine === draft.endLine
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine });
}
}, [t]);
const groups = React.useMemo<ChipGroup[]>(() => {
const result: ChipGroup[] = [];
const byKind = (
key: string,
icon: IconName,
label: string,
match: (draft: InlineCommentDraft) => boolean,
iconClassName?: string,
) => {
const matched = drafts.filter(match);
if (matched.length > 0) {
result.push({ key, icon, iconClassName, label, count: matched.length, drafts: matched });
}
};
for (const draft of drafts) {
if (draft.source !== 'terminal') continue;
result.push({
key: `terminal-${draft.id}`,
icon: 'terminal',
label: t('chat.chatInput.terminalContext', {
terminal: draft.fileLabel,
start: draft.startLine,
end: draft.endLine,
}),
count: 0,
drafts: [draft],
});
}
byKind('review', 'chat-1', t('chat.chatInput.reviewComments'), (draft) => REVIEW_SOURCES.includes(draft.source));
byKind('pr-comment', 'git-pull-request', t('chat.chatInput.prCommentContext'), (draft) => draft.source === 'pr-comment');
byKind('pr-check', 'close-circle', t('chat.chatInput.prCheckContext'), (draft) => draft.source === 'pr-check', 'text-[var(--status-error)]');
byKind('chat-quote', 'chat-1', t('chat.chatInput.chatQuoteContext'), (draft) => draft.source === 'chat-quote');
byKind('annotation', 'global', t('chat.chatInput.previewAnnotations'), (draft) => draft.source === 'preview-annotation');
return result;
}, [drafts, t]);
React.useEffect(() => {
if (openGroupKey && !groups.some((group) => group.key === openGroupKey)) {
setOpenGroupKey(null);
setEditingDraftId(null);
}
}, [groups, openGroupKey]);
if (!draftTarget || drafts.length === 0) return null;
const openGroup = openGroupKey ? groups.find((group) => group.key === openGroupKey) ?? null : null;
return (
<div className="relative" ref={containerRef}>
{openGroup ? (
<div
className="oc-glass-popover absolute bottom-full left-0 z-30 mb-1.5 w-full max-w-[480px] overflow-hidden rounded-xl border border-[var(--interactive-border)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
<div className="max-h-[min(50vh,420px)] divide-y divide-[var(--interactive-border)] overflow-y-auto">
{openGroup.drafts.map((draft, index) => (
<DraftPreviewEntry
key={draft.id}
draft={draft}
index={index}
title={titleFor(draft)}
editing={editingDraftId === draft.id}
onStartEdit={() => setEditingDraftId(draft.id)}
onEndEdit={() => setEditingDraftId((current) => (current === draft.id ? null : current))}
onRemove={() => removeDraft(draftTarget, draft.id)}
onSaveComment={editableSource(draft.source)
? (text) => updateDraft(draftTarget, draft.id, { text })
: null}
/>
))}
</div>
</div>
))}
{reviewCount > 0 ? (
<CountChip
label={t('chat.chatInput.reviewComments')}
count={reviewCount}
removeLabel={t('chat.chatInput.reviewCommentsRemove')}
onRemove={onRemoveReviewDrafts}
colors={colors}
/>
) : null}
{prCommentCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCommentContext')}
count={prCommentCount}
removeLabel={t('chat.chatInput.prCommentContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-comment')}
colors={colors}
icon={<Icon name="git-pull-request" className="h-3.5 w-3.5 text-muted-foreground" />}
/>
) : null}
{prCheckCount > 0 ? (
<CountChip
label={t('chat.chatInput.prCheckContext')}
count={prCheckCount}
removeLabel={t('chat.chatInput.prCheckContextRemove')}
onRemove={() => onRemovePreviewDrafts('pr-check')}
colors={colors}
icon={<Icon name="close-circle" className="h-3.5 w-3.5 text-[var(--status-error)]" />}
/>
) : null}
{previewConsoleCount > 0 ? (
<CountChip
label={t('chat.chatInput.devServerLogs')}
count={previewConsoleCount}
removeLabel={t('chat.chatInput.devServerLogsRemove')}
onRemove={() => onRemovePreviewDrafts('preview-console')}
colors={colors}
/>
) : null}
{previewAnnotationCount > 0 ? (
<CountChip
label={t('chat.chatInput.previewAnnotations')}
count={previewAnnotationCount}
removeLabel={t('chat.chatInput.previewContextRemove')}
onRemove={() => onRemovePreviewDrafts('preview-annotation')}
colors={colors}
/>
) : null}
<div className="flex flex-wrap items-center gap-2 pb-2">
{groups.map((group) => (
<button
key={group.key}
type="button"
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border px-2.5 py-1 text-left"
style={{
backgroundColor: colors?.surface?.elevated,
borderColor: colors?.interactive?.border,
}}
onMouseEnter={() => {
cancelClose();
setOpenGroupKey(group.key);
}}
onMouseLeave={scheduleClose}
onClick={() => {
if (editingRef.current) return;
setOpenGroupKey((current) => (current === group.key ? null : group.key));
}}
aria-expanded={openGroupKey === group.key}
>
<Icon name={group.icon} className={`h-3.5 w-3.5 shrink-0 text-muted-foreground ${group.iconClassName ?? ''}`} />
<span className="truncate text-xs font-medium text-muted-foreground">{group.label}</span>
{group.count > 0 ? (
<span className="text-xs font-semibold" style={{ color: colors?.status?.info }}>
{group.count}
</span>
) : null}
</button>
))}
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation';
import {
Select,
SelectContent,
@@ -23,8 +24,10 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { useKeybind } from '@/hooks/useKeybind';
import type { Theme } from '@/types/theme';
import { normalizePath } from '../attachments/filePaths';
import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget';
@@ -54,10 +57,12 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined;
/** A project's icon (custom image, configured icon, or a folder) plus its name. */
export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
const fallbackIcon = project.kind === 'chat' ? (
<Icon name="chat-4" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
@@ -103,25 +108,61 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
onDirectoryChange,
theme,
} = props;
const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
const handlePickerKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
setOpenPicker(null);
};
useKeybind('open_draft_project_picker', () => {
projectTriggerRef.current?.focus();
setOpenPicker('project');
});
useKeybind('open_draft_worktree_picker', () => {
if (!showBranchSelector) return false;
worktreeTriggerRef.current?.focus();
setOpenPicker('worktree');
});
const handleProjectChange = (projectId: string) => {
onProjectChange(projectId);
setOpenPicker(null);
};
const handleDirectoryChange = (directory: string) => {
onDirectoryChange(directory);
setOpenPicker(null);
};
return (
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
<Select
value={selectedProject.id}
onValueChange={onProjectChange}
open={openPicker === 'project'}
onOpenChange={(open) => setOpenPicker(open ? 'project' : null)}
onValueChange={handleProjectChange}
disableGlobalShortcuts
>
<SelectTrigger
ref={projectTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain fitContent onKeyDown={handlePickerKeyDown}>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />}
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
@@ -130,9 +171,14 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{showBranchSelector ? (
<Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
onValueChange={onDirectoryChange}
open={openPicker === 'worktree'}
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
@@ -140,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-max min-w-48">
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{projectRootBranchOption.label}
</SelectItem>
</SelectGroup>
@@ -163,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</button>
</div>
{worktreeBranchOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
<SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
{option.pending ? '⏳ ' : ''}{option.label}
</SelectItem>
))}
</SelectGroup>
{selectedDirectory && !selectedBranchIsKnown ? (
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
<SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
{selectedBranchLabel}
</SelectItem>
) : null}
@@ -195,7 +241,9 @@ export function MobileDraftTargetTriggers(
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('project')}
>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
@@ -258,13 +306,7 @@ export function MobileDraftTargetSheets(
className="h-9"
/>
<div className="flex flex-col">
{projects
.filter((project) => {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return getProjectDisplayLabel(project).toLowerCase().includes(needle)
|| project.path.toLowerCase().includes(needle);
})
{rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path])
.map((project) => (
<button
key={project.id}
@@ -275,7 +317,7 @@ export function MobileDraftTargetSheets(
onOpenPickerChange(null);
}}
>
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span>
<span className="min-w-0 flex-1"><ProjectLabel project={project} theme={theme} /></span>
{project.id === selectedProject.id ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null}
@@ -298,8 +340,7 @@ export function MobileDraftTargetSheets(
/>
<div className="flex flex-col">
{(() => {
const needle = query.trim().toLowerCase();
const matches = (label: string) => !needle || label.toLowerCase().includes(needle);
const matches = (label: string) => matchesRankQuery([label], query);
const selectedValue = selectedDirectory
?? branchItems[0]?.value
?? normalizePath(selectedProject.path)
@@ -343,8 +384,7 @@ export function MobileDraftTargetSheets(
{t('chat.chatInput.worktreeNew')}
</button>
</div>
{worktreeBranchOptions
.filter((option) => matches(option.label))
{rankByQuery(worktreeBranchOptions, query, (option) => [option.label])
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
@@ -5,7 +5,12 @@ import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { cn, isMacOS } from '@/lib/utils';
import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
type FocusModeButtonProps = {
footerIconButtonClass: string;
@@ -17,6 +22,12 @@ type FocusModeButtonProps = {
export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
const { t } = useI18n();
const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input);
const expandInputCombo = getEffectiveShortcutCombo(
'expand_input',
expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride },
);
const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null;
return (
<Tooltip>
@@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
<TooltipContent side="top" sideOffset={8}>
<div className="flex flex-col gap-0.5 text-center">
<span>{t('chat.chatInput.focusMode.label')}</span>
<span className="font-mono opacity-60">
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
</span>
{shortcut ? <span className="font-mono opacity-60">{shortcut}</span> : null}
</div>
</TooltipContent>
</Tooltip>
@@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
/>
<div className="flex items-center gap-2">
<div
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
data-mobile-composer-pill="true"
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
>
<ComposerAttachmentControls
@@ -93,7 +93,8 @@ export const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.mem
if (!sessionId || restoringId) return;
setRestoringId(messageId);
try {
const nextMessage = userMessages.find((message) => message.id > messageId);
const messageIndex = userMessages.findIndex((message) => message.id === messageId);
const nextMessage = messageIndex >= 0 ? userMessages[messageIndex + 1] : undefined;
if (nextMessage) {
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
} else {
@@ -103,7 +103,7 @@ const STYLE_CLASS: Record<AnyStyle, string> = {
mentionAgent: 'text-[var(--status-success)]',
mentionCommand: 'text-[var(--primary)]',
mentionSnippet: 'text-[var(--status-warning)]',
code: 'rounded-[3px] bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
code: 'rounded-[6px] bg-[var(--markdown-inline-code-bg)] text-[var(--markdown-inline-code)]',
codeFence: 'bg-[var(--surface-subtle)] text-[var(--markdown-inline-code)]',
// A `~path` is written for the reader's benefit, not to attach anything —
// it takes the same colour as a file mention, since it names the same kind
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test';
import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults';
const hit = (relativePath: string) => {
const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath;
return {
name,
path: `/root/${relativePath}`,
relativePath,
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
};
};
describe('tokenizeMentionQuery', () => {
test('normalizes leading ./ and slashes and splits on whitespace', () => {
expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']);
expect(tokenizeMentionQuery(' ')).toEqual([]);
});
});
describe('mentionServerQuery', () => {
test('uses the longest token for the server search', () => {
expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a');
expect(mentionServerQuery('')).toBe('');
});
});
describe('rankFileMentionResults', () => {
test('ranks files and directories together by match quality, not by category', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')];
const ranked = rankFileMentionResults(files, directories, 'solo');
const paths = ranked.map((entry) => entry.relativePath);
expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']);
expect(paths).not.toContain('machine-learning/tensorflow/');
});
test('multi-token queries match tokens in any order across the path', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const ranked = rankFileMentionResults(files, [], 'team solo');
expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']);
});
test('tags each result with its kind', () => {
const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a');
expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory');
expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file');
});
});
@@ -0,0 +1,60 @@
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' };
export const tokenizeMentionQuery = (query: string): string[] =>
(query ?? '')
.trim()
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
/**
* The opencode file search takes a single term, so multi-word queries send the
* most selective (longest) token and the remaining tokens filter client-side.
*/
export const mentionServerQuery = (query: string): string => {
const tokens = tokenizeMentionQuery(query);
if (tokens.length === 0) {
return '';
}
return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
};
/**
* Merge directory and file hits into one list ranked by match quality against
* the full relative path. Multi-token queries require every token to appear
* somewhere in the path, in any order.
*/
export function rankFileMentionResults(
files: ProjectFileSearchHit[],
directories: ProjectFileSearchHit[],
query: string,
limit = 20,
): FileMentionHit[] {
const merged: FileMentionHit[] = [
...directories.map((hit) => ({ ...hit, kind: 'directory' as const })),
...files.map((hit) => ({ ...hit, kind: 'file' as const })),
];
const tokens = tokenizeMentionQuery(query);
if (tokens.length === 0) {
return merged.slice(0, limit);
}
const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name;
const candidates = tokens.length === 1
? merged
: merged.filter((hit) => {
const haystack = pathOf(hit).toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map(
(scored) => scored.item,
);
}
@@ -24,6 +24,29 @@ export const normalizeReferencePath = (value: string): string => normalizeFilePa
export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value);
export const localPathFromFileUrl = (value: string): string | null => {
let parsed: URL;
try {
parsed = new URL(value.trim());
} catch {
return null;
}
if (parsed.protocol !== 'file:' || (parsed.hostname && parsed.hostname !== 'localhost')) {
return null;
}
try {
const decodedPath = decodeURIComponent(parsed.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1);
}
return decodedPath.startsWith('/') ? decodedPath : null;
} catch {
return null;
}
};
const trimPathCandidate = (value: string): string => {
let next = (value || '').trim();
if (!next) {
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { fileReferenceExists } from './fileReferenceStat';
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Headers }> = [];
const stubFetchWith = (respond: () => Response) => {
calls.length = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString();
const headers = new Headers(init?.headers);
calls.push({ url, headers });
return respond();
// SAFETY: the stub preserves the fetch signature; every caller in this
// file restores globalThis.fetch in afterEach.
}) as typeof fetch;
};
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('fileReferenceExists directory scoping (issue 3019)', () => {
test('sends the session directory on the stat probe', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-b/src/index.ts', isFile: true, size: 12 }), { status: 200 }));
const exists = await fileReferenceExists('/repo-b/src/index.ts', '/repo-b');
expect(exists).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/fs/stat?path=%2Frepo-b%2Fsrc%2Findex.ts&optional=true');
expect(calls[0].headers.get('x-opencode-directory')).toBe('/repo-b');
});
test('treats a workspace rejection under one directory as unknown under another directory', async () => {
// Directory A resolves the workspace on the server (the browsed
// lastDirectory), so the probe for a path under B is rejected with 400
// and resolves false. The same path probed under B itself must issue a
// fresh request rather than reuse A's cached rejection.
stubFetchWith(() => {
const directoryHint = calls[calls.length - 1]?.headers.get('x-opencode-directory') ?? null;
if (directoryHint !== '/repo-b') {
return new Response(JSON.stringify({ error: 'Path is outside of active workspace' }), { status: 400 });
}
return new Response(JSON.stringify({ path: '/repo-b/lib/main.ts', isFile: true, size: 12 }), { status: 200 });
});
const rejectedUnderA = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-a');
const acceptedUnderB = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-b');
expect(rejectedUnderA).toBe(false);
expect(acceptedUnderB).toBe(true);
expect(calls).toHaveLength(2);
});
test('serves a repeated probe under the same directory from the cache', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-c/lib.ts', isFile: true, size: 4 }), { status: 200 }));
await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
const warm = await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
expect(warm).toBe(true);
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,80 @@
import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { normalizeReferencePath } from './fileReferenceParser';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
// with a differently scoped entry.
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
const normalizedPath = normalizeReferencePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
// The stat route resolves the workspace from this header. Without it
// the server falls back to the browsed lastDirectory, which rejects
// session-local files with 400 whenever the two directories differ.
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
return request;
};
@@ -0,0 +1,227 @@
import { describe, expect, test } from 'bun:test';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveChatListAnchoredEndSpace,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
} from './timelineScrollAnchoring';
const buildState = ({
positions,
sizes,
scroll = 0,
scrollLength = 700,
}: {
readonly positions: readonly number[];
readonly sizes: readonly number[];
readonly scroll?: number;
readonly scrollLength?: number;
}): TimelineListMeasurementState => ({
data: positions.map((_, index) => index),
scroll,
scrollLength,
positionAtIndex: (index) => positions[index],
sizeAtIndex: (index) => sizes[index],
});
describe('getRowBottom', () => {
test('measures row bottoms from list row position and size', () => {
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
expect(getRowBottom(state, 1)).toBe(160);
});
test('returns null for unmeasured rows', () => {
const state = buildState({ positions: [0], sizes: [80] });
expect(getRowBottom(state, 5)).toBeNull();
});
test('treats a zero-height row as one pixel tall', () => {
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
expect(getRowBottom(state, 1)).toBe(121);
});
});
describe('getAnchoredTurnMetrics', () => {
test('returns null for an empty timeline', () => {
const state = buildState({ positions: [], sizes: [] });
expect(getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 180,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
})).toBeNull();
});
test('treats the active turn as fitting when it fits above the composer', () => {
const state = buildState({
positions: [0, 300, 460],
sizes: [240, 80, 140],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(300);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(false);
expect(metrics?.targetScrollToRevealEnd).toBe(36);
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
});
test('targets the real row end instead of any temporary reserved tail', () => {
const state = buildState({
positions: [0, 1720, 1880],
sizes: [1600, 80, 120],
scroll: 1900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(2000);
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
});
test('reports overflow only for the current anchored turn', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 300],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(580);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(true);
});
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 360],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(1540);
expect(metrics?.visibleUsableBottom).toBe(1464);
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
});
test('subtracts composer height from usable viewport height', () => {
const state = buildState({
positions: [0, 300],
sizes: [120, 470],
scrollLength: 700,
});
const withoutComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 0,
anchorOffset: 16,
});
const withComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 220,
anchorOffset: 16,
});
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
expect(withComposer?.overflowsUsableViewport).toBe(true);
});
test('clamps an out-of-range anchor index to the last row', () => {
const state = buildState({
positions: [0, 300],
sizes: [240, 80],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 99,
composerOverlayHeight: 0,
anchorOffset: 16,
});
expect(metrics?.anchorTop).toBe(300);
expect(metrics?.turnHeight).toBe(80);
});
});
describe('resolveTimelineIsAtEnd', () => {
test('uses a tight distance band against the full content length', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
});
test('falls back to the list flags when distances are unavailable', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
test('reports nothing without a state', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
});
});
describe('resolveChatListAnchoredEndSpace', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
test('returns nothing when no anchor is set', () => {
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
});
test('returns nothing when the anchor is not in the list', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
});
test('resolves the last occurrence so a resent message anchors to its live row', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
test('honours an explicit anchor offset', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
anchorIndex: 1,
anchorOffset: 40,
});
});
});
@@ -0,0 +1,167 @@
// Anchored-turn scroll geometry for the chat timeline.
//
// The timeline has three mutually exclusive scroll modes:
//
// • `following-end` — stay pinned to the live edge as content grows.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into reserved space below it. The
// viewport does NOT move until the turn outgrows the usable viewport.
// • `free-scrolling` — the user took over; nothing moves the scroll
// position until they opt back in.
//
// This module is pure geometry: it reads measurements from the virtualized
// list and answers "how far, if at all, must we scroll to reveal the end of
// the anchored turn". Keeping it free of DOM and React makes the mode machine
// testable without a renderer.
//
// "Usable viewport" is the visible height minus the composer overlay (the
// composer floats over the list) minus the anchor offset, so a turn is only
// considered overflowing when it genuinely cannot be read.
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
// Distance from the top of the viewport at which an anchored user message
// parks. Small enough to read as "at the top", large enough not to collide
// with the timeline's top fade.
export const CHAT_LIST_ANCHOR_OFFSET = 16;
export interface TimelineListMeasurementState {
readonly data: readonly unknown[];
readonly scroll: number;
readonly scrollLength: number;
readonly positionAtIndex: (index: number) => number | undefined;
readonly sizeAtIndex: (index: number) => number | undefined;
}
export interface AnchoredTurnMetrics {
readonly anchorTop: number;
readonly lastBottom: number;
readonly turnHeight: number;
readonly usableViewportHeight: number;
readonly visibleUsableBottom: number;
readonly overflowsUsableViewport: boolean;
readonly targetScrollToRevealEnd: number;
readonly scrollDeltaToRevealEnd: number;
}
export const getRowBottom = (
state: TimelineListMeasurementState,
index: number,
): number | null => {
const top = state.positionAtIndex(index);
const height = state.sizeAtIndex(index);
if (
typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(top)
|| !Number.isFinite(height)
) {
return null;
}
// Rows measured at zero height would make an anchored turn look empty and
// suppress the reveal scroll; treat them as one pixel tall instead.
return top + Math.max(1, height);
};
export const getAnchoredTurnMetrics = ({
state,
anchorIndex,
composerOverlayHeight,
anchorOffset,
}: {
readonly state: TimelineListMeasurementState;
readonly anchorIndex: number;
readonly composerOverlayHeight: number;
readonly anchorOffset: number;
}): AnchoredTurnMetrics | null => {
if (state.data.length === 0) return null;
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
// The LAST row bottom, not the content length: the reserved anchored end
// space lives past it, and targeting that reserved tail would scroll the
// real content off the top.
const lastBottom = getRowBottom(state, state.data.length - 1);
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
return null;
}
const usableViewportHeight = Math.max(
0,
state.scrollLength - composerOverlayHeight - anchorOffset,
);
const turnHeight = Math.max(0, lastBottom - anchorTop);
const visibleUsableBottom = state.scroll + usableViewportHeight;
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
// Never negative: revealing the end must not scroll the timeline backwards.
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
return {
anchorTop,
lastBottom,
turnHeight,
usableViewportHeight,
visibleUsableBottom,
overflowsUsableViewport: turnHeight > usableViewportHeight,
targetScrollToRevealEnd,
scrollDeltaToRevealEnd,
};
};
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
state: {
readonly contentLength?: number;
readonly scroll?: number;
readonly scrollLength?: number;
readonly isNearEnd?: boolean;
readonly isAtEnd?: boolean;
} | undefined,
): boolean | undefined => {
if (!state) return undefined;
const { contentLength, scroll, scrollLength } = state;
if (
typeof contentLength === 'number'
&& typeof scroll === 'number'
&& typeof scrollLength === 'number'
&& Number.isFinite(contentLength)
) {
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
readonly anchorOffset: number;
}
// Finds the anchored row from the BACK of the list: a retried or re-sent
// message id can appear more than once, and the live one is always the last.
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
items: readonly Item[],
anchorId: AnchorId | null,
getAnchorId: (item: Item) => AnchorId | null,
options: { readonly anchorOffset?: number } = {},
): ChatListAnchoredEndSpace | undefined => {
if (anchorId === null) return undefined;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item !== undefined && getAnchorId(item) === anchorId) {
return {
anchorIndex: index,
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
};
}
}
return undefined;
};
@@ -0,0 +1,84 @@
import { describe, expect, test } from 'bun:test';
import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent';
const key = (
k: string,
modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {},
) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers });
describe('isFollowReleaseKey', () => {
test('upward navigation keys release follow', () => {
for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true);
expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true);
});
test('downward keys, plain space, and modified shortcuts do not', () => {
for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) {
expect(isFollowReleaseKey(key(k))).toBe(false);
}
expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false);
expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false);
expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false);
});
});
// The helpers only use Element#closest, scrollTop, and identity, so a minimal
// DOM stand-in built on EventTarget is enough — no renderer or jsdom.
class FakeElement extends EventTarget {
scrollTop = 0;
constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) {
super();
}
closest(selector: string): FakeElement | null {
if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`);
if (this.scrollable) return this;
return this.parent?.closest(selector) ?? null;
}
}
// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`;
// registering the fakes under those globals keeps the narrowing honest in bun.
const installDomGlobals = () => {
const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement };
Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement });
return () => Object.assign(globalThis, previous);
};
// With the globals above installed, FakeElement IS the HTMLElement the helpers
// narrow to; reading it back through the global bridges the static type without
// asserting anything the runtime does not hold.
const asRoot = (element: FakeElement): HTMLElement => {
if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed');
return element;
};
describe('nested scroller handling', () => {
test('an upward wheel over a nested scroller with room above stays there', () => {
const restore = installDomGlobals();
try {
const root = new FakeElement(false);
const box = new FakeElement(true, root);
const inner = new FakeElement(false, box);
box.scrollTop = 40;
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true);
box.scrollTop = 0;
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false);
expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false);
} finally {
restore();
}
});
test('a middle-button press pans the timeline unless it lands in a nested scroller', () => {
const restore = installDomGlobals();
try {
const root = new FakeElement(false);
const row = new FakeElement(false, root);
const box = new FakeElement(true, root);
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true);
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false);
expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false);
} finally {
restore();
}
});
});
@@ -0,0 +1,41 @@
// Gesture classification for the chat timeline's follow opt-out.
//
// The timeline releases live follow on REAL upward gestures only. Wheel and
// touch carry their direction; this module answers the same question for the
// inputs that do not: which keys mean "scroll up", when a middle-button press
// starts a pan, and when an upward wheel belongs to a nested scroller (a tool
// output box) that can still consume it. Pure functions, no DOM ownership,
// so the rules are testable without a renderer.
// A nested scroller inside the timeline marks itself with this attribute
// (see ToolPart). Wheel-up over it scrolls the box, not the conversation, for
// as long as the box has room above.
const NESTED_SCROLLABLE_SELECTOR = '[data-scrollable]';
export const isFollowReleaseKey = (
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
): boolean => {
// Modified keys are shortcuts, not navigation.
if (event.altKey || event.ctrlKey || event.metaKey) return false;
if (event.key === ' ') return event.shiftKey;
return event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home';
};
const nestedScrollable = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
const nested = target.closest(NESTED_SCROLLABLE_SELECTOR);
return nested instanceof HTMLElement && nested !== root ? nested : null;
};
// An upward wheel over a nested scroller that still has content above stays
// with that scroller; the timeline must not treat it as leaving the end.
export const nestedScrollableConsumesWheelUp = (root: HTMLElement, target: EventTarget | null): boolean => {
const nested = nestedScrollable(root, target);
return nested !== null && nested.scrollTop > 0;
};
// Middle-button press starts the platform's autoscroll pan (Windows/Linux
// Chromium); the pan then scrolls without wheel events, so the press itself is
// the gesture. Inside a nested scroller the pan belongs to that scroller.
export const isMiddleButtonPan = (root: HTMLElement, event: Pick<MouseEvent, 'button' | 'target'>): boolean =>
event.button === 1 && nestedScrollable(root, event.target) === null;
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test';
import { commitStreamedText } from './streamTextCommit';
describe('commitStreamedText', () => {
test('holds an incomplete short paragraph entirely', () => {
expect(commitStreamedText('An unfinished thought abo')).toBe('');
});
test('commits up to the last complete line', () => {
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
});
test('reveals code fences line by line', () => {
const text = '```py\nprint("a")\nprint("b';
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
});
test('releases a long held paragraph at the last sentence boundary', () => {
const sentence = 'A finished sentence lives here. ';
const text = sentence.repeat(12) + 'and an unfinished trail';
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
});
test('falls back to the last word boundary without sentences', () => {
const words = 'word '.repeat(70);
const text = words + 'unfinishe';
expect(commitStreamedText(text)).toBe(words);
});
test('keeps unbreakable runs intact rather than splitting them', () => {
const run = 'x'.repeat(400);
expect(commitStreamedText(run)).toBe(run);
});
test('empty input stays empty', () => {
expect(commitStreamedText('')).toBe('');
});
});
@@ -0,0 +1,47 @@
// Block-level streaming reveal.
//
// Token-by-token streaming mutates the trailing paragraph in place on every
// tick: words rewrap, the last line jitters, and the reader's eye fights the
// motion. Committing only up to the last COMPLETE line keeps every rendered
// block immutable once it appears — prose arrives a paragraph at a time (a
// markdown paragraph is one logical line), code fences reveal line by line,
// tables row by row — and the only remaining motion is the follow scroll.
//
// A paragraph with no newline for a long stretch must not stall the stream,
// so once the held tail outgrows a threshold it is committed at the last
// sentence boundary (falling back to the last word boundary).
const HOLD_MAX_CHARS = 320;
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
export const commitStreamedText = (text: string): string => {
if (text.length === 0) return text;
const lastNewline = text.lastIndexOf('\n');
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
const held = text.slice(committed.length);
if (held.length <= HOLD_MAX_CHARS) {
return committed;
}
// The held paragraph got long: release it up to the last finished
// sentence so the block still never mutates mid-sentence.
let lastSentenceEnd = -1;
for (const match of held.matchAll(SENTENCE_END)) {
lastSentenceEnd = match.index + match[0].length;
}
if (lastSentenceEnd > 0) {
return committed + held.slice(0, lastSentenceEnd);
}
// No sentence boundary either (a URL, a very long token run): release up
// to the last word boundary, keeping only the incomplete word held.
const lastSpace = held.lastIndexOf(' ');
if (lastSpace > 0) {
return committed + held.slice(0, lastSpace + 1);
}
return text;
};
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other',
liveParts: [textPart('part_live', 'live')],
livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: false,
showTurnChangedFiles: false,
});
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts: [synthetic, visible],
livePartsByMessageId: { assistant_1: [synthetic, visible] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
});
test('keeps a finished step message on its live parts after the stream moves on', () => {
const finished = message('assistant_1', 'assistant', 'user_1', []);
const streaming = message('assistant_2', 'assistant', 'user_1', []);
const entry = turnEntry(finished);
if (entry.kind !== 'turn') return;
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
entry.turn.assistantMessages = [finished, streaming];
const finishedLive = [textPart('part_tool_done', 'tool output')];
const streamingLive = [textPart('part_streaming', 'streaming')];
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
});
test('never erases record parts with an empty live array', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: [] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
});
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined;
liveParts: Part[];
// Live parts for EVERY message of the streaming tail, not only the one
// currently streaming: when the stream moves to the next step message, the
// previous message's base record can still lag behind the part store, and
// rendering it from that stale snapshot briefly drops its completed tool
// parts — remounting them (and replaying their reveal animation) once the
// record catches up.
livePartsByMessageId: Readonly<Record<string, Part[]>>;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
mergeHiddenUserTurns?: { planModeEnabled: boolean };
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
const withLiveParts = (
message: ChatMessageEntry,
activeStreamingMessageId: string,
liveParts: Part[],
livePartsByMessageId: Readonly<Record<string, Part[]>>,
): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
const liveParts = livePartsByMessageId[message.info.id];
// An empty live array is ambiguous — the store may simply not have loaded
// this message's parts — and must never erase parts the record does have.
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
return message;
}
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry,
options: BuildLiveStreamingEntryOptions,
): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId;
if (!activeStreamingMessageId) {
return entry;
}
const livePartsByMessageId = options.livePartsByMessageId;
if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
const message = withLiveParts(entry.message, livePartsByMessageId);
if (message === entry.message) {
return entry;
}
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
const next = withLiveParts(message, livePartsByMessageId);
if (next !== message) {
changed = true;
}
@@ -43,27 +43,42 @@ export type DecorateContext = {
onPreviewLoopback?: (url: string) => void;
};
// Reference the app's icon sprite (injected into <body> by the shared Icon
// component) so DOM-built controls use the same themed icons as the rest of
// the app. Sprite symbols are registered under `#oc-<name>`.
const spriteIcon = (name: IconName): string =>
`<svg class="remixicon size-3.5" viewBox="0 0 24 24" aria-hidden="true"><use href="#oc-${name}"></use></svg>`;
const ICONS = {
copy: spriteIcon('file-copy'),
check: spriteIcon('check'),
download: spriteIcon('download'),
zoomIn: spriteIcon('add'),
zoomOut: spriteIcon('subtract'),
fit: spriteIcon('refresh'),
textWrap: spriteIcon('text-wrap'),
} as const;
copy: 'file-copy',
check: 'check',
download: 'download',
zoomIn: 'add',
zoomOut: 'subtract',
fit: 'refresh',
textWrap: 'text-wrap',
image: 'file-image',
} as const satisfies Record<string, IconName>;
const ICON_BTN_CLASS =
'p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--interactive-focus-ring)]';
const setIconHtml = (el: Element, html: string): void => {
el.innerHTML = html;
const setIcon = (el: Element, icon: keyof typeof ICONS): void => {
const iconName = ICONS[icon];
const svg = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'remixicon size-3.5');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
const use = el.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', `#oc-${iconName}`);
svg.appendChild(use);
el.replaceChildren(svg);
};
const decorateImageLabels = (root: HTMLElement): void => {
for (const label of Array.from(root.querySelectorAll<HTMLElement>('[data-openchamber-markdown-image-label="true"]'))) {
if (label.querySelector('[data-openchamber-markdown-image-label-icon]')) continue;
const icon = document.createElement('span');
icon.className = 'inline-flex shrink-0';
icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
setIcon(icon, 'image');
label.prepend(icon);
}
};
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
@@ -73,7 +88,7 @@ const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string):
button.setAttribute('data-md-action', slot);
button.setAttribute('title', title);
button.setAttribute('aria-label', title);
setIconHtml(button, ICONS[icon]);
setIcon(button, icon);
return button;
};
@@ -118,6 +133,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
const code = pre.querySelector<HTMLElement>(':scope > code');
if (!code || code.hasAttribute('data-md-code-lines')) return;
// The real gutter takes over the reserved footprint.
pre.removeAttribute('data-md-gutter-reserved');
const text = code.textContent ?? '';
const hasTrailingNewline = text.endsWith('\n');
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
@@ -138,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
row.setAttribute('data-md-code-line', '');
const number = document.createElement('span');
number.setAttribute('data-md-code-line-number', '');
number.setAttribute('data-md-code-line-number', String(index + 1));
number.setAttribute('aria-hidden', 'true');
number.textContent = String(index + 1);
const content = document.createElement('span');
content.setAttribute('data-md-code-line-content', '');
@@ -150,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
} else {
content.textContent = sourceLine;
}
row.append(number, content);
fragment.appendChild(row);
if (index < sourceLines.length - 1 || hasTrailingNewline) {
@@ -183,11 +199,11 @@ export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: bool
};
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => {
setIconHtml(button, ICONS.check);
setIcon(button, 'check');
button.setAttribute('title', copiedTitle);
button.setAttribute('aria-label', copiedTitle);
window.setTimeout(() => {
setIconHtml(button, ICONS[restore]);
setIcon(button, restore);
button.setAttribute('title', restoreTitle);
button.setAttribute('aria-label', restoreTitle);
}, 2000);
@@ -250,7 +266,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
pre.style.margin = '0';
pre.style.background = 'transparent';
pre.classList.add('min-w-0', 'w-full', 'flex-1');
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
if (!ctx.deferCodeLineNumberSync) {
layoutCodeLines(pre);
} else {
// Streaming defers the per-line gutter markup, but the gutter's
// horizontal footprint is reserved immediately — otherwise the
// end-of-stream decorate pass shifts every code line right by the
// gutter column and the finished message visibly jumps.
pre.setAttribute('data-md-gutter-reserved', '');
}
body.appendChild(pre);
wrapper.appendChild(header);
wrapper.appendChild(body);
@@ -479,7 +503,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
preview.setAttribute('data-md-url', href);
preview.setAttribute('title', ctx.labels.previewTitle);
preview.setAttribute('aria-label', ctx.labels.previewLabel);
setIconHtml(preview, ICONS.download);
setIcon(preview, 'download');
anchor.parentNode?.insertBefore(preview, anchor.nextSibling);
}
}
@@ -487,6 +511,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
decorateImageLabels(root);
decorateInlineCode(root);
decorateMermaid(root, ctx);
decorateCodeBlocks(root, ctx);
@@ -516,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
}
};
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
const element = node.nodeType === 1 ? node as Element : node.parentElement;
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
};
const getMarkdownCodeSelectionText = (range: Range): string | null => {
const code = getContainingMarkdownCode(range.startContainer);
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
// Line numbers are CSS-generated, so the DOM range is already the exact
// source selection, including boundaries between rows and empty lines.
return range.toString();
};
type MarkdownCopyState = {
registrations: number;
handler: (event: ClipboardEvent) => void;
menuHandler: (event: Event) => void;
};
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
let state = markdownCopyStates.get(doc);
if (!state) {
const getSelectedText = (): string | null => {
const selection = doc.getSelection();
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
};
const handler = (event: ClipboardEvent) => {
if (!event.clipboardData) return;
const text = getSelectedText();
if (text === null) return;
event.preventDefault();
event.stopPropagation();
event.clipboardData.setData('text/plain', text);
};
const menuHandler = (event: Event) => {
const text = getSelectedText();
if (text === null) return;
event.preventDefault();
void copyTextToClipboard(text);
};
state = { registrations: 0, handler, menuHandler };
markdownCopyStates.set(doc, state);
doc.addEventListener('copy', handler, true);
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
}
state.registrations += 1;
return () => {
const current = markdownCopyStates.get(doc);
if (!current) return;
current.registrations -= 1;
if (current.registrations > 0) return;
doc.removeEventListener('copy', current.handler, true);
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
markdownCopyStates.delete(doc);
};
};
/**
* Attach a single delegated click listener for all in-markdown actions: code
* copy, table copy/download menus, mermaid copy/download, loopback preview.
@@ -525,6 +611,7 @@ export const attachMarkdownInteractions = (
container: HTMLElement,
ctx: DecorateContext,
): (() => void) => {
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
const handleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
@@ -540,7 +627,12 @@ export const attachMarkdownInteractions = (
if (action === 'copy-code') {
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code');
const text = code ? getMarkdownCodeText(code) : '';
if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
if (text) {
actionEl.setAttribute('data-md-copy-pending', '');
void copyTextToClipboard(text)
.then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy))
.finally(() => actionEl.removeAttribute('data-md-copy-pending'));
}
return;
}
@@ -626,5 +718,8 @@ export const attachMarkdownInteractions = (
};
container.addEventListener('click', handleClick);
return () => container.removeEventListener('click', handleClick);
return () => {
unregisterCodeCopy();
container.removeEventListener('click', handleClick);
};
};
@@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMarkdownDomCache';
Object.assign(globalThis, { document: new Window().document });
const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
const createEntry = (
document: Document,
sessionId: string,
messageId: string,
partId: string,
): DetachedMarkdownDom => {
const fragment = document.createDocumentFragment();
const node = document.createElement('p');
node.textContent = `${messageId}:${partId}`;
fragment.appendChild(node);
return {
scope: `runtime:${sessionId}`,
id: `${messageId}:${partId}`,
locale: 'en',
directory: '/repo-a',
fragment,
};
};
describe('DetachedMarkdownDomCache', () => {
test('consumes the original DOM fragment once and rejects another locale', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const entry = createEntry(document, 'session-a', 'message-a', 'part-a');
const originalNode = entry.fragment.firstChild;
cache.store(entry);
expect(cache.take({ ...keyFor(entry), locale: 'zh' })).toBeNull();
cache.store(entry);
const restored = cache.take(keyFor(entry));
expect(restored?.firstChild).toBe(originalNode);
expect(cache.take(keyFor(entry))).toBeNull();
});
test('bounds entries per session and evicts the least recently used session', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
cache.store(createEntry(document, 'session-a', 'message-1', 'part'));
cache.store(createEntry(document, 'session-a', 'message-2', 'part'));
cache.store(createEntry(document, 'session-a', 'message-3', 'part'));
cache.store(createEntry(document, 'session-b', 'message-4', 'part'));
cache.store(createEntry(document, 'session-c', 'message-5', 'part'));
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
expect(cache.take({
scope: 'runtime:session-a',
id: 'message-2:part',
locale: 'en',
directory: '/repo-a',
})).toBeNull();
expect(cache.take({
scope: 'runtime:session-c',
id: 'message-5:part',
locale: 'en',
directory: '/repo-a',
})).not.toBeNull();
});
test('isolates identities by runtime and replaces an identity without growing stats', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const first = createEntry(document, 'session', 'message', 'part');
const replacement = createEntry(document, 'session', 'message', 'part');
const replacementNode = replacement.fragment.firstChild;
const otherRuntime = createEntry(document, 'other-runtime-session', 'message', 'part');
cache.store(first);
cache.store(replacement);
cache.store(otherRuntime);
expect(cache.stats()).toEqual({ sessions: 2, entries: 2 });
expect(cache.take(keyFor(otherRuntime))).not.toBeNull();
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
});
test('does not restore file-link DOM under another directory', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const entry = createEntry(document, 'session', 'message', 'part');
cache.store(entry);
expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
});
test('refreshes session LRU and clears all entries', () => {
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
const sessionB = createEntry(document, 'session-b', 'message-b', 'part');
const sessionC = createEntry(document, 'session-c', 'message-c', 'part');
cache.store(sessionA);
cache.store(sessionB);
cache.store(sessionA);
cache.store(sessionC);
expect(cache.take(keyFor(sessionB))).toBeNull();
cache.clear();
expect(cache.stats()).toEqual({ sessions: 0, entries: 0 });
});
});
@@ -0,0 +1,124 @@
export type DetachedMarkdownDomKey = {
scope: string;
id: string;
locale: string;
directory: string;
};
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
// The fragment owns the original nodes. take() consumes it once by moving
// those nodes back into a renderer; nothing is cloned or serialized.
fragment: DocumentFragment;
};
export type DetachedMarkdownDomCacheStats = {
sessions: number;
entries: number;
};
// Holds detached, fully decorated Markdown DOM. The cache is intentionally
// small: it accelerates recent-session and reverse-scroll remounts without
// retaining whole session trees or depending on browser-specific byte guesses.
type DetachedMarkdownDomCacheLimits = {
maxSessions: number;
maxEntriesPerSession: number;
};
type SessionCache = Map<string, DetachedMarkdownDom>;
const DEFAULT_LIMITS: DetachedMarkdownDomCacheLimits = {
// Eight buckets cover a broader recent-session working set without
// coupling eviction to React commit or microtask timing.
maxSessions: 8,
maxEntriesPerSession: 4,
};
export class DetachedMarkdownDomCache {
private readonly maxSessions: number;
private readonly maxEntriesPerSession: number;
private readonly sessions = new Map<string, SessionCache>();
constructor(limits: DetachedMarkdownDomCacheLimits = DEFAULT_LIMITS) {
this.maxSessions = Math.max(1, limits.maxSessions);
this.maxEntriesPerSession = Math.max(1, limits.maxEntriesPerSession);
}
store(entry: DetachedMarkdownDom): void {
const sessionKey = entry.scope;
const entryKey = entry.id;
let session = this.sessions.get(sessionKey);
if (session === undefined) {
session = new Map();
this.sessions.set(sessionKey, session);
} else {
this.refreshSession(sessionKey, session);
}
// A part has one DOM version inside its authoritative runtime/session.
session.delete(entryKey);
session.set(entryKey, entry);
while (session.size > this.maxEntriesPerSession) {
this.removeOldestEntry(session);
}
while (this.sessions.size > this.maxSessions) {
this.removeOldestSession();
}
}
take(key: DetachedMarkdownDomKey): DocumentFragment | null {
const sessionKey = key.scope;
const session = this.sessions.get(sessionKey);
if (!session) return null;
const entryKey = key.id;
this.refreshSession(sessionKey, session);
const entry = session.get(entryKey);
if (entry === undefined) return null;
// A mismatched probe (different locale or directory for the same part)
// must not destroy the entry — the matching renderer may still come for
// it. Only a real hit transfers ownership out of the cache.
if (entry.locale !== key.locale || entry.directory !== key.directory) return null;
// A fragment is a move-only resource; taking it removes cache ownership.
session.delete(entryKey);
if (session.size === 0) this.sessions.delete(sessionKey);
return entry.fragment;
}
clear(): void {
this.sessions.clear();
}
stats(): DetachedMarkdownDomCacheStats {
let entries = 0;
for (const session of this.sessions.values()) {
entries += session.size;
}
return {
sessions: this.sessions.size,
entries,
};
}
private refreshSession(sessionKey: string, session: SessionCache): void {
this.sessions.delete(sessionKey);
this.sessions.set(sessionKey, session);
}
private removeOldestEntry(session: SessionCache): void {
const oldestKey = session.keys().next().value;
if (oldestKey === undefined) return;
session.delete(oldestKey);
}
private removeOldestSession(): void {
const oldestKey = this.sessions.keys().next().value;
if (oldestKey === undefined) return;
this.sessions.delete(oldestKey);
}
}
export const detachedMarkdownDomCache = new DetachedMarkdownDomCache();
@@ -0,0 +1,125 @@
// Bounded LRU for rendered markdown / Shiki highlight results.
//
// Used by `markdownCore` (per-block HTML) and by the main-thread markdown
// worker client (highlight results) so unchanged content is never re-rendered
// or re-tokenized. Keys are short content fingerprints (not the full source) so
// cache maps do not duplicate large strings. Entry byte sizes are recorded once
// at insert time — get/evict never re-walk the payload.
export type HighlightResultCacheOptions = {
maxEntries: number;
maxBytes: number;
};
type CacheEntry<T> = {
value: T;
bytes: number;
};
/** UTF-16 storage estimate for a JS string (chars × 2). Avoids TextEncoder allocs. */
export const utf16Bytes = (value: string): number => value.length * 2;
/** Final avalanche so near-identical sources do not land in adjacent buckets. */
const mix32 = (hash: number): number => {
let h = hash;
h ^= h >>> 16;
h = Math.imul(h, 0x85ebca6b);
h ^= h >>> 13;
return h >>> 0;
};
/**
* Short stable fingerprint for cache keys: length + two independent 32-bit
* multiplicative hashes (~64 bits of key space).
*
* These caches are content-addressed and global, so a collision does not merely
* mis-color a block the cache returns a *different* block's rendered HTML and
* the user is shown source they never wrote. One 32-bit hash is not enough for
* that failure mode: a few thousand same-length entries reach a birthday
* collision probability worth caring about, and the result would be
* undiagnosable in the field. Two multiplies per character are free next to
* Shiki tokenization.
*/
export const contentFingerprint = (value: string): string => {
let h1 = 0x811c9dc5;
let h2 = 0xc2b2ae35;
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
h1 = Math.imul(h1 ^ code, 0x01000193);
h2 = Math.imul(h2 ^ code, 0x27220a95);
}
return `${value.length.toString(36)}_${mix32(h1).toString(36)}_${mix32(h2).toString(36)}`;
};
/** Approximate byte cost of token-run lines without JSON.stringify. */
export const estimateTokenRunsBytes = (
lines: ReadonlyArray<ReadonlyArray<readonly [number, string, number]>>,
): number => {
let total = 0;
for (const line of lines) {
total += 4;
for (const run of line) {
total += 8 + utf16Bytes(run[1]);
}
}
return total;
};
export class HighlightResultCache<T> {
private readonly maxEntries: number;
private readonly maxBytes: number;
private readonly map = new Map<string, CacheEntry<T>>();
private totalBytes = 0;
constructor(options: HighlightResultCacheOptions) {
this.maxEntries = Math.max(1, options.maxEntries);
this.maxBytes = Math.max(1, options.maxBytes);
}
get size(): number {
return this.map.size;
}
get bytes(): number {
return this.totalBytes;
}
get(key: string): T | undefined {
const entry = this.map.get(key);
if (entry === undefined) return undefined;
// Refresh LRU order without recomputing size.
this.map.delete(key);
this.map.set(key, entry);
return entry.value;
}
set(key: string, value: T, bytes: number): void {
const existing = this.map.get(key);
if (existing !== undefined) {
this.totalBytes -= existing.bytes;
this.map.delete(key);
}
const entryBytes = Math.max(0, bytes);
while (
this.map.size > 0
&& (this.map.size >= this.maxEntries || this.totalBytes + entryBytes > this.maxBytes)
) {
const oldest = this.map.keys().next().value;
if (oldest === undefined) break;
const oldestEntry = this.map.get(oldest);
if (oldestEntry !== undefined) this.totalBytes -= oldestEntry.bytes;
this.map.delete(oldest);
// Always allow a single oversized entry so huge files still cache once.
if (this.map.size === 0) break;
}
this.map.set(key, { value, bytes: entryBytes });
this.totalBytes += entryBytes;
}
clear(): void {
this.map.clear();
this.totalBytes = 0;
}
}
@@ -1,4 +1,11 @@
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
import {
contentFingerprint,
estimateTokenRunsBytes,
HighlightResultCache,
utf16Bytes,
} from './highlightResultCache';
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
@@ -6,47 +13,117 @@ import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse }
// ready-to-splice Shiki HTML. On any failure (no worker support, worker crash,
// tokenization error) the promise resolves to `null` and the caller keeps the
// escaped plain-text code — highlighting never falls back onto the main thread.
//
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
// content must not re-enter the worker — that was the sustained ~40 msg/s
// re-highlight load in openchamber/openchamber#2769. In-flight requests with
// the same key coalesce so remount storms share one round-trip. Cache keys are
// fingerprints (not full source) so large files are not duplicated in the Map.
//
// This module is the only sender to the worker, so memoizing here is sufficient
// and the worker itself stays stateless apart from the Shiki instance. A second
// cache inside the worker would only duplicate these payloads in another heap.
//
// `highlight` / `highlightLines` results are theme-independent: the worker
// tokenizes with the CSS-variable `MARKDOWN_SHIKI_THEME`, so a theme switch
// repaints via CSS and must not invalidate these entries. Only
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
type CachedHighlight =
| { type: 'highlight'; html: string }
| { type: 'highlightLines'; lines: string[] }
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
const CLIENT_CACHE_MAX_ENTRIES = 2000;
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
const resultCache = new HighlightResultCache<CachedHighlight>({
maxEntries: CLIENT_CACHE_MAX_ENTRIES,
maxBytes: CLIENT_CACHE_MAX_BYTES,
});
const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined;
let workerCreation: Promise<Worker | undefined> | undefined;
let workerObjectUrl: string | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
// Theme names whose full definition we've already shipped to the live worker, so
// repeat tokenization sends only the name (not the whole theme object) again.
const sentThemes = new Set<string>();
const entryBytes = (key: string, value: CachedHighlight): number => {
const keyBytes = utf16Bytes(key);
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
if (value.type === 'highlightLines') {
let total = keyBytes;
for (const line of value.lines) total += utf16Bytes(line);
return total;
}
return keyBytes + estimateTokenRunsBytes(value.lines);
};
const failAll = (): void => {
pending.forEach((resolve) => resolve(null));
pending.clear();
sentThemes.clear();
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
inflight.clear();
worker?.terminate();
worker = undefined;
workerCreation = undefined;
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
};
const getWorker = (): Worker | undefined => {
if (worker) return worker;
const createWorker = async (): Promise<Worker | undefined> => {
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
try {
worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' });
let workerUrl = MarkdownShikiWorkerUrl;
if (isVSCodeRuntime(null)) {
const response = await fetch(workerUrl);
if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`);
workerObjectUrl = URL.createObjectURL(await response.blob());
workerUrl = workerObjectUrl;
}
const instance = new Worker(workerUrl, { type: 'module' });
worker = instance;
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
instance.onerror = failAll;
instance.onmessageerror = failAll;
instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return instance;
} catch (err) {
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
console.error('Failed to create Shiki worker:', err);
return undefined;
}
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
worker.onerror = failAll;
worker.onmessageerror = failAll;
worker.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return worker;
};
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = getWorker();
const getWorker = async (): Promise<Worker | undefined> => {
if (worker) return worker;
workerCreation ??= createWorker().finally(() => {
workerCreation = undefined;
});
return workerCreation;
};
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = await getWorker();
if (!instance) return Promise.resolve(null);
const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
@@ -55,13 +132,47 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<Markdo
});
};
const coalesce = (
key: string,
run: () => Promise<CachedHighlight | null>,
): Promise<CachedHighlight | null> => {
const existing = inflight.get(key);
if (existing) return existing;
const pendingRequest = run().finally(() => {
inflight.delete(key);
});
inflight.set(key, pendingRequest);
return pendingRequest;
};
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
const fp = contentFingerprint(code);
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
};
/** Test-only: clear client-side highlight memoization. */
export const resetMarkdownWorkerClientCacheForTests = (): void => {
resultCache.clear();
inflight.clear();
};
/**
* Highlight a complete code block in the worker. Resolves to Shiki `<pre>` HTML,
* or `null` if highlighting is unavailable or failed (caller keeps plain code).
*/
export const highlightCodeInWorker = async (code: string, lang: string): Promise<string | null> => {
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
return response?.type === 'highlight' ? response.html : null;
const key = cacheKeyFor('highlight', lang, code);
const cached = resultCache.get(key);
if (cached?.type === 'highlight') return cached.html;
const result = await coalesce(key, async () => {
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
if (response?.type !== 'highlight') return null;
const entry: CachedHighlight = { type: 'highlight', html: response.html };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlight' ? result.html : null;
};
/**
@@ -70,8 +181,24 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
* round-trip instead of one per line. Resolves to `null` on failure.
*/
export const highlightLinesInWorker = async (code: string, lang: string): Promise<string[] | null> => {
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
return response?.type === 'highlightLines' ? response.lines : null;
const key = cacheKeyFor('highlightLines', lang, code);
const cached = resultCache.get(key);
if (cached?.type === 'highlightLines') return cached.lines;
const result = await coalesce(key, async () => {
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
if (response?.type !== 'highlightLines') return null;
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlightLines' ? result.lines : null;
};
/** Return an already-tokenized line result without scheduling a worker request. */
export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => {
const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code));
return cached?.type === 'highlightLines' ? cached.lines : null;
};
/**
@@ -86,18 +213,25 @@ export const highlightTokensInWorker = async (
themeName: string,
theme: unknown,
): Promise<MarkdownTokenRun[][] | null> => {
const needsTheme = !sentThemes.has(themeName);
const response = await request((id) => ({
type: 'highlightTokens',
id,
code,
lang,
themeName,
...(needsTheme ? { theme } : {}),
}));
if (response?.type === 'highlightTokens') {
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
const cached = resultCache.get(key);
if (cached?.type === 'highlightTokens') return cached.lines;
const result = await coalesce(key, async () => {
const needsTheme = !sentThemes.has(themeName);
const response = await request((id) => ({
type: 'highlightTokens',
id,
code,
lang,
themeName,
...(needsTheme ? { theme } : {}),
}));
if (response?.type !== 'highlightTokens') return null;
sentThemes.add(themeName);
return response.lines;
}
return null;
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
return result?.type === 'highlightTokens' ? result.lines : null;
};
@@ -1,6 +1,72 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
type SanitizeAttribute = {
attrName: string;
attrValue: string;
forceKeepAttr?: boolean;
};
class TestAnchorElement {
target = '';
setAttribute(name: string, value: string): void {
if (name === 'target') this.target = value;
}
}
const sanitizeHooks: {
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
afterSanitizeAttributes?: (node: unknown) => void;
} = {};
// Mirrors DOMPurify's default URI policy: approved schemes plus relative URLs.
const DOMPURIFY_ALLOWED_URI_RE =
// Keep this byte-aligned with DOMPurify's default IS_ALLOWED_URI expression.
// eslint-disable-next-line no-useless-escape
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
const URI_ATTRIBUTE_WHITESPACE_RE =
// eslint-disable-next-line no-control-regex
/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g;
Object.assign(globalThis, {
window: {},
HTMLAnchorElement: TestAnchorElement,
});
mock.module('dompurify', () => ({
default: {
isSupported: true,
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
sanitizeHooks[name] = hook;
},
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
const anchor = new TestAnchorElement();
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
sanitizeHooks.afterSanitizeAttributes?.(anchor);
const normalizedHref = href.replace(URI_ATTRIBUTE_WHITESPACE_RE, '');
return data.forceKeepAttr || DOMPURIFY_ALLOWED_URI_RE.test(normalizedHref)
? attribute
: '';
}),
},
}));
mock.module('./markdown-worker', () => ({
highlightCodeInWorker: async () => null,
}));
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const {
__markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates,
getCachedMarkdownBlocks,
renderMarkdownBlocks,
renderMarkdownSync,
resetMarkdownHtmlCacheForTests,
} = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
describe('markdown sanitization', () => {
test('turns raw assistant HTML into inert visible text', () => {
@@ -15,4 +81,240 @@ describe('markdown sanitization', () => {
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
});
test('allows only local file URLs through the sanitizer policy', () => {
expect(isLocalFileUrl('file:///private/tmp/report%20viewer.html')).toBe(true);
expect(isLocalFileUrl('file://localhost/private/tmp/REPORT.md')).toBe(true);
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
});
test('keeps app and local file links while stripping blocked schemes', () => {
const html = renderMarkdownSync([
'[app](obsidian://open?vault=Notebook)',
'[file](file:///workspace/notes.md)',
'[script](javascript:alert(1))',
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
].join('\n\n'), 'inline');
expect(html).toContain('href="obsidian://open?vault=Notebook"');
expect(html).toContain('href="file:///workspace/notes.md"');
expect(html).not.toContain('href="javascript:alert(1)"');
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
});
});
describe('Markdown block cache reads', () => {
test('returns all settled blocks synchronously after a full cache hit', async () => {
resetMarkdownHtmlCacheForTests();
const text = '**cached** settled markdown';
expect(getCachedMarkdownBlocks(text)).toBeNull();
const rendered = await renderMarkdownBlocks(text, false);
expect(getCachedMarkdownBlocks(text)).toEqual(rendered);
});
test('returns null for a cold or partial settled miss', async () => {
resetMarkdownHtmlCacheForTests();
const first = 'first settled block';
const changed = 'first settled block\n\nsecond settled block';
await renderMarkdownBlocks(first, false);
expect(getCachedMarkdownBlocks(changed)).toBeNull();
});
test('keeps image mode identity out of the settled full hit', async () => {
resetMarkdownHtmlCacheForTests();
const text = '![image](https://example.test/image.png)';
await renderMarkdownBlocks(text, false, 'inline');
expect(getCachedMarkdownBlocks(text, 'label')).toBeNull();
expect(getCachedMarkdownBlocks(text, 'inline')).not.toBeNull();
});
test('does not treat streaming live-cache entries as settled full hits', async () => {
resetMarkdownHtmlCacheForTests();
const text = 'streaming markdown';
await renderMarkdownBlocks(text, true);
expect(getCachedMarkdownBlocks(text)).toBeNull();
});
});
describe('Markdown images', () => {
test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([
'[linked image](packages/vscode/extension.jpg)',
'![image syntax](packages/vscode/extension.jpg)',
].join('\n\n'), 'label');
expect(html).toContain('data-openchamber-markdown-image-label="true"');
expect(html).toContain('extension.jpg');
expect(html).not.toContain('image syntax');
expect(html).not.toContain('<img');
expect(html.match(/<a /g)).toHaveLength(1);
});
test('keeps non-chat Markdown images inline', () => {
const html = renderMarkdownSync([
'[remote link](https://example.test/image.png)',
'![remote image](https://example.test/image.png)',
].join('\n\n'));
expect(html).toContain('<a href="https://example.test/image.png"');
expect(html).toContain('<img src="https://example.test/image.png" alt="remote image">');
expect(html).not.toContain('data-openchamber-markdown-image-label');
});
test('collects image syntax across mixed Markdown and ignores links and code', () => {
const candidates = extractMarkdownImageCandidates([
[
'Before [local link](screens/first%20view.png) and `![code](ignored.png)`.',
'',
'- ![duplicate](screens/first%20view.png)',
'- ![remote](https://example.test/second.webp?size=2)',
'',
'```md',
'![fenced](ignored-too.jpg)',
'```',
].join('\n'),
'After ![third](data:image/png;base64,AAAA).',
]);
expect(candidates).toEqual([
{ source: 'screens/first%20view.png', filename: 'first view.png' },
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
]);
});
test('does not add an ordinary local image link to the gallery', () => {
expect(extractMarkdownImageCandidates(['[download](screens/image.png)'])).toEqual([]);
});
test('limits one finalized message gallery to twelve unique candidates', () => {
const markdown = Array.from({ length: 14 }, (_, index) => `![image ${index}](screens/${index}.png)`).join('\n');
const candidates = extractMarkdownImageCandidates([markdown]);
expect(candidates).toHaveLength(12);
expect(candidates.at(-1)?.source).toBe('screens/11.png');
});
test('reuses extracted candidates across virtualized remounts without changing gallery behavior', () => {
__markdownImageCandidateCacheForTests.reset();
const contents = Array.from({ length: 20 }, (_, index) => `![image ${index}](screens/${index}.png)`);
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
expect(__markdownImageCandidateCacheForTests.stats().scans).toBe(12);
for (let round = 0; round < 1000; round += 1) {
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
}
const stats = __markdownImageCandidateCacheForTests.stats();
expect(stats.entries).toBe(12);
expect(stats.scans).toBe(12);
});
test('scans one thousand independent messages once across virtualized remounts', () => {
__markdownImageCandidateCacheForTests.reset();
const messages = Array.from(
{ length: 1000 },
(_, index) => `![image ${index}](screens/${index}.png)`,
);
for (const message of messages) extractMarkdownImageCandidates([message]);
for (const message of messages) extractMarkdownImageCandidates([message]);
const stats = __markdownImageCandidateCacheForTests.stats();
expect(stats.entries).toBe(1000);
expect(stats.scans).toBe(1000);
});
test('gives embedded images without alt text a stable filename', () => {
const source = 'data:image/png;base64,AAAA';
expect(extractMarkdownImageCandidates([`![](${source})`])).toEqual([
{ source, filename: 'image.png' },
]);
expect(renderMarkdownSync(`![](${source})`, 'label')).toContain('image.png');
});
test('bounds cached candidate entries and bytes, and skips oversized individual content', () => {
__markdownImageCandidateCacheForTests.reset();
for (let index = 0; index < 1025; index += 1) {
extractMarkdownImageCandidates([`![image ${index}](screens/${index}.png)`]);
}
const boundedStats = __markdownImageCandidateCacheForTests.stats();
expect(boundedStats.entries).toBe(1024);
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
__markdownImageCandidateCacheForTests.reset();
const oversized = `![image](screens/large.png)\n${'x'.repeat(64 * 1024)}`;
extractMarkdownImageCandidates([oversized]);
extractMarkdownImageCandidates([oversized]);
expect(__markdownImageCandidateCacheForTests.stats()).toEqual({ entries: 0, bytes: 0, scans: 2 });
});
test('validates embedded image bytes against the declared MIME type', async () => {
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const signal = new AbortController().signal;
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, signal)).toBe(`data:image/png;base64,${png}`);
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, signal).then(
() => { throw new Error('Expected mismatched image data to fail'); },
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
);
});
test('does not resolve images after cancellation', async () => {
const controller = new AbortController();
controller.abort();
await resolveMarkdownImageSource('https://example.test/image.png', controller.signal).then(
() => { throw new Error('Expected an aborted image load to fail'); },
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
);
});
test('keeps the existing image renderer outside finalized assistant text', () => {
const html = renderMarkdownSync('![tool image](https://example.test/image.png)');
expect(html).toContain('<img src="https://example.test/image.png"');
expect(html).not.toContain('data-openchamber-markdown-image');
});
});
describe('CJK-aware link parsing', () => {
const hrefOf = (html: string): string | null => /<a\b[^>]*href="([^"]*)"/.exec(html)?.[1] ?? null;
test('bare URL followed by a CJK annotation trims the annotation from the href', () => {
const html = renderMarkdownSync('访问 https://example.com/docs(中文说明)了解更多');
expect(hrefOf(html)).toBe('https://example.com/docs');
});
test('bare URL followed by CJK punctuation trims the punctuation', () => {
expect(hrefOf(renderMarkdownSync('地址 https://example.com/guide,详见'))).toBe(
'https://example.com/guide',
);
expect(hrefOf(renderMarkdownSync('官网 https://example.com。'))).toBe('https://example.com');
});
test('correct links are unaffected', () => {
expect(hrefOf(renderMarkdownSync('官方文档见 [这里](https://docs.example.com)(中文说明)'))).toBe(
'https://docs.example.com',
);
expect(hrefOf(renderMarkdownSync('[下载](https://dl.example.com/安装包(正式版))'))).toBe(
'https://dl.example.com/安装包(正式版)',
);
expect(hrefOf(renderMarkdownSync('[a](url(1))'))).toBe('url(1)');
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
});
});
@@ -1,15 +1,176 @@
import { marked, type Tokens } from 'marked';
import { Marked, marked, type Tokens } from 'marked';
import markedLinkifyIt from 'marked-linkify-it';
import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
import { isAppLinkUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
import { highlightCodeInWorker } from './markdown-worker';
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
export interface MarkdownImageCandidate {
source: string;
filename: string;
}
export type MarkdownImageMode = 'inline' | 'label';
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES = 1024;
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES = 64 * 1024;
type MarkdownImageCandidateCacheEntry = {
candidates: MarkdownImageCandidate[];
bytes: number;
};
const markdownImageCandidateCache = new Map<string, MarkdownImageCandidateCacheEntry>();
let markdownImageCandidateCacheBytes = 0;
let markdownImageCandidateScanCount = 0;
const isLocalMarkdownImageSource = (source: string): boolean => {
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|| /^file:\/\//i.test(source)
|| !URL_SCHEME_RE.test(source);
};
const isSupportedMarkdownImageSource = (source: string): boolean => (
/^(?:https?:)?\/\//i.test(source)
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|| isLocalMarkdownImageSource(source)
);
const getMarkdownImageFilename = (source: string, fallback: string): string => {
if (/^data:image\/(png|jpeg|gif|webp)/i.test(source)) {
const extension = /^data:image\/([^;,]+)/i.exec(source)?.[1]?.replace('jpeg', 'jpg') ?? 'png';
return fallback.trim() || `image.${extension}`;
}
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
if (!encodedName) return fallback.trim();
try {
return decodeURIComponent(encodedName);
} catch {
return encodedName;
}
};
const estimateMarkdownImageCandidateCacheEntryBytes = (
markdown: string,
candidates: readonly MarkdownImageCandidate[],
): number => (
(markdown.length + candidates.reduce((total, candidate) => total + candidate.source.length + candidate.filename.length, 0)) * 2
);
const scanMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
markdownImageCandidateScanCount += 1;
const candidates: MarkdownImageCandidate[] = [];
const seen = new Set<string>();
const tokens = marked.lexer(markdown);
marked.walkTokens(tokens, (token) => {
if (token.type !== 'image') return;
const source = token.href ?? '';
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
const fallback = typeof token.text === 'string' ? token.text : '';
const filename = getMarkdownImageFilename(source, fallback);
if (!filename) return;
seen.add(source);
candidates.push({ source, filename });
});
return candidates;
};
const getMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
const cached = markdownImageCandidateCache.get(markdown);
if (cached) {
markdownImageCandidateCache.delete(markdown);
markdownImageCandidateCache.set(markdown, cached);
return cached.candidates;
}
const candidates = scanMarkdownImageCandidates(markdown);
const bytes = estimateMarkdownImageCandidateCacheEntryBytes(markdown, candidates);
if (bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES) return candidates;
while (
markdownImageCandidateCache.size >= MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES
|| markdownImageCandidateCacheBytes + bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES
) {
const oldest = markdownImageCandidateCache.entries().next().value;
if (!oldest) break;
markdownImageCandidateCache.delete(oldest[0]);
markdownImageCandidateCacheBytes -= oldest[1].bytes;
}
markdownImageCandidateCache.set(markdown, { candidates, bytes });
markdownImageCandidateCacheBytes += bytes;
return candidates;
};
/** @internal Test-only cache instrumentation for deterministic regression tests. */
export const __markdownImageCandidateCacheForTests = {
reset: (): void => {
markdownImageCandidateCache.clear();
markdownImageCandidateCacheBytes = 0;
markdownImageCandidateScanCount = 0;
},
stats: () => ({
entries: markdownImageCandidateCache.size,
bytes: markdownImageCandidateCacheBytes,
scans: markdownImageCandidateScanCount,
}),
};
const renderMarkdownImageLabel = ({
href,
title,
text,
}: {
href: string;
title?: string | null;
text: string;
}): string => {
const label = getMarkdownImageFilename(href ?? '', text);
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<span${titleAttr} class="inline-flex items-center gap-1 align-text-bottom text-muted-foreground" data-openchamber-markdown-image-label="true">${escapeAttr(label)}</span>`;
};
export const extractMarkdownImageCandidates = (
markdownTexts: readonly string[],
limit = MAX_MARKDOWN_IMAGE_COUNT,
): MarkdownImageCandidate[] => {
if (limit <= 0) return [];
const candidates: MarkdownImageCandidate[] = [];
const seen = new Set<string>();
for (const markdown of markdownTexts) {
if (!markdown || candidates.length >= limit) continue;
for (const candidate of getMarkdownImageCandidates(markdown)) {
if (candidates.length >= limit) break;
if (seen.has(candidate.source)) continue;
seen.add(candidate.source);
candidates.push({ ...candidate });
}
}
return candidates;
};
// ---------------------------------------------------------------------------
// Streaming block segmentation (port of OpenCode's markdown-stream)
// ---------------------------------------------------------------------------
@@ -18,9 +179,11 @@ type MarkdownBlock = {
raw: string;
src: string;
mode: 'full' | 'live';
// When false, skip syntax highlighting for this block. Set for the actively
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec
// (O(n^2)); it highlights once the fence closes and becomes a stable block.
// When false, skip syntax highlighting for this block. Block-level commit
// feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
// partial fence highlights too and streamed code arrives colored; only a
// very large open fence falls back to plain text until it closes, keeping
// the repeated worker re-tokenization bounded.
highlight: boolean;
};
@@ -41,6 +204,11 @@ const hasOpenFence = (raw: string): boolean => {
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
};
// Above this, re-highlighting the still-open fence on every committed line
// costs more than the colored preview is worth; the block highlights in one
// pass when the fence closes.
const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
const heal = (text: string): string => {
try {
return remend(text, { linkMode: 'text-only' });
@@ -90,11 +258,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
const raw = token.raw ?? '';
const isLast = i === tail;
const openFence = token.type === 'code' && hasOpenFence(raw);
const openFenceHighlight = openFence
&& raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
blocks.push({
raw,
src: openFence ? raw : heal(raw),
mode: isLast ? 'live' : 'full',
highlight: !openFence,
highlight: !openFence || openFenceHighlight,
});
}
@@ -162,10 +332,15 @@ const blockMathExtension = {
},
};
const parser = marked.use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
// marked's GFM autolink swallows CJK punctuation after a bare URL, so switch
// to marked-linkify-it, which treats Unicode punctuation as a URL boundary.
// Plain CJK characters right after a URL are still consumed, matching GitHub.
const createParser = (imageMode: MarkdownImageMode) => new Marked().use(
markedLinkifyIt({ fuzzyLink: false }),
{
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
renderer: {
// Assistant output is untrusted. Markdown constructs still render as HTML,
// but raw HTML must remain visible text so it cannot introduce active DOM
@@ -186,9 +361,13 @@ const parser = marked.use({
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
},
...(imageMode === 'label' ? { image: renderMarkdownImageLabel } : {}),
},
});
const inlineImageParser = createParser('inline');
const imageLabelParser = createParser('label');
// ---------------------------------------------------------------------------
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
// ---------------------------------------------------------------------------
@@ -253,32 +432,37 @@ const highlightCodeBlocks = async (html: string): Promise<string> => {
const lineLimit = isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT;
let result = html;
for (const match of matches) {
const [full, rawLang, escapedCode] = match;
const requested = (rawLang || 'text').toLowerCase();
// Leave mermaid fences untouched so the decorate pass can render them as
// diagrams (highlighting would strip the `language-mermaid` class).
if (requested === 'mermaid') continue;
// Highlight all eligible fences concurrently — sequential await was O(n)
// worker round-trips for messages with multiple code blocks.
const replacements = await Promise.all(
matches.map(async (match) => {
const [full, rawLang, escapedCode] = match;
const requested = (rawLang || 'text').toLowerCase();
// Leave mermaid fences untouched so the decorate pass can render them as
// diagrams (highlighting would strip the `language-mermaid` class).
if (requested === 'mermaid') return null;
const code = unescapeHtml(escapedCode ?? '');
const code = unescapeHtml(escapedCode ?? '');
// Oversized block: skip highlight, keep plain code but stamp the language.
if (exceedsLineLimit(code, lineLimit)) {
result = result.replace(full, () => full.replace('<pre', `<pre data-md-lang="${requested}"`));
continue;
}
// Oversized block: skip highlight, keep plain code but stamp the language.
if (exceedsLineLimit(code, lineLimit)) {
return { full, next: full.replace('<pre', `<pre data-md-lang="${requested}"`) };
}
// Tokenize off the main thread. On failure the worker resolves to null and
// we keep the original escaped <pre><code> (no main-thread highlight).
const highlighted = await highlightCodeInWorker(code, requested);
if (highlighted) {
// Tokenize off the main thread. On failure the worker resolves to null and
// we keep the original escaped <pre><code> (no main-thread highlight).
const highlighted = await highlightCodeInWorker(code, requested);
if (!highlighted) return null;
// Stamp the language so the decorate pass can show a header label.
const stamped = highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`);
result = result.replace(full, () => stamped);
}
}
return { full, next: highlighted.replace(/^<pre/, `<pre data-md-lang="${requested}"`) };
}),
);
let result = html;
for (const replacement of replacements) {
if (!replacement) continue;
result = result.replace(replacement.full, () => replacement.next);
}
return result;
};
@@ -302,6 +486,13 @@ const ensureSanitizeHook = (): void => {
if (sanitizeHookInstalled) return;
if (typeof window === 'undefined' || !DOMPurify.isSupported) return;
sanitizeHookInstalled = true;
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
// DOMPurify's default URI policy strips custom application schemes
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
// stay excluded via isAppLinkUrl and clicks go through confirmation.
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
if (node.target !== '_blank') return;
@@ -317,31 +508,86 @@ const sanitize = (html: string): string => {
// ---------------------------------------------------------------------------
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
// Per-block HTML cache (content-addressed LRU)
// ---------------------------------------------------------------------------
//
// Keyed by content hash + mode + highlight flag + image mode — NOT by renderer
// instance id. `SimpleMarkdownRenderer` historically used a shared
// `simple:${variant}` key, so every same-variant instance fought over one cache
// slot and re-highlighted unchanged content on every pass
// (openchamber/openchamber#2769). Content addressing makes identical blocks
// share one entry and stops that thrash. Bounds are high enough for long
// sessions; byte cap keeps memory bounded.
//
// `full` (settled) and `live` (trailing, still streaming) blocks get separate
// caches. A live block's content changes on every stream step, so under one
// shared content-addressed cache each step would insert a new entry and a long
// streaming message would evict the settled blocks this fix exists to keep
// warm. The live cache is small on purpose: it only has to absorb repeat
// renders of the *same* step.
const CACHE_MAX = 240;
const htmlCache = new Map<string, { hash: string; html: string }>();
const FULL_CACHE_MAX_ENTRIES = 2000;
const FULL_CACHE_MAX_BYTES = 24 * 1024 * 1024;
const LIVE_CACHE_MAX_ENTRIES = 32;
const LIVE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
// FNV-1a 32-bit hash of the block content.
const hash = (value: string): string => {
let h = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 0x01000193);
const fullBlockCache = new HighlightResultCache<string>({
maxEntries: FULL_CACHE_MAX_ENTRIES,
maxBytes: FULL_CACHE_MAX_BYTES,
});
const liveBlockCache = new HighlightResultCache<string>({
maxEntries: LIVE_CACHE_MAX_ENTRIES,
maxBytes: LIVE_CACHE_MAX_BYTES,
});
const cacheForMode = (mode: MarkdownBlock['mode']): HighlightResultCache<string> =>
(mode === 'live' ? liveBlockCache : fullBlockCache);
/** Content-addressed cache key for a markdown block. */
const markdownBlockCacheKey = (
contentHash: string,
mode: MarkdownBlock['mode'],
highlight: boolean,
imageMode: MarkdownImageMode,
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
/** Test-only: clear the render HTML caches between cases. */
export const resetMarkdownHtmlCacheForTests = (): void => {
fullBlockCache.clear();
liveBlockCache.clear();
};
/** Test-only: entry counts per block cache, for churn/eviction assertions. */
export const __markdownBlockCacheSizesForTests = (): { full: number; live: number } => ({
full: fullBlockCache.size,
live: liveBlockCache.size,
});
/**
* Read a settled render synchronously when every block is already in the full
* cache. Cache reads retain the existing LRU `get` semantics and do not insert
* or expand either cache.
*/
export const getCachedMarkdownBlocks = (
text: string,
imageMode: MarkdownImageMode = 'inline',
): RenderedBlock[] | null => {
if (!text) return [];
const blocks = streamBlocks(text, false);
const rendered: RenderedBlock[] = [];
for (const block of blocks) {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const html = fullBlockCache.get(id);
if (html === undefined) return null;
rendered.push({ id, html });
}
return (h >>> 0).toString(36);
return rendered;
};
const touch = (key: string, entry: { hash: string; html: string }): void => {
htmlCache.delete(key);
htmlCache.set(key, entry);
if (htmlCache.size <= CACHE_MAX) return;
const oldest = htmlCache.keys().next().value;
if (oldest) htmlCache.delete(oldest);
};
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
@@ -357,8 +603,12 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string): string => {
export const renderMarkdownSync = (
text: string,
imageMode: MarkdownImageMode = 'inline',
): string => {
if (!text) return '';
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = parser.parse(text) as string;
const withMath = renderMathExpressions(parsed);
return sanitize(withMath);
@@ -377,27 +627,29 @@ export type RenderedBlock = {
* splits into blocks, caches per-block, heals incomplete syntax. Returning
* blocks (instead of one joined string) lets the renderer re-morph only the
* block that changed, keeping per-step streaming cost ~O(last block).
*
* Lookup is content-addressed: distinct renderers holding identical blocks
* share one entry and cannot evict each other by identity collision.
*/
export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
imageMode: MarkdownImageMode = 'inline',
): Promise<RenderedBlock[]> => {
if (!text) return [];
const blocks = streamBlocks(text, streaming);
return Promise.all(
blocks.map(async (block, index) => {
const contentHash = hash(block.raw);
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
const key = `${cacheKey}:${index}:${block.mode}`;
const cached = htmlCache.get(key);
if (cached && cached.hash === contentHash) {
touch(key, cached);
return { id, html: cached.html };
blocks.map(async (block) => {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const cache = cacheForMode(block.mode);
const cached = cache.get(id);
if (cached !== undefined) {
return { id, html: cached };
}
const html = await parseBlock(block);
touch(key, { hash: contentHash, html });
const html = await parseBlock(block, imageMode);
cache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
return { id, html };
}),
);
@@ -0,0 +1,242 @@
/**
* Regression tests for https://github.com/openchamber/openchamber/issues/2769
*
* Sustained Shiki worker CPU came from re-tokenizing unchanged content:
* 1. `htmlCache` keyed by renderer identity (`simple:${variant}`) so
* same-variant instances evicted each other every pass.
* 2. LRU capped at 240 entries, so long sessions missed 100% on every pass.
* 3. Worker/client had no result memoization.
*
* These tests assert the fixed contracts: content-addressed caching, room for
* long sessions, bounded LRU behavior, and fingerprint-key helpers.
*/
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import {
contentFingerprint,
estimateTokenRunsBytes,
HighlightResultCache,
utf16Bytes,
} from './highlightResultCache';
let highlightCalls = 0;
let highlightInflight = 0;
let highlightMaxInflight = 0;
const highlightCodeInWorkerMock = mock(async (code: string, lang: string) => {
highlightCalls += 1;
highlightInflight += 1;
highlightMaxInflight = Math.max(highlightMaxInflight, highlightInflight);
await Promise.resolve();
highlightInflight -= 1;
return `<pre data-lang="${lang}"><code>${code}</code></pre>`;
});
mock.module('./markdown-worker', () => ({
highlightCodeInWorker: highlightCodeInWorkerMock,
highlightLinesInWorker: mock(async () => null),
highlightTokensInWorker: mock(async () => null),
resetMarkdownWorkerClientCacheForTests: mock(() => undefined),
}));
const {
renderMarkdownBlocks,
resetMarkdownHtmlCacheForTests,
__markdownBlockCacheSizesForTests,
} = await import('./markdownCore');
const { resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
beforeEach(() => {
resetMarkdownHtmlCacheForTests();
resetMarkdownWorkerClientCacheForTests();
highlightCalls = 0;
highlightInflight = 0;
highlightMaxInflight = 0;
});
describe('HighlightResultCache', () => {
test('returns cached values for identical keys and refreshes LRU order', () => {
const cache = new HighlightResultCache<string>({ maxEntries: 2, maxBytes: 10_000 });
cache.set('a', 'one', utf16Bytes('a') + utf16Bytes('one'));
cache.set('b', 'two', utf16Bytes('b') + utf16Bytes('two'));
expect(cache.get('a')).toBe('one');
// Touch `a` so `b` is oldest; inserting `c` should evict `b`.
cache.set('c', 'three', utf16Bytes('c') + utf16Bytes('three'));
expect(cache.get('b')).toEqual(undefined);
expect(cache.get('a')).toBe('one');
expect(cache.get('c')).toBe('three');
});
test('evicts by byte budget while still caching a single oversized entry', () => {
const cache = new HighlightResultCache<string>({ maxEntries: 10, maxBytes: 64 });
cache.set('small', 'x', utf16Bytes('small') + utf16Bytes('x'));
cache.set('huge', 'y'.repeat(200), utf16Bytes('huge') + utf16Bytes('y'.repeat(200)));
expect(cache.get('huge')).toBe('y'.repeat(200));
// Oversized insert cleared prior entries to make room.
expect(cache.size).toBe(1);
});
test('contentFingerprint is stable and length-qualified', () => {
expect(contentFingerprint('const x = 1')).toBe(contentFingerprint('const x = 1'));
expect(contentFingerprint('const x = 1')).not.toBe(contentFingerprint('const x = 2'));
expect(contentFingerprint('ab')).not.toBe(contentFingerprint('abc'));
});
test('contentFingerprint stays collision-free across a realistic session', () => {
// A collision here does not mis-color a block — it returns a *different*
// block's HTML, showing the user source they never wrote. Keep enough key
// space that a session-sized working set never collides.
const seen = new Map<string, string>();
for (let i = 0; i < 20_000; i += 1) {
// Same-length, near-identical sources are the realistic worst case:
// repeated tool output differing by a few characters.
const source = `const value_${String(i).padStart(6, '0')} = ${String(i).padStart(6, '0')};`;
const fingerprint = contentFingerprint(source);
expect(seen.get(fingerprint) ?? source).toBe(source);
seen.set(fingerprint, source);
}
expect(seen.size).toBe(20_000);
});
test('estimateTokenRunsBytes avoids JSON and stays positive', () => {
const lines: Array<Array<[number, string, number]>> = [
[[3, '#fff', 0], [1, '', 1]],
[[8, 'var(--md-syntax-keyword)', 0]],
];
expect(estimateTokenRunsBytes(lines)).toBeGreaterThan(0);
});
});
describe('markdownCore content-addressed htmlCache (#2769)', () => {
test('repeat renders of unchanged content never re-enter the worker', async () => {
const toolOutputA = '```ts\nconst a = 1;\n```';
const toolOutputB = '```ts\nconst b = 2;\n```';
// First pass: cold miss for each distinct block.
await renderMarkdownBlocks(toolOutputA, false);
await renderMarkdownBlocks(toolOutputB, false);
const coldCalls = highlightCalls;
expect(coldCalls).toBeGreaterThan(0);
// 100 more passes. Renderers used to pass a shared `simple:${variant}`
// identity key here and evict each other every pass; lookup is now
// content-addressed, so no additional worker calls may happen.
for (let pass = 0; pass < 100; pass += 1) {
await renderMarkdownBlocks(toolOutputA, false);
await renderMarkdownBlocks(toolOutputB, false);
}
expect(highlightCalls).toBe(coldCalls);
});
test('long sessions (working set > former 240 cap) stay warm across re-render passes', async () => {
const parts = Array.from({ length: 600 }, (_, i) => ({
content: `\`\`\`ts\nconst value_${i} = ${i};\n\`\`\``,
}));
for (const part of parts) {
await renderMarkdownBlocks(part.content, false);
}
const afterCold = highlightCalls;
expect(afterCold).toBe(parts.length);
for (let pass = 0; pass < 5; pass += 1) {
for (const part of parts) {
await renderMarkdownBlocks(part.content, false);
}
}
// Unchanged content must not re-enter the worker.
expect(highlightCalls).toBe(afterCold);
});
test('content changes invalidate only the changed block', async () => {
const stable = '```ts\nconst stable = true;\n```';
const changing = '```ts\nconst n = 1;\n```';
await renderMarkdownBlocks(stable, false);
await renderMarkdownBlocks(changing, false);
const afterFirst = highlightCalls;
await renderMarkdownBlocks(stable, false);
await renderMarkdownBlocks('```ts\nconst n = 2;\n```', false);
expect(highlightCalls).toBe(afterFirst + 1);
await renderMarkdownBlocks(stable, false);
expect(highlightCalls).toBe(afterFirst + 1);
});
test('image mode is part of the cache identity, not shared across modes', async () => {
const source = '![diagram](https://example.com/a.png)';
const [inline] = await renderMarkdownBlocks(source, false, 'inline');
expect(__markdownBlockCacheSizesForTests().full).toBe(1);
// Same source, different rendering: content addressing must not let the
// first-rendered mode answer for both.
const [label] = await renderMarkdownBlocks(source, false, 'label');
expect(inline?.id).not.toBe(label?.id);
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
// Re-rendering a mode already seen stays a cache hit.
const [inlineAgain] = await renderMarkdownBlocks(source, false, 'inline');
expect(inlineAgain?.id).toBe(inline?.id);
expect(__markdownBlockCacheSizesForTests().full).toBe(2);
});
test('streaming a message does not evict settled blocks (live cache is separate)', async () => {
const settled = Array.from(
{ length: 40 },
(_, i) => `\`\`\`ts\nconst settled_${i} = ${i};\n\`\`\``,
);
for (const block of settled) {
await renderMarkdownBlocks(block, false);
}
const settledEntries = __markdownBlockCacheSizesForTests().full;
expect(settledEntries).toBe(settled.length);
const afterSettled = highlightCalls;
// Stream a message: every step is new content for the trailing live block,
// so a single shared content-addressed cache would insert one entry per
// step and evict the settled working set this fix exists to keep warm.
let streamed = '';
for (let step = 0; step < 150; step += 1) {
streamed += `word_${step} `;
await renderMarkdownBlocks(streamed, true);
}
const sizes = __markdownBlockCacheSizesForTests();
expect(sizes.live).toBeLessThanOrEqual(32);
expect(sizes.full).toBe(settledEntries);
for (const block of settled) {
await renderMarkdownBlocks(block, false);
}
expect(highlightCalls).toBe(afterSettled);
});
test('a repeated streaming step is served from the live cache', async () => {
const step = 'partial answer text';
const [first] = await renderMarkdownBlocks(step, true);
const [second] = await renderMarkdownBlocks(step, true);
expect(second?.id).toBe(first?.id);
expect(__markdownBlockCacheSizesForTests()).toEqual({ full: 0, live: 1 });
});
test('multiple code fences in one document highlight concurrently', async () => {
const multi = [
'```ts\nconst a = 1;\n```',
'',
'```ts\nconst b = 2;\n```',
'',
'```ts\nconst c = 3;\n```',
].join('\n');
await renderMarkdownBlocks(multi, false);
expect(highlightCalls).toBe(3);
// Sequential awaits would keep max inflight at 1.
expect(highlightMaxInflight).toBeGreaterThan(1);
});
});
@@ -0,0 +1,119 @@
import { describe, expect, mock, test } from 'bun:test';
let requestCount = 0;
let requestPaths: string[] = [];
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
const runtimeFetch = mock(async (path: string, init?: RequestInit & { query?: Record<string, unknown> }) => {
requestPaths.push(path);
if (path === '/api/fs/stat') {
return new Response(JSON.stringify({ isFile: true, size: PNG.byteLength }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (path === '/api/fs/raw') {
return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } });
}
requestCount += 1;
const body = JSON.parse(String(init?.body)) as { sources: string[] };
return new Response(JSON.stringify({
results: body.sources.map((source) => ({ source, status: 'ready', path: `/repo/${source}` })),
}), { status: 200, headers: { 'content-type': 'application/json' } });
});
const resolver = {
api: () => '',
authenticatedAsset: (path: string, query: Record<string, string | undefined>) => {
const params = new URLSearchParams(Object.entries(query).filter((entry): entry is [string, string] => Boolean(entry[1])));
return `${path}?${params}`;
},
};
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver }));
class TestFileReader {
result: string | ArrayBuffer | null = null;
error: DOMException | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then((buffer) => {
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
this.onload?.();
}).catch((error) => {
this.error = error as DOMException;
this.onerror?.();
});
}
}
globalThis.FileReader = TestFileReader as unknown as typeof FileReader;
const {
getPreparedMarkdownImageUrl,
prepareLocalMarkdownImages,
resolveWorkspaceMarkdownImageSource,
} = await import('./markdownImageAssets');
describe('Markdown image asset preparation', () => {
test('prepares many images in one message-level request', async () => {
requestCount = 0;
const sources = Array.from({ length: 12 }, (_, index) => `${index}.png`);
const result = await prepareLocalMarkdownImages({
sources,
directory: '/repo',
sessionId: 'ses_batch',
messageId: 'msg_batch',
signal: new AbortController().signal,
});
expect(result.size).toBe(12);
expect(requestCount).toBe(1);
});
test('reuses preparation for one thousand messages after virtualized remounts', async () => {
requestCount = 0;
const requests = Array.from({ length: 1000 }, (_, index) => ({
sources: [`${index}.png`],
directory: '/repo',
sessionId: 'ses_long',
messageId: `msg_${index}`,
signal: new AbortController().signal,
}));
for (const request of requests) await prepareLocalMarkdownImages(request);
for (const request of requests) await prepareLocalMarkdownImages(request);
expect(requestCount).toBe(1000);
});
test('reuses the existing authenticated raw-file asset URL', () => {
const url = getPreparedMarkdownImageUrl({
status: 'ready',
path: '/tmp/opencode/image.png',
outsideFileGrant: 'grant-1',
}, '/repo');
expect(url).toContain('/api/fs/raw?');
expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png');
expect(url).toContain('outsideFileGrant=grant-1');
});
test('loads a workspace image through the local filesystem bridge', async () => {
requestPaths = [];
const url = await resolveWorkspaceMarkdownImageSource(
'screens/image.png',
'/repo',
new AbortController().signal,
);
expect(url.startsWith('data:image/png;base64,')).toBe(true);
expect(requestPaths).toEqual(['/api/fs/stat', '/api/fs/raw']);
});
});
@@ -0,0 +1,260 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeUrlResolver, type RuntimeUrlResolver } from '@/lib/runtime-url';
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
const MAX_PREPARE_CACHE_ENTRIES = 1024;
const NON_READY_CACHE_MS = 30_000;
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]);
export type PreparedMarkdownImage =
| { status: 'ready'; path: string; outsideFileGrant?: string; expiresAt?: number }
| { status: 'missing' | 'error' };
type PrepareCacheEntry = {
result: Map<string, PreparedMarkdownImage>;
expiresAt: number;
};
const prepareCaches = new WeakMap<RuntimeUrlResolver, Map<string, PrepareCacheEntry>>();
const throwIfAborted = (signal: AbortSignal): void => {
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
};
const parseLocalImagePath = (source: string): string => {
let value = source;
if (/^file:\/\//i.test(value)) {
try {
const fileUrl = new URL(value);
if (fileUrl.protocol !== 'file:') return '';
value = fileUrl.host && fileUrl.host !== 'localhost'
? `//${fileUrl.host}${fileUrl.pathname}`
: fileUrl.pathname;
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
} catch {
return '';
}
}
const path = value.split(/[?#]/, 1)[0] ?? '';
try {
return decodeURIComponent(path);
} catch {
return path;
}
};
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Unable to encode image'));
}
};
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
reader.readAsDataURL(blob);
});
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
switch (mimeType) {
case 'image/png':
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
case 'image/jpeg':
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
case 'image/gif': {
const gif = ascii(0, 6);
return gif === 'GIF87a' || gif === 'GIF89a';
}
case 'image/webp':
return ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
default:
return false;
}
};
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) throw new Error('Unsupported image type');
if (blob.size > MAX_MARKDOWN_IMAGE_BYTES) throw new Error('Image is too large');
if (!await hasImageSignature(blob, mimeType)) throw new Error('Unsupported image data');
};
const validateDataImage = async (source: string): Promise<void> => {
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
if (match[2].length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) throw new Error('Image is too large');
let binary: string;
try {
binary = atob(match[2]);
} catch {
throw new Error('Invalid image data URL');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
};
export const isLocalMarkdownImageSource = (source: string): boolean => (
!/^(?:https?:)?\/\//i.test(source)
&& !/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
);
export const prepareLocalMarkdownImages = async ({
sources,
directory,
sessionId,
messageId,
signal,
}: {
sources: readonly string[];
directory: string;
sessionId: string;
messageId: string;
signal: AbortSignal;
}): Promise<Map<string, PreparedMarkdownImage>> => {
const resolver = getRuntimeUrlResolver();
let cache = prepareCaches.get(resolver);
if (!cache) {
cache = new Map();
prepareCaches.set(resolver, cache);
}
const key = `${sessionId}\0${messageId}\0${directory}\0${sources.join('\0')}`;
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
cache.delete(key);
cache.set(key, cached);
return cached.result;
}
if (cached) cache.delete(key);
const response = await runtimeFetch(
`/api/openchamber/sessions/${encodeURIComponent(sessionId)}/markdown-image-grants`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ directory, messageId, sources }),
signal,
},
);
if (!response.ok) throw new Error(`Unable to prepare images (${response.status})`);
const payload = await response.json() as {
results?: Array<{
source?: string;
status?: string;
path?: string;
outsideFileGrant?: string;
expiresAt?: number;
}>;
};
const prepared = new Map<string, PreparedMarkdownImage>();
for (const result of payload.results ?? []) {
if (!result.source) continue;
if (result.status === 'ready' && result.path) {
prepared.set(result.source, {
status: 'ready',
path: result.path,
outsideFileGrant: result.outsideFileGrant,
expiresAt: result.expiresAt,
});
} else if (result.status === 'missing') {
prepared.set(result.source, { status: 'missing' });
} else {
prepared.set(result.source, { status: 'error' });
}
}
for (const source of sources) {
if (!prepared.has(source)) prepared.set(source, { status: 'error' });
}
while (cache.size >= MAX_PREPARE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
const allReady = [...prepared.values()].every((value) => value.status === 'ready');
const grantExpiry = Math.min(...[...prepared.values()]
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
cache.set(key, {
result: prepared,
expiresAt: allReady ? grantExpiry : Date.now() + NON_READY_CACHE_MS,
});
return prepared;
};
export const resolveMarkdownImageSource = async (
source: string,
signal: AbortSignal,
): Promise<string> => {
throwIfAborted(signal);
if (/^(?:https?:)?\/\//i.test(source)) return source;
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
await validateDataImage(source);
throwIfAborted(signal);
return source;
}
throw new Error('Local image has not been prepared');
};
/**
* VS Code has no OpenChamber server route for message-scoped temporary-file
* grants. Preserve its existing workspace-only gallery path through the local
* filesystem bridge, including the same size and signature validation.
*/
export const resolveWorkspaceMarkdownImageSource = async (
source: string,
directory: string,
signal: AbortSignal,
): Promise<string> => {
throwIfAborted(signal);
const localPath = parseLocalImagePath(source);
const absolutePath = toAbsoluteFilePath(directory, localPath);
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
throw new Error('Image path is outside the active workspace');
}
const statResponse = await runtimeFetch('/api/fs/stat', {
query: { path: absolutePath, directory, optional: 'true' },
signal,
});
if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`);
const stat = await statResponse.json() as { isFile?: boolean; size?: number };
if (!stat.isFile) throw new Error('Image path is not a file');
if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const response = await runtimeFetch('/api/fs/raw', {
query: { path: absolutePath, directory },
signal,
});
if (!response.ok) throw new Error(`Unable to load image (${response.status})`);
const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? '';
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const blob = await response.blob();
await validateImageBlob(blob, mimeType);
throwIfAborted(signal);
return blobToDataUrl(blob);
};
export const getPreparedMarkdownImageUrl = (
image: Extract<PreparedMarkdownImage, { status: 'ready' }>,
directory: string,
): string => getRuntimeUrlResolver().authenticatedAsset(
'/api/fs/raw',
{
path: image.path,
directory,
allowOutsideWorkspace: image.outsideFileGrant ? 'true' : undefined,
outsideFileGrant: image.outsideFileGrant,
},
);
@@ -4,3 +4,12 @@ export const escapeRawMarkdownHtml = (value: string): string =>
/** Active elements forbidden again at the final DOMPurify boundary. */
export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const;
export const isLocalFileUrl = (value: string): boolean => {
try {
const parsed = new URL(value);
return parsed.protocol === 'file:' && (!parsed.hostname || parsed.hostname === 'localhost');
} catch {
return false;
}
};
@@ -0,0 +1,31 @@
import type { Theme } from '@/types/theme';
/**
* Build the `--md-syntax-*` CSS custom properties for the given app theme.
* Apply the result as inline styles on the markdown container so the static
* Shiki theme resolves to the active palette.
*
* Lives apart from `markdownTheme.ts` because that module imports
* `@pierre/diffs` for theme registration; eager consumers of these CSS vars
* (tool output, code blocks) must not pull that stack into the startup graph.
*/
export const getMarkdownSyntaxVars = (theme: Theme): Record<string, string> => {
const base = theme.colors.syntax.base;
const tokens = theme.colors.syntax.tokens ?? {};
const status = theme.colors.status;
return {
'--md-syntax-foreground': base.foreground,
'--md-syntax-comment': base.comment,
'--md-syntax-string': base.string,
'--md-syntax-number': base.number,
'--md-syntax-keyword': base.keyword,
'--md-syntax-operator': base.operator,
'--md-syntax-function': base.function,
'--md-syntax-type': base.type,
'--md-syntax-variable': base.variable,
'--md-syntax-property': tokens.variableProperty ?? base.variable,
'--md-syntax-inserted': status.success,
'--md-syntax-deleted': status.error,
};
};
@@ -1,5 +1,4 @@
import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs';
import type { Theme } from '@/types/theme';
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
// The static Shiki theme name. Its definition (token colors referencing
@@ -27,29 +26,3 @@ export const ensureMarkdownShikiTheme = (): void => {
Promise.resolve(MARKDOWN_SHIKI_THEME_DEFINITION as unknown as ThemeRegistrationResolved),
);
};
/**
* Build the `--md-syntax-*` CSS custom properties for the given app theme.
* Apply the result as inline styles on the markdown container so the static
* Shiki theme resolves to the active palette.
*/
export const getMarkdownSyntaxVars = (theme: Theme): Record<string, string> => {
const base = theme.colors.syntax.base;
const tokens = theme.colors.syntax.tokens ?? {};
const status = theme.colors.status;
return {
'--md-syntax-foreground': base.foreground,
'--md-syntax-comment': base.comment,
'--md-syntax-string': base.string,
'--md-syntax-number': base.number,
'--md-syntax-keyword': base.keyword,
'--md-syntax-operator': base.operator,
'--md-syntax-function': base.function,
'--md-syntax-type': base.type,
'--md-syntax-variable': base.variable,
'--md-syntax-property': tokens.variableProperty ?? base.variable,
'--md-syntax-inserted': status.success,
'--md-syntax-deleted': status.error,
};
};
@@ -22,6 +22,18 @@ type MermaidViewerController = {
cleanup: () => void;
};
type InternalMermaidViewerController = MermaidViewerController & {
viewport: HTMLElement;
fitToViewport: (viewport: MermaidViewport) => void;
};
type MermaidViewerRegistryState = {
container: HTMLElement;
controllers: Map<HTMLElement, InternalMermaidViewerController>;
signatures: Map<HTMLElement, string>;
disposed: boolean;
};
type MermaidSvgBoundsSource = {
viewBox?: string | null;
width?: string | number | null;
@@ -36,13 +48,8 @@ type MermaidViewerSignatureSource = MermaidSvgBoundsSource & {
const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
const parseSvgNumber = (value: string | number | null | undefined): number | null => {
if (typeof value === 'number') {
return isPositiveFinite(value) ? value : null;
}
if (typeof value !== 'string') {
return null;
}
const match = value.trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
if (value === null || value === undefined) return null;
const match = String(value).trim().match(/^([+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?)(?:px)?$/);
if (!match) {
return null;
}
@@ -233,10 +240,14 @@ export const zoomMermaidViewBoxAtPoint = ({
};
const controllerByBlock = new WeakMap<HTMLElement, MermaidViewerController>();
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => (
block instanceof HTMLElement ? controllerByBlock.get(block) ?? null : null
);
const controllerByViewport = new WeakMap<HTMLElement, InternalMermaidViewerController>();
const activeControllers = new Set<InternalMermaidViewerController>();
const pendingRegistries = new Set<MermaidViewerRegistryState>();
// Controllers are non-essential for the static SVG. Initialize all renderers
// from one post-presentation batch so geometry reads precede every SVG write.
let sharedResizeObserver: ResizeObserver | null = null;
let pendingRegistryFlushFrame: number | null = null;
let pendingResizeFrame: number | null = null;
const getSvgViewport = (block: HTMLElement): HTMLElement | null => (
block.querySelector<HTMLElement>('[data-markdown="mermaid-viewport"]')
@@ -272,7 +283,62 @@ const isPanExcludedTarget = (target: EventTarget | null): boolean => (
target instanceof Element && Boolean(target.closest('button, a, [role="button"]'))
);
const createMermaidViewerController = (block: HTMLElement): MermaidViewerController | null => {
const fitControllers = (controllers: readonly InternalMermaidViewerController[]): void => {
const viewportSizes = controllers.map((controller) => getViewportSize(controller.viewport));
controllers.forEach((controller, index) => {
const viewport = viewportSizes[index];
if (viewport) controller.fitToViewport(viewport);
});
};
const scheduleActiveControllerFit = (): void => {
if (pendingResizeFrame !== null || activeControllers.size === 0) return;
pendingResizeFrame = window.requestAnimationFrame(() => {
pendingResizeFrame = null;
fitControllers(Array.from(activeControllers));
});
};
const ensureSharedResizeObserver = (): ResizeObserver | null => {
if (sharedResizeObserver) return sharedResizeObserver;
const ResizeObserverConstructor = globalThis.ResizeObserver;
if (!ResizeObserverConstructor) return null;
sharedResizeObserver = new ResizeObserverConstructor((entries) => {
for (const entry of entries) {
if (!(entry.target instanceof HTMLElement)) continue;
controllerByViewport.get(entry.target)?.fitToViewport({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
return sharedResizeObserver;
};
const registerController = (controller: InternalMermaidViewerController): void => {
if (activeControllers.has(controller)) return;
const wasEmpty = activeControllers.size === 0;
activeControllers.add(controller);
controllerByViewport.set(controller.viewport, controller);
ensureSharedResizeObserver()?.observe(controller.viewport);
if (wasEmpty) window.addEventListener('resize', scheduleActiveControllerFit);
};
const unregisterController = (controller: InternalMermaidViewerController): void => {
if (!activeControllers.delete(controller)) return;
sharedResizeObserver?.unobserve(controller.viewport);
controllerByViewport.delete(controller.viewport);
if (activeControllers.size > 0) return;
sharedResizeObserver?.disconnect();
sharedResizeObserver = null;
window.removeEventListener('resize', scheduleActiveControllerFit);
if (pendingResizeFrame !== null) {
window.cancelAnimationFrame(pendingResizeFrame);
pendingResizeFrame = null;
}
};
const createMermaidViewerController = (block: HTMLElement): InternalMermaidViewerController | null => {
const viewport = getSvgViewport(block);
const svg = block.querySelector<SVGSVGElement>('[data-markdown="mermaid"] svg');
if (!viewport || !svg) {
@@ -301,8 +367,12 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
svg.removeAttribute('height');
};
const fitToViewport = (size: MermaidViewport): void => {
applyViewBox(fitMermaidViewBox(contentBox, size));
};
const fit = (): void => {
applyViewBox(fitMermaidViewBox(contentBox, getViewportSize(viewport)));
fitToViewport(getViewportSize(viewport));
};
const zoomAt = (pointer: MermaidPoint, zoomFactor: number): void => {
@@ -390,32 +460,24 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
}
};
const onResize = (): void => {
fit();
};
viewport.addEventListener('wheel', onWheel, { passive: false });
viewport.addEventListener('pointerdown', onPointerDown);
viewport.addEventListener('pointermove', onPointerMove);
viewport.addEventListener('pointerup', stopPan);
viewport.addEventListener('pointercancel', stopPan);
window.addEventListener('resize', onResize);
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
observer?.observe(viewport);
fit();
return {
const controller: InternalMermaidViewerController = {
viewport,
zoomIn,
zoomOut,
fit,
fitToViewport,
cleanup: () => {
unregisterController(controller);
viewport.removeEventListener('wheel', onWheel);
viewport.removeEventListener('pointerdown', onPointerDown);
viewport.removeEventListener('pointermove', onPointerMove);
viewport.removeEventListener('pointerup', stopPan);
viewport.removeEventListener('pointercancel', stopPan);
window.removeEventListener('resize', onResize);
observer?.disconnect();
if (clearClickSuppressionTimer !== null) {
window.clearTimeout(clearClickSuppressionTimer);
}
@@ -424,42 +486,97 @@ const createMermaidViewerController = (block: HTMLElement): MermaidViewerControl
controllerByBlock.delete(block);
},
};
return controller;
};
export const createMermaidViewerRegistry = (container: HTMLElement): { refresh: () => void; cleanup: () => void } => {
const controllers = new Map<HTMLElement, MermaidViewerController>();
const signatures = new Map<HTMLElement, string>();
const refresh = (): void => {
for (const [block, controller] of Array.from(controllers.entries())) {
const signature = getBlockViewerSignature(block);
if (!container.contains(block) || signature !== signatures.get(block)) {
controller.cleanup();
controllers.delete(block);
signatures.delete(block);
}
const removeStaleControllers = (state: MermaidViewerRegistryState): void => {
for (const [block, controller] of state.controllers) {
const signature = getBlockViewerSignature(block);
if (!state.container.contains(block) || signature !== state.signatures.get(block)) {
controller.cleanup();
state.controllers.delete(block);
state.signatures.delete(block);
}
}
};
for (const block of Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
if (controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) {
continue;
}
const controller = createMermaidViewerController(block);
if (!controller) {
continue;
}
controllers.set(block, controller);
signatures.set(block, getBlockViewerSignature(block));
controllerByBlock.set(block, controller);
}
const collectNewControllers = (state: MermaidViewerRegistryState): InternalMermaidViewerController[] => {
if (state.disposed) return [];
const newControllers: InternalMermaidViewerController[] = [];
for (const block of Array.from(state.container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR))) {
if (state.controllers.has(block) || block.querySelector('[data-markdown="mermaid"] svg') === null) continue;
const controller = createMermaidViewerController(block);
if (!controller) continue;
state.controllers.set(block, controller);
state.signatures.set(block, getBlockViewerSignature(block));
controllerByBlock.set(block, controller);
newControllers.push(controller);
}
return newControllers;
};
const flushPendingRegistries = (): void => {
const registries = Array.from(pendingRegistries);
pendingRegistries.clear();
const newControllers: InternalMermaidViewerController[] = [];
for (const state of registries) {
if (state.disposed) continue;
removeStaleControllers(state);
newControllers.push(...collectNewControllers(state));
}
fitControllers(newControllers);
for (const controller of newControllers) registerController(controller);
};
const schedulePendingRegistryFlush = (): void => {
if (pendingRegistryFlushFrame !== null) return;
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
pendingRegistryFlushFrame = null;
pendingRegistryFlushFrame = window.requestAnimationFrame(() => {
pendingRegistryFlushFrame = null;
flushPendingRegistries();
});
});
};
const scheduleRegistryRefresh = (state: MermaidViewerRegistryState): void => {
if (state.disposed) return;
removeStaleControllers(state);
pendingRegistries.add(state);
schedulePendingRegistryFlush();
};
export const getMermaidViewerController = (block: Element | null): MermaidViewerController | null => {
if (!(block instanceof HTMLElement)) return null;
const existing = controllerByBlock.get(block);
if (existing) return existing;
for (const state of pendingRegistries) {
if (!state.container.contains(block)) continue;
flushPendingRegistries();
return controllerByBlock.get(block) ?? null;
}
return null;
};
export const createMermaidViewerRegistry = (container: HTMLElement) => {
const state: MermaidViewerRegistryState = {
container,
controllers: new Map(),
signatures: new Map(),
disposed: false,
};
const refresh = (): void => scheduleRegistryRefresh(state);
const cleanup = (): void => {
for (const controller of controllers.values()) {
state.disposed = true;
pendingRegistries.delete(state);
for (const controller of state.controllers.values()) {
controller.cleanup();
}
controllers.clear();
signatures.clear();
state.controllers.clear();
state.signatures.clear();
};
refresh();
@@ -19,9 +19,8 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
@@ -41,7 +40,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup';
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useI18n } from '@/lib/i18n';
@@ -56,6 +55,7 @@ import {
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { getAgentColor } from '@/lib/agentColors';
import { isCapacitorMobileApp } from '@/apps/mobileNativeChrome';
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
@@ -418,20 +418,16 @@ interface MessageBodyProps {
onShowPopup: (content: ToolPopupContent) => void;
streamPhase: StreamPhase;
allowAnimation: boolean;
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
shouldShowHeader?: boolean;
hasTextContent?: boolean;
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean;
onAuxiliaryContentComplete?: () => void;
showReasoningTraces?: boolean;
agentMention?: AgentMentionInfo;
turnGroupingContext?: TurnGroupingContext;
onRevert?: () => void;
onFork?: () => void;
errorMessage?: string;
errorVariant?: 'error' | 'info';
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
reviewTransferDirection?: ReviewTransferDirection | null;
@@ -489,6 +485,20 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
// One expanded state for the whole message: text parts and context cards
// collapse and expand together, with a single collapse control up here
// instead of one per part.
const collapsibleUserMessages = useUIStore((state) => state.collapsibleUserMessages);
const [messageExpanded, setMessageExpanded] = React.useState(false);
const expandMessage = React.useCallback(() => setMessageExpanded(true), []);
const collapseMessage = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setMessageExpanded(false);
}, []);
React.useEffect(() => {
if (!collapsibleUserMessages) setMessageExpanded(false);
}, [collapsibleUserMessages]);
const userContentParts = React.useMemo(() => {
return parts.filter((part) => {
if (part.type === 'text') {
@@ -566,7 +576,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference);
return formatted.length > 0 ? formatted : null;
}, [locale, messageCreatedAt, timeFormatPreference]);
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
<div className={cn(
'group/user-actions',
isMobile
@@ -716,6 +726,16 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
style={CONTAIN_LAYOUT_STYLE}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
{collapsibleUserMessages && messageExpanded && (
<button
type="button"
onClick={collapseMessage}
className="absolute top-0 right-0 z-10 flex items-center justify-center rounded-sm bg-[var(--surface-elevated)] p-0.5 text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
aria-label={t('chat.message.userText.collapseAria')}
>
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
@@ -725,10 +745,13 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
)}
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
>
{/* Positional keys, not part ids: the server echo of a just-sent
message swaps the optimistic part id, and id-based keys would
remount the text subtree (blank frame + height jump). */}
{userContentParts.map((part, index) => {
if (isSubtaskPart(part)) {
return (
<React.Fragment key={part.id ?? `user-subtask-${index}`}>
<React.Fragment key={`user-subtask-${index}`}>
<UserSubtaskPart part={part} />
</React.Fragment>
);
@@ -736,7 +759,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
if (isShellActionPart(part)) {
return (
<React.Fragment key={part.id ?? `user-shell-${index}`}>
<React.Fragment key={`user-shell-${index}`}>
<UserShellActionPart part={part} />
</React.Fragment>
);
@@ -751,12 +774,14 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
}
}
return (
<React.Fragment key={part.id ?? `user-text-${index}`}>
<React.Fragment key={`user-text-${index}`}>
<UserTextPart
part={part}
messageId={messageId}
isMobile={isMobile}
agentMention={mentionForPart}
messageExpanded={messageExpanded}
onExpandMessage={expandMessage}
/>
</React.Fragment>
);
@@ -1083,14 +1108,11 @@ const AssistantMessageBody = React.memo(({
onShowPopup,
streamPhase: _streamPhase,
allowAnimation: _allowAnimation,
onContentChange,
hasTextContent = false,
onCopyMessage,
onAuxiliaryContentComplete,
showReasoningTraces = false,
turnGroupingContext,
errorMessage,
errorVariant = 'error',
reviewTransferDirection = null,
contextPinned,
contextPinPending,
@@ -1211,6 +1233,11 @@ const AssistantMessageBody = React.memo(({
const assistantTextParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]);
const finalizedAssistantMarkdownContents = React.useMemo(() => (
isMessageCompleted
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
: []
), [assistantTextParts, isMessageCompleted]);
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
@@ -1316,16 +1343,6 @@ const AssistantMessageBody = React.memo(({
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]);
const hasTools = toolParts.length > 0;
const hasPendingTools = React.useMemo(() => {
return toolParts.some((toolPart) => {
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
const status = state?.status;
return status === 'pending' || status === 'running' || status === 'started';
});
}, [toolParts]);
const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => {
const state = (toolPart as Record<string, unknown>).state as Record<string, unknown> | undefined ?? {};
const status = state?.status;
@@ -1354,86 +1371,6 @@ const AssistantMessageBody = React.memo(({
return isActiveTool(toolPart) || isToolFinalized(toolPart);
}, [isActiveTool, isToolFinalized]);
const allToolsFinalized = React.useMemo(() => {
if (toolParts.length === 0) {
return true;
}
if (hasPendingTools) {
return false;
}
return toolParts.every((toolPart) => isToolFinalized(toolPart));
}, [toolParts, hasPendingTools, isToolFinalized]);
const reasoningParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'reasoning');
}, [visibleParts]);
const reasoningComplete = React.useMemo(() => {
if (reasoningParts.length === 0) {
return true;
}
return reasoningParts.every((part) => {
const time = (part as Record<string, unknown>).time as { end?: number } | undefined;
return typeof time?.end === 'number';
});
}, [reasoningParts]);
// Message is considered to have an "open step" if info.finish is not yet present
const hasOpenStep = typeof messageFinish !== 'string';
const shouldHoldForReasoning =
reasoningParts.length > 0 &&
hasTools &&
(hasPendingTools || hasOpenStep || !allToolsFinalized);
const shouldHoldTools = awaitingMessageCompletion
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
const isTextlessAssistantMessage = assistantTextParts.length === 0;
const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
const auxiliaryCompletionAnnouncedRef = React.useRef(false);
const soloReasoningScrollTriggeredRef = React.useRef(false);
React.useEffect(() => {
soloReasoningScrollTriggeredRef.current = false;
}, [messageId]);
React.useEffect(() => {
if (!auxiliaryContentComplete) {
auxiliaryCompletionAnnouncedRef.current = false;
return;
}
if (auxiliaryCompletionAnnouncedRef.current) {
return;
}
auxiliaryCompletionAnnouncedRef.current = true;
onAuxiliaryContentComplete?.();
}, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
React.useEffect(() => {
if (awaitingMessageCompletion) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (hasTools) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (reasoningParts.length === 0) {
return;
}
if (shouldHoldReasoning || !reasoningComplete) {
return;
}
if (soloReasoningScrollTriggeredRef.current) {
return;
}
soloReasoningScrollTriggeredRef.current = true;
onContentChange?.('structural');
}, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback(
@@ -1503,7 +1440,7 @@ const AssistantMessageBody = React.memo(({
setIsSavingPlan(true);
try {
const created = await createProjectPlanFile(currentProjectRef, {
const created = await useProjectContextStore.getState().createPlan(currentProjectRef, {
title,
body: assistantPlanText,
});
@@ -1511,9 +1448,6 @@ const AssistantMessageBody = React.memo(({
toast.error(t('chat.messageBody.toast.savePlanFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-plan-saved', {
detail: { projectId: currentProjectRef.id },
}));
setIsPlanDialogOpen(false);
toast.success(t('chat.messageBody.toast.planSaved'));
} finally {
@@ -1612,6 +1546,13 @@ const AssistantMessageBody = React.memo(({
}
throw new Error(payload.error || 'Failed to save image in VS Code');
}
} else if (isCapacitorMobileApp()) {
const blob = await fetch(dataUrl).then((response) => response.blob());
const file = new File([blob], fileName, { type: blob.type || 'image/png' });
if (!navigator.canShare?.({ files: [file] })) {
throw new Error('Image sharing is unavailable in this mobile runtime');
}
await navigator.share({ files: [file] });
} else {
const link = document.createElement('a');
link.download = fileName;
@@ -1691,9 +1632,9 @@ const AssistantMessageBody = React.memo(({
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
const showErrorMessage = Boolean(errorMessage);
const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning';
const shouldShowMessageActions = hasCopyableText;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
const isPeekSurface = chatSurfaceMode === 'peek';
const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface;
const shouldRenderActionsInActivity = isSortedRenderMode;
const shouldShowStandaloneMessageActions = showSplitAssistantMessageActions && shouldShowMessageActions && !shouldShowTurnFooter && !shouldRenderActionsInActivity;
@@ -1784,7 +1725,6 @@ const AssistantMessageBody = React.memo(({
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
streamPhase={effectiveStreamPhase}
showHeader={true}
animateRows={animateActivityRows}
@@ -1861,7 +1801,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId}
streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
/>
</div>
@@ -1896,7 +1835,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId}
streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
/>
);
@@ -1908,7 +1846,6 @@ const AssistantMessageBody = React.memo(({
part={part}
messageId={messageId}
streamPhase={effectiveStreamPhase}
onContentChange={onContentChange}
/>
);
}
@@ -1952,7 +1889,6 @@ const AssistantMessageBody = React.memo(({
onToggle={onToggleTool}
isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
/>
@@ -2024,7 +1960,6 @@ const AssistantMessageBody = React.memo(({
messageActionButtons,
renderJustificationActions,
sessionId,
onContentChange,
onShowPopup,
onToggleTool,
shouldRenderActivityGroup,
@@ -2203,17 +2138,9 @@ const AssistantMessageBody = React.memo(({
{renderedParts}
{showErrorMessage && (
<FadeInOnReveal key="assistant-error">
<div className={cn(
'group/assistant-text relative mt-3 p-3 rounded-lg border break-words max-w-full',
errorVariant === 'info'
? 'bg-[var(--status-info-background)] border-[var(--status-info-border)]'
: 'bg-[var(--status-error-background)] border-[var(--status-error-border)]',
)}>
<div className="flex items-center gap-2">
<Icon name={errorIconName} className={cn(
'h-4 w-4 shrink-0',
errorVariant === 'info' ? 'text-[var(--status-info)]' : 'text-[var(--status-error)]',
)} />
<div className="group/assistant-text relative mt-3 max-w-full break-words rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-background)] px-4 py-3 text-base leading-relaxed">
<div className="flex items-center gap-3">
<Icon name="information" className="size-4 shrink-0 text-[var(--status-info)]" />
<div className="min-w-0 flex-1 break-words">
<SimpleMarkdownRenderer
content={errorMessage ?? ''}
@@ -2228,6 +2155,12 @@ const AssistantMessageBody = React.memo(({
)}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
<MarkdownImageGallery
sessionId={sessionId}
messageId={messageId}
contents={finalizedAssistantMarkdownContents}
onShowPopup={onShowPopup}
/>
{shouldRenderStandaloneActionsAfterContent && (
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
<div className="flex items-center gap-1.5" data-message-action-group="true">
@@ -1,28 +1,31 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { useSessions } from '@/sync/sync-context';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { summarizeSelectionForNotes } from '@/lib/smallModel';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat';
import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects';
import {
DESKTOP_MENU_FALLBACK_HEIGHT_PX,
DESKTOP_MENU_FALLBACK_WIDTH_PX,
getDesktopClampedX,
getDesktopClampedY,
} from './selectionMenuPosition';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
interface TextSelectionMenuProps {
containerRef: React.RefObject<HTMLElement | null>;
@@ -38,23 +41,67 @@ interface SelectionPayload {
plainText: string;
markdownText: string;
rect: DOMRect;
messageId: string | null;
range: Range;
}
const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
if (!trimmedInsight) {
return existingNotes;
}
const trimmedNotes = existingNotes.trimEnd();
return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight;
};
const normalizeDistilledInsight = (insight: string): string => (
insight.trim().replace(/^[-*+]\s+/, '').slice(0, PROJECT_NOTE_BODY_MAX_LENGTH)
);
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
const { t } = useI18n();
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState('');
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
const [selectedMessageId, setSelectedMessageId] = React.useState<string | null>(null);
const [commentMode, setCommentMode] = React.useState(false);
const commentModeRef = React.useRef(false);
const [commentText, setCommentText] = React.useState('');
const commentInputRef = React.useRef<HTMLTextAreaElement>(null);
// While the comment input owns focus the native selection is gone, so the
// quoted fragment is repainted with our own overlay rectangles. Raw
// Range.getClientRects() mixes block-container boxes with text boxes and
// the translucent overlaps paint double-dark bands, so the rects are taken
// from the text nodes only and merged into one strip per visual line.
const [commentRects, setCommentRects] = React.useState<DOMRect[] | null>(null);
const updateCommentRects = React.useCallback(() => {
const range = pendingSelectionRef.current?.range;
if (!range) {
setCommentRects(null);
return;
}
setCommentRects(collectSelectionOverlayRects(range));
}, []);
React.useEffect(() => {
if (!commentMode) return;
let frame: number | null = null;
const scheduleUpdate = () => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
updateCommentRects();
});
};
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
if (frame !== null) window.cancelAnimationFrame(frame);
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
window.removeEventListener('resize', scheduleUpdate);
};
}, [commentMode, updateCommentRects]);
// Grow the comment box with its content, up to five lines.
const resizeCommentInput = React.useCallback(() => {
const element = commentInputRef.current;
if (!element) return;
element.style.height = 'auto';
element.style.height = `${Math.min(element.scrollHeight, 120)}px`;
}, []);
const isDraggingRef = React.useRef(false);
const [isOpening, setIsOpening] = React.useState(false);
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
@@ -65,8 +112,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const openRafRef = React.useRef<number | null>(null);
const mouseUpTimeoutRef = React.useRef<number | null>(null);
const isMenuVisibleRef = React.useRef(false);
const createSession = useSessionUIStore((state) => state.createSession);
const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const isMobile = useUIStore((state) => state.isMobile);
const projects = useProjectsStore((state) => state.projects);
@@ -74,12 +123,47 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
// Mobile: the comment bar is rendered inside the composer form (its
// positioning context), so it inherits the runtime's own keyboard handling
// — browser viewport resizing and Capacitor choreography alike. This effect
// only centers it on the composer pill in the form's local coordinates; no
// viewport math, which Safari's keyboard handling reliably breaks for
// fixed elements.
React.useEffect(() => {
if (!commentMode || !isMobile) return;
const update = () => {
const element = menuRef.current;
const host = element?.offsetParent;
if (!element || !host) return;
const pill = document.querySelector('[data-mobile-composer-pill="true"]')
?? document.querySelector('[data-chat-input="true"]');
const pillRect = pill?.getBoundingClientRect();
if (!pillRect || pillRect.height <= 0) return;
const hostRect = host.getBoundingClientRect();
element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`;
element.style.left = `${pillRect.left - hostRect.left}px`;
element.style.width = `${pillRect.width}px`;
element.style.bottom = 'auto';
};
update();
const raf = window.requestAnimationFrame(update);
// The composer relayouts with its own transitions and timeouts that emit
// no event; a light poll keeps the overlay glued to the pill.
const poll = window.setInterval(update, 200);
return () => {
window.cancelAnimationFrame(raf);
window.clearInterval(poll);
};
}, [commentMode, isMobile]);
React.useEffect(() => {
isMenuVisibleRef.current = position.show;
}, [position.show]);
React.useEffect(() => {
return () => {
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null;
@@ -93,6 +177,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const hideMenu = React.useCallback(() => {
pendingSelectionRef.current = null;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = null;
setCommentRects(null);
if (!isMenuVisibleRef.current) {
return;
@@ -107,41 +194,56 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
setPosition((prev) => ({ ...prev, show: false }));
setSelectedText('');
setSelectedTextMarkdown('');
setSelectedMessageId(null);
setCommentMode(false);
commentModeRef.current = false;
setCommentText('');
isMenuVisibleRef.current = false;
}, []);
const clampDesktopX = React.useCallback((anchorX: number) => {
if (typeof window === 'undefined') {
return anchorX;
}
const getClampedX = React.useCallback((anchorX: number) => (
getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current)
), []);
return getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current);
}, []);
const getClampedY = React.useCallback((anchorY: number) => (
getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current)
), []);
const clampDesktopY = React.useCallback((anchorY: number) => {
if (typeof window === 'undefined') {
return anchorY;
}
const addMarkdownToChat = React.useCallback((markdownText: string) => {
const markdownBlock = wrapMarkdownSelectionForChat(markdownText);
setPendingInputText(markdownBlock, 'append');
return getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current);
}, []);
hideMenu();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [hideMenu, setPendingInputText]);
const showMenu = React.useCallback(() => {
if (!pendingSelectionRef.current) return;
const { plainText, markdownText, rect } = pendingSelectionRef.current;
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
const shouldAnimateIn = !position.show;
activeAddToChatCleanupRef.current?.();
activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({
addToChat: () => addMarkdownToChat(markdownText),
dismiss: hideMenu,
});
// Position menu above the selection
const menuX = isMobile
? rect.left + rect.width / 2
: clampDesktopX(rect.left + rect.width / 2);
: getClampedX(rect.left + rect.width / 2);
const menuY = isMobile
? rect.top - 10
: clampDesktopY(rect.top - 10);
: getClampedY(rect.top - 10);
setSelectedText(plainText);
setSelectedTextMarkdown(markdownText);
setSelectedMessageId(messageId);
setPosition({
x: menuX,
y: menuY,
@@ -159,7 +261,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
openRafRef.current = null;
});
}
}, [clampDesktopX, clampDesktopY, isMobile, position.show]);
}, [addMarkdownToChat, getClampedX, getClampedY, hideMenu, isMobile, position.show]);
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
@@ -182,10 +284,29 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}
setPosition((prev) => ({
...prev,
x: clampDesktopX(prev.x),
y: clampDesktopY(prev.y),
x: getClampedX(prev.x),
y: getClampedY(prev.y),
}));
}, [clampDesktopX, clampDesktopY, isMobile, position.show]);
}, [getClampedX, getClampedY, isMobile, position.show]);
// The desktop popup hangs above its anchor, so a tall comment box near the
// top of the chat can climb over the app header. On the desktop shell the
// header is a window drag zone, which makes the overlapped part of the
// textarea untouchable, so the popup is pushed down until its top edge stays
// inside the chat container.
React.useLayoutEffect(() => {
if (!position.show || isMobile || !menuRef.current) {
return;
}
const container = containerRef.current;
const minTop = (container ? container.getBoundingClientRect().top : 0) + 4;
const menuTop = menuRef.current.getBoundingClientRect().top;
if (menuTop < minTop) {
const delta = minTop - menuTop;
setPosition((prev) => ({ ...prev, y: prev.y + delta }));
}
}, [containerRef, isMobile, position.show, position.y, commentMode, commentText]);
React.useEffect(() => {
if (!position.show || isMobile) {
@@ -195,8 +316,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleViewportResize = () => {
setPosition((prev) => ({
...prev,
x: clampDesktopX(prev.x),
y: clampDesktopY(prev.y),
x: getClampedX(prev.x),
y: getClampedY(prev.y),
}));
};
@@ -204,9 +325,14 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return () => {
window.removeEventListener('resize', handleViewportResize);
};
}, [clampDesktopX, clampDesktopY, isMobile, position.show]);
}, [getClampedX, getClampedY, isMobile, position.show]);
const handleSelectionChange = React.useCallback(() => {
// While the comment input is open, clicking or typing in it collapses the
// text selection; the captured quote must survive that.
if (commentModeRef.current) {
return;
}
const selection = window.getSelection();
const container = containerRef.current;
@@ -241,10 +367,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const rect = range.getBoundingClientRect();
// Store the selection but don't show menu yet if dragging
const anchorElement = range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
pendingSelectionRef.current = {
plainText: text,
markdownText: rangeToMarkdown(range, text),
rect,
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
range: range.cloneRange(),
};
// Only show menu if we're not currently dragging
@@ -258,7 +389,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (!container) return;
// Track when dragging starts
const handleMouseDown = () => {
const handleMouseDown = (event: MouseEvent) => {
// SAFETY: a MouseEvent target inside the document is always a Node;
// `contains` only needs that.
if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) {
return;
}
isDraggingRef.current = true;
hideMenu();
};
@@ -274,6 +410,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Small delay to ensure selection is finalized
mouseUpTimeoutRef.current = window.setTimeout(() => {
mouseUpTimeoutRef.current = null;
// The click that opened the comment input cleared the selection on
// purpose; the input must survive this deferred check.
if (commentModeRef.current) {
return;
}
const selection = window.getSelection();
if (selection && selection.toString().trim()) {
showMenu();
@@ -295,7 +436,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
!window.getSelection()?.toString().trim()
(commentModeRef.current || !window.getSelection()?.toString().trim())
) {
hideMenu();
}
@@ -317,42 +458,40 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const handleAddToChat = React.useCallback(() => {
if (!selectedTextMarkdown) return;
addMarkdownToChat(selectedTextMarkdown);
}, [addMarkdownToChat, selectedTextMarkdown]);
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
setPendingInputText(markdownBlock, 'append');
hideMenu();
// Clear selection
const handleOpenComment = React.useCallback(() => {
if (!selectedTextMarkdown) return;
setCommentMode(true);
commentModeRef.current = true;
updateCommentRects();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
commentInputRef.current?.focus();
});
}, [selectedTextMarkdown, updateCommentRects]);
const handleAttachComment = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) {
hideMenu();
return;
}
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
source: 'chat-quote',
fileLabel: selectedMessageId ?? '',
startLine: 1,
endLine: 1,
code: selectedTextMarkdown,
language: '',
text: commentText.trim(),
});
hideMenu();
queueMicrotask(() => {
focusChatInput();
});
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
const handleCreateNewSession = React.useCallback(async () => {
if (!selectedText) return;
const session = await createSession(undefined, null, null);
if (session) {
setPendingInputText(selectedText, 'replace');
}
hideMenu();
window.getSelection()?.removeAllRanges();
}, [selectedText, createSession, setPendingInputText, hideMenu]);
const handleCopy = React.useCallback(async () => {
if (!selectedText) return;
const result = await copyTextToClipboard(selectedText);
if (!result.ok) {
console.error('Failed to copy:', result.error);
}
hideMenu();
window.getSelection()?.removeAllRanges();
}, [selectedText, hideMenu]);
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
const currentSession = React.useMemo(() => {
if (!currentSessionId) {
@@ -381,19 +520,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
// Long selections are distilled into a compact note by the small model;
// short ones (and any generation failure) go in verbatim.
const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId);
const projectData = await getProjectNotesAndTodos(currentProjectRef);
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
notes: nextNotes,
todos: projectData.todos,
const insight = normalizeDistilledInsight(noteText);
if (!insight) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
// Recorded as its own note with provenance, so the distilled insight can
// later be traced back to the conversation it came from.
const saved = await useProjectContextStore.getState().createNote(currentProjectRef, {
body: insight,
source: 'selection',
...(currentSessionId ? { origin: { sessionId: currentSessionId } } : {}),
});
if (!saved) {
toast.error(t('chat.textSelection.toast.addToNotesFailed'));
return;
}
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
detail: { projectId: currentProjectRef.id },
}));
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
hideMenu();
window.getSelection()?.removeAllRanges();
@@ -407,15 +549,110 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
if (!position.show) return null;
const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0
? createPortal(
<div className="pointer-events-none fixed inset-0 z-[5]">
{commentRects.map((rect, index) => (
<div
key={index}
className="oc-chat-comment-rect absolute"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
/>
))}
</div>,
document.body,
)
: null;
const commentInput = (
<div
className={cn(
'oc-glass-popover flex items-end gap-2 rounded-3xl border border-[var(--interactive-border)]',
'pl-4 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'py-1 pr-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
>
<textarea
ref={commentInputRef}
rows={1}
value={commentText}
onChange={(event) => {
setCommentText(event.target.value);
resizeCommentInput();
}}
onKeyDown={(event) => {
// Desktop: Enter attaches, Shift+Enter breaks the line. Mobile
// keyboards use Enter for line breaks; attaching is the button's job.
if (event.key === 'Enter' && !event.shiftKey && !isMobile) {
event.preventDefault();
handleAttachComment();
} else if (event.key === 'Escape') {
event.preventDefault();
hideMenu();
}
}}
placeholder={t('chat.textSelection.comment.placeholder')}
className={cn(
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60',
// The width cap sizes the floating desktop pill; on mobile the pill
// spans the bottom bar and the cap would strand slack space to the
// right of the attach button.
isMobile ? 'w-full min-w-0 py-1.5 text-base leading-6' : 'w-64 max-w-[70vw] py-1.5'
)}
style={{ minHeight: 0, height: 'auto' }}
/>
<button
type="button"
onClick={handleAttachComment}
className={cn(
'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity duration-150',
isMobile ? 'h-9 w-9' : 'h-8 w-8'
)}
aria-label={t('chat.textSelection.comment.attach')}
title={t('chat.textSelection.comment.attach')}
>
<Icon name="attachment-2" className="h-4 w-4" />
</button>
</div>
);
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
if (isMobile) {
if (commentMode) {
// Overlay the comment input onto the composer pill: rendering into the
// composer form (position: relative) inherits the runtime's keyboard
// handling in both browser and Capacitor; the centering effect above
// glues it to the pill in the form's local coordinates.
const composerHost = document.querySelector('form.oc-mobile-composer');
const bar = (
<div
ref={menuRef}
className={cn(
'z-50',
composerHost
? 'absolute inset-x-0 bottom-[var(--oc-safe-area-bottom-visual,0.5rem)]'
: 'oc-chat-comment-bar fixed left-3 right-3 mx-auto max-w-[420px]',
)}
>
{commentInput}
</div>
);
return (
<>
{commentHighlightOverlay}
{createPortal(bar, composerHost ?? document.body)}
</>
);
}
return createPortal(
<div
ref={menuRef}
className={cn(
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
'rounded-2xl border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] p-2 shadow-lg',
'oc-glass-popover rounded-2xl border border-[var(--interactive-border)]',
'p-2 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'safe-area-bottom',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
@@ -425,6 +662,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}}
>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleOpenComment}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.commentOnSelection')}
type="button"
>
<Icon name="chat-1" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.comment')}</span>
</button>
<button
onClick={handleAddToChat}
className={cn(
@@ -438,39 +691,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<Icon name="add" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<button
onClick={handleCreateNewSession}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<Icon name="chat-new" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
</button>
<button
onClick={handleCopy}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.actions.copy')}
type="button"
>
<Icon name="file-copy" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.copy')}</span>
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
</button>
{!isVSCodeRuntime() ? (
@@ -501,80 +722,64 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
return createPortal(
<div
ref={menuRef}
className="fixed z-50"
className="app-region-no-drag fixed z-50"
style={{
left: position.x,
top: position.y,
transform: 'translate(-50%, -100%)',
}}
>
<div
className={cn(
'flex items-center gap-1 whitespace-nowrap',
'rounded-lg border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-none',
'px-1.5 py-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
>
<button
onClick={handleAddToChat}
{commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
<div
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
'flex items-center whitespace-nowrap',
'oc-glass-popover rounded-full border border-[var(--interactive-border)]',
'shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
'p-1',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
title={t('chat.textSelection.title.addToCurrentChat')}
type="button"
>
<Icon name="add" className="h-4 w-4" />
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<button
onClick={handleCreateNewSession}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.newSessionWithSelection')}
type="button"
>
<Icon name="chat-new" className="h-4 w-4" />
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
</button>
<button
onClick={handleOpenComment}
className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.commentOnSelection')}
type="button"
>
{t('chat.textSelection.actions.comment')}
</button>
{!isVSCodeRuntime() ? (
<>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : <Icon name="booklet" className="h-4 w-4" />}
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
</div>
{!isVSCodeRuntime() ? (
<>
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<button
onClick={handleAddToNotes}
disabled={isAddingToNotes}
className={cn(
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.saveInsightToNotes')}
type="button"
>
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
</button>
</>
) : null}
</div>
)}
</div>,
document.body
);
@@ -1,4 +1,5 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { readContextPart } from '@/lib/messages/contextParts';
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
@@ -96,6 +97,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
if (!synthetic) return true;
if (part.type !== 'text') return false;
if (readContextPart(part)) return true;
const text = (part as { text?: unknown }).text;
if (typeof text !== 'string') {
return false;
@@ -116,6 +118,27 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
const synthetic = rawPart.synthetic === true;
if (synthetic) {
const contextPayload = readContextPart(part);
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
// SAFETY: same display-only file-part shape the legacy
// buildGitHubAttachmentPart produces; consumed by
// FileAttachment, which matches on the mime type.
return {
type: 'file',
mime: contextPayload.kind === 'github-issue'
? 'application/vnd.github.issue-link'
: 'application/vnd.github.pull-request-link',
filename: contextPayload.kind === 'github-issue'
? `Issue #${contextPayload.number}: ${contextPayload.title}`
: `PR #${contextPayload.number}: ${contextPayload.title}`,
url: contextPayload.url,
} as Part;
}
if (contextPayload) {
// Other context kinds render through UserContextPart.
return part;
}
// Legacy messages: sniff the pre-metadata text format.
const attachmentPart = buildGitHubAttachmentPart(text);
if (attachmentPart) {
return attachmentPart;
@@ -1,4 +1,5 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { readContextPart } from '@/lib/messages/contextParts';
type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -54,6 +55,13 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
}
}
// User-attached context (inline comments, terminal selections, and
// such) is synthetic transport-wise but is user content: it renders
// as a context block and must survive alongside regular text.
if (isSynthetic && readContextPart(part)) {
return true;
}
// Only filter out synthetic parts if there are non-synthetic parts present
// Otherwise, show synthetic parts so the message is displayed
if (isSynthetic && hasNonSynthetic) {
@@ -2,7 +2,6 @@ import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase, ToolPopupContent } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
@@ -17,7 +16,6 @@ interface AssistantTextPartProps {
messageId: string;
streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void;
}
@@ -54,12 +54,40 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- Assistant markdown treats raw HTML as inert visible text. The final generated
HTML is sanitized as defense in depth, with script and style elements
forbidden, so message content cannot inject active DOM or application-wide
CSS into any runtime surface.
CSS into any runtime surface. Safe custom application links go through the
app-link confirmation flow in every supported renderer, including VS Code.
- Final assistant Markdown rendering is independent from image gallery
extraction: gallery presence never changes the chat body. Assistant image
syntax consistently renders as a shared image icon followed by its filename,
without loading the image in the body; tool and simple Markdown retain normal
inline image rendering. The gallery separately collects HTTP(S), embedded, and workspace-local
PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the
message-completion area after all message text and above the turn's changed
files. Each muted filename caption includes the shared image-file icon.
HTTP(S) images keep their browser URL. Embedded and workspace-local images
are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Chat Markdown uses
the assistant image-label policy without gallery-specific link rewriting,
completion-state switching, or hidden placeholders. A
completed assistant message hydrates at most 12 unique image candidates,
including persisted text parts that omit their optional part-level end time.
In server-backed runtimes, a gallery approaching the viewport prepares all
local candidates in one message-level request, then reuses the authenticated
`/api/fs/raw` asset route. Each URL loads only when its thumbnail approaches
the viewport. VS Code instead loads workspace-contained images through its
local filesystem bridge and never calls the server grant route; OpenCode
temporary-directory images remain unsupported there. Mounted historical
messages therefore do not eagerly read every image.
Gallery clicks do not introduce or alter preview chrome: desktop and mobile
both reuse the pre-existing attachment image preview overlay.
Workspace-external images receive the existing path-bound `outsideFileGrant`
only when the server verifies the exact source in the owning assistant
message and the real file is inside OpenCode's dedicated temporary directory.
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
## "I want to change description for Perplexity" (example recipe)
@@ -93,7 +121,15 @@ Why: only navigation tools use the compact static path; all other tools need obs
## Quick map of files in this folder
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- User-attached context (inline code comments, terminal selections, browser
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
routes to it when the part's metadata carries an `openchamberContext`
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
builder and the read-back parser). Linked GitHub issues/PRs are instead
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
pre-metadata messages still render via text sniffing (`<terminal_context>`
blocks, `GitHub issue context (JSON)` prefixes).
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`

Some files were not shown because too many files have changed in this diff Show More