Merge main and fix lazy image export loading
This commit is contained in:
@@ -38,10 +38,19 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const shouldSkip = Boolean(skipAnimation) || (!ignoreContextDisabled && contextDisabled) || reducedMotion;
|
||||
const animationEnabled = FADE_ANIMATION_ENABLED || forceAnimation;
|
||||
const [visible, setVisible] = React.useState(shouldSkip);
|
||||
// Latch the animate/skip decision at mount. If skipAnimation flips to true
|
||||
// mid-transition (e.g. the "animate on mount" flag is consumed on the next
|
||||
// re-render), unwrapping the animated div would cut the translate short and
|
||||
// visibly snap the content up by the remaining offset.
|
||||
const animateRef = React.useRef<boolean | null>(null);
|
||||
if (animateRef.current === null) {
|
||||
animateRef.current = animationEnabled && !shouldSkip;
|
||||
}
|
||||
const animate = animateRef.current;
|
||||
const [visible, setVisible] = React.useState(!animate);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!animationEnabled || shouldSkip) {
|
||||
if (!animate) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,9 +73,9 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
}, [animationEnabled, shouldSkip]);
|
||||
}, [animate]);
|
||||
|
||||
if (!animationEnabled || shouldSkip) {
|
||||
if (!animate) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import UserTextPart from './parts/UserTextPart';
|
||||
import ToolPart from './parts/ToolPart';
|
||||
import AssistantTextPart from './parts/AssistantTextPart';
|
||||
import ReasoningPart, { MergedReasoningPart } from './parts/ReasoningPart';
|
||||
import ReasoningPart from './parts/ReasoningPart';
|
||||
import { MessageFilesDisplay } from '../FileAttachment';
|
||||
import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
|
||||
import type { TurnChangedFile, TurnGroupingContext } from '../lib/turns/types';
|
||||
import type { TurnActivityGroup, TurnChangedFile, TurnGroupingContext } from '../lib/turns/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
import { isEmptyTextPart, extractTextContent } from './partUtils';
|
||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -52,6 +53,9 @@ import {
|
||||
sendImplementationResponseToReviewer,
|
||||
sendReviewFeedbackToOriginal,
|
||||
} from '@/lib/reviewFlow';
|
||||
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
|
||||
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
|
||||
|
||||
const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' };
|
||||
@@ -64,37 +68,89 @@ const getDisplayFileName = (file: string): string => {
|
||||
return segments.at(-1) ?? file;
|
||||
};
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files }: { files?: TurnChangedFile[] }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const TurnChangedFileChipContent = React.memo(({ file, interactive = false }: { file: TurnChangedFile; interactive?: boolean }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs text-muted-foreground',
|
||||
interactive && 'transition-colors hover:border-border/60 hover:bg-interactive-hover'
|
||||
)}
|
||||
style={{ lineHeight: 'round(1.35em, 1px)' }}
|
||||
>
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
|
||||
const TurnChangedFilePillButton = React.memo(({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: TurnChangedFile;
|
||||
onOpen: (file: string) => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 max-w-full cursor-pointer items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={t('chat.changedFiles.actions.openFileTitle', { path: file.file })}
|
||||
title={file.file}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpen(file.file);
|
||||
}}
|
||||
>
|
||||
<TurnChangedFileChipContent file={file} interactive />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const StaticTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<span key={file.file} className="inline-flex h-8 max-w-full items-center" title={file.file}>
|
||||
<TurnChangedFileChipContent file={file} />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
|
||||
const InteractiveTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => {
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
||||
|
||||
const openLastTurnDiff = React.useCallback((file: string) => {
|
||||
if (!isMobile && effectiveDirectory) {
|
||||
openContextDiff(effectiveDirectory, file, false, 'turn');
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToDiff(file, false, 'turn');
|
||||
}, [effectiveDirectory, isMobile, navigateToDiff, openContextDiff]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<Tooltip key={file.file}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-8 max-w-full items-center">
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground">
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{file.file}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{files.map((file) => (
|
||||
<TurnChangedFilePillButton key={file.file} file={file} onOpen={openLastTurnDiff} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files, isInteractive }: { files?: TurnChangedFile[]; isInteractive: boolean }) => {
|
||||
if (!files || files.length === 0) return null;
|
||||
|
||||
return isInteractive ? <InteractiveTurnChangedFilePills files={files} /> : <StaticTurnChangedFilePills files={files} />;
|
||||
});
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
description?: unknown;
|
||||
@@ -200,7 +256,11 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
||||
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
onClick={() => {
|
||||
if (!effectiveDirectory) return;
|
||||
if (isMobile || isVSCodeRuntime()) {
|
||||
// In contexts with no ContextPanel (embedded
|
||||
// session-chat iframe) or single-surface layouts
|
||||
// (mobile, VS Code), navigate in place. Otherwise
|
||||
// open a new side-panel tab.
|
||||
if (isEmbeddedSessionChat() || isMobile || isVSCodeRuntime()) {
|
||||
setCurrentSession(taskSessionID, effectiveDirectory);
|
||||
return;
|
||||
}
|
||||
@@ -221,6 +281,8 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' };
|
||||
|
||||
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [copiedOutput, setCopiedOutput] = React.useState(false);
|
||||
@@ -278,9 +340,14 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
|
||||
</div>
|
||||
|
||||
{command ? (
|
||||
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/90 font-mono">
|
||||
{command}
|
||||
</pre>
|
||||
<div className="typography-meta mt-1.5 overflow-x-auto font-mono">
|
||||
<WorkerHighlightedCode
|
||||
language="bash"
|
||||
code={command}
|
||||
codeStyle={SHELL_CODE_TAG_STYLE}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasOutput ? (
|
||||
@@ -306,9 +373,14 @@ const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part })
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<pre className="typography-meta mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-words text-foreground/85 font-mono">
|
||||
{output}
|
||||
</pre>
|
||||
<div className="typography-meta mt-1.5 max-h-56 overflow-auto font-mono text-foreground/85">
|
||||
<WorkerHighlightedCode
|
||||
language="bash"
|
||||
code={output}
|
||||
codeStyle={SHELL_CODE_TAG_STYLE}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -364,6 +436,14 @@ interface MessageBodyProps {
|
||||
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
||||
stickyUserHeaderEnabled?: boolean;
|
||||
reviewTransferDirection?: ReviewTransferDirection | null;
|
||||
contextPinned?: boolean;
|
||||
contextPinPending?: boolean;
|
||||
onToggleContextPin?: () => void;
|
||||
footerProviderID?: string | null;
|
||||
footerModelName?: string;
|
||||
footerAgentName?: string;
|
||||
footerVariant?: string;
|
||||
isDarkTheme?: boolean;
|
||||
}
|
||||
|
||||
const TOOL_REVEAL_CACHE_MAX = 200;
|
||||
@@ -384,9 +464,10 @@ const writeRevealedToolIds = (messageId: string, value: Set<string>): void => {
|
||||
revealedToolIdsByMessage.set(messageId, new Set(value));
|
||||
};
|
||||
|
||||
const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
|
||||
const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, contextPinned, contextPinPending, onToggleContextPin, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
|
||||
messageId: string;
|
||||
parts: Part[];
|
||||
messageCreatedAt?: number | null;
|
||||
isMobile: boolean;
|
||||
alwaysShowActions?: boolean;
|
||||
hasTouchInput?: boolean;
|
||||
@@ -397,11 +478,15 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
|
||||
agentMention?: AgentMentionInfo;
|
||||
onRevert?: () => void;
|
||||
onFork?: () => void;
|
||||
contextPinned?: boolean;
|
||||
contextPinPending?: boolean;
|
||||
onToggleContextPin?: () => void;
|
||||
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
||||
stickyUserHeaderEnabled?: boolean;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { locale, t } = useI18n();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
@@ -476,7 +561,13 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
|
||||
);
|
||||
|
||||
const effectiveOnFork = chatSurfaceMode === 'mini-chat' ? undefined : onFork;
|
||||
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork) && showUserActions ? (
|
||||
const timestamp = React.useMemo(() => {
|
||||
void locale;
|
||||
if (typeof messageCreatedAt !== 'number' || messageCreatedAt <= 0) return null;
|
||||
const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference);
|
||||
return formatted.length > 0 ? formatted : null;
|
||||
}, [locale, messageCreatedAt, timeFormatPreference]);
|
||||
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
|
||||
<div className={cn(
|
||||
'group/user-actions',
|
||||
isMobile
|
||||
@@ -504,6 +595,20 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
|
||||
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
|
||||
)}
|
||||
>
|
||||
{timestamp ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="mr-1 flex items-center gap-1 text-sm tabular-nums text-muted-foreground/60"
|
||||
aria-label={`Message time: ${timestamp}`}
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{timestamp}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{timestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onRevert && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -546,6 +651,29 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onToggleContextPin && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
|
||||
)}
|
||||
disabled={contextPinPending}
|
||||
aria-pressed={contextPinned}
|
||||
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
|
||||
>
|
||||
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCopyMessage && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -965,6 +1093,14 @@ const AssistantMessageBody = React.memo(({
|
||||
errorMessage,
|
||||
errorVariant = 'error',
|
||||
reviewTransferDirection = null,
|
||||
contextPinned,
|
||||
contextPinPending,
|
||||
onToggleContextPin,
|
||||
footerProviderID,
|
||||
footerModelName,
|
||||
footerAgentName,
|
||||
footerVariant,
|
||||
isDarkTheme = false,
|
||||
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||
const { t, locale } = useI18n();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
@@ -980,6 +1116,7 @@ const AssistantMessageBody = React.memo(({
|
||||
|
||||
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
|
||||
const alwaysShowMessageActions = Boolean(alwaysShowActions ?? isMobile);
|
||||
const { src: footerLogoSrc, onError: handleFooterLogoError, hasLogo: footerHasLogo } = useProviderLogo(footerProviderID ?? null);
|
||||
const awaitingMessageCompletion = !isMessageCompleted;
|
||||
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
|
||||
|
||||
@@ -1158,7 +1295,6 @@ const AssistantMessageBody = React.memo(({
|
||||
const [isForkSubmitting, setIsForkSubmitting] = React.useState(false);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks);
|
||||
const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks);
|
||||
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const vscodeApi = useRuntimeAPIs().vscode;
|
||||
@@ -1395,6 +1531,9 @@ const AssistantMessageBody = React.memo(({
|
||||
|
||||
let wrapper: HTMLDivElement | null = null;
|
||||
try {
|
||||
// Load the exporter before attaching its temporary clone so a slow
|
||||
// chunk request cannot leave export-only content in the page layout.
|
||||
const { toPng } = await import('html-to-image');
|
||||
const originalElement = sourceElement;
|
||||
const computedStyle = window.getComputedStyle(originalElement);
|
||||
const rootStyle = window.getComputedStyle(document.documentElement);
|
||||
@@ -1405,6 +1544,7 @@ const AssistantMessageBody = React.memo(({
|
||||
const paddingSize = 24;
|
||||
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-message-image-export', 'true');
|
||||
wrapper.style.cssText = `
|
||||
padding: ${paddingSize}px;
|
||||
background-color: ${resolvedBackgroundColor};
|
||||
@@ -1454,9 +1594,6 @@ const AssistantMessageBody = React.memo(({
|
||||
wrapper.appendChild(clone);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
// Lazy-load html-to-image: only needed when the user exports a
|
||||
// message as an image, so keep it out of the eager app shell.
|
||||
const { toPng } = await import('html-to-image');
|
||||
const dataUrl = await toPng(wrapper, {
|
||||
quality: 1,
|
||||
pixelRatio: 2,
|
||||
@@ -1627,51 +1764,82 @@ const AssistantMessageBody = React.memo(({
|
||||
const renderedParts = React.useMemo(() => {
|
||||
const rendered: React.ReactNode[] = [];
|
||||
|
||||
const renderSegmentBlock = (segment: TurnActivityGroup): React.ReactNode | null => {
|
||||
if (!shouldRenderActivityGroup || !toggleActivityGroup) {
|
||||
return null;
|
||||
}
|
||||
const visibleSegmentParts = showReasoningTraces
|
||||
? segment.parts
|
||||
: segment.parts.filter((activity) => activity.kind !== 'reasoning');
|
||||
if (visibleSegmentParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={`progressive-group-${segment.id}`} className="mb-3">
|
||||
<TurnActivity
|
||||
parts={visibleSegmentParts}
|
||||
isExpanded={turnGroupingContext?.isGroupExpanded === true}
|
||||
collapsedPreviewCount={collapsedPreviewCount}
|
||||
onToggle={toggleActivityGroup}
|
||||
isMobile={isMobile}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
showHeader={true}
|
||||
animateRows={animateActivityRows}
|
||||
animatedToolIds={animatedToolIdsLookup}
|
||||
diffStats={turnGroupingContext?.diffStats}
|
||||
renderJustificationActions={renderJustificationActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Segments that follow a standalone tool of THIS message render right
|
||||
// after that tool's row so e.g. an Agent Task sits chronologically
|
||||
// between the activity before it and the activity after it.
|
||||
const localToolPartIds = new Set<string>();
|
||||
visibleParts.forEach((part, partIndex) => {
|
||||
if (part.type === 'tool') {
|
||||
localToolPartIds.add(part.id ?? `${messageId}-part-${partIndex}-${part.type}`);
|
||||
}
|
||||
});
|
||||
const segmentsAfterLocalTool = new Map<string, TurnActivityGroup[]>();
|
||||
if (shouldRenderActivityGroup && toggleActivityGroup) {
|
||||
activityGroupSegmentsForMessage.forEach((segment) => {
|
||||
const visibleSegmentParts = showReasoningTraces
|
||||
? segment.parts
|
||||
: segment.parts.filter((activity) => activity.kind !== 'reasoning');
|
||||
if (visibleSegmentParts.length === 0) {
|
||||
if (segment.afterToolPartId && localToolPartIds.has(segment.afterToolPartId)) {
|
||||
const list = segmentsAfterLocalTool.get(segment.afterToolPartId) ?? [];
|
||||
list.push(segment);
|
||||
segmentsAfterLocalTool.set(segment.afterToolPartId, list);
|
||||
return;
|
||||
}
|
||||
rendered.push(
|
||||
<div key={`progressive-group-${segment.id}`} className="mb-3">
|
||||
<TurnActivity
|
||||
parts={visibleSegmentParts}
|
||||
isExpanded={turnGroupingContext.isGroupExpanded === true}
|
||||
collapsedPreviewCount={collapsedPreviewCount}
|
||||
onToggle={toggleActivityGroup}
|
||||
isMobile={isMobile}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={onToggleTool}
|
||||
onShowPopup={onShowPopup}
|
||||
onContentChange={onContentChange}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
showHeader={true}
|
||||
animateRows={animateActivityRows}
|
||||
animatedToolIds={animatedToolIdsLookup}
|
||||
diffStats={turnGroupingContext.diffStats}
|
||||
renderJustificationActions={renderJustificationActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const block = renderSegmentBlock(segment);
|
||||
if (block) {
|
||||
rendered.push(block);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const flushSegmentsAfterTool = (toolPartId: string) => {
|
||||
const segments = segmentsAfterLocalTool.get(toolPartId);
|
||||
if (!segments) {
|
||||
return;
|
||||
}
|
||||
segmentsAfterLocalTool.delete(toolPartId);
|
||||
segments.forEach((segment) => {
|
||||
const block = renderSegmentBlock(segment);
|
||||
if (block) {
|
||||
rendered.push(block);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Flat rendering: iterate parts in natural order.
|
||||
// Group consecutive static tools (read, grep, glob, etc.) into compact rows.
|
||||
// Expandable tools (bash, edit, task) get individual rows.
|
||||
// Text renders inline at its natural position.
|
||||
// Reasoning: all reasoning parts for this message are merged into ONE block
|
||||
// at the position of the first reasoning part (VSCode Copilot pattern).
|
||||
const flatReasoningParts = visibleParts.filter((p) => {
|
||||
if (p.type !== 'reasoning') return false;
|
||||
const a = activityByPart.get(p);
|
||||
return a?.kind !== 'reasoning';
|
||||
});
|
||||
let reasoningMergeRendered = false;
|
||||
|
||||
let i = 0;
|
||||
while (i < visibleParts.length) {
|
||||
const part = visibleParts[i];
|
||||
@@ -1733,20 +1901,6 @@ const AssistantMessageBody = React.memo(({
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
);
|
||||
} else if (groupReasoningBlocks) {
|
||||
// Merged mode (VSCode pattern): one block for all reasoning parts.
|
||||
if (!reasoningMergeRendered) {
|
||||
reasoningMergeRendered = true;
|
||||
rendered.push(
|
||||
<MergedReasoningPart
|
||||
key={`reasoning-merged-${messageId}`}
|
||||
parts={flatReasoningParts}
|
||||
messageId={messageId}
|
||||
streamPhase={effectiveStreamPhase}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Per-part mode: each reasoning block at its natural position.
|
||||
rendered.push(
|
||||
@@ -1767,19 +1921,23 @@ const AssistantMessageBody = React.memo(({
|
||||
if (part.type === 'tool') {
|
||||
const toolPart = part as ToolPartType;
|
||||
const toolName = toolPart.tool?.toLowerCase() ?? '';
|
||||
const toolPartId = toolPart.id ?? `${messageId}-part-${i}-${part.type}`;
|
||||
|
||||
if (isSortedRenderMode && !isActivityOwnerMessage) {
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const activity = activityByPart.get(part);
|
||||
if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) {
|
||||
if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) {
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldShowTool(toolPart)) {
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -1802,6 +1960,7 @@ const AssistantMessageBody = React.memo(({
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -1827,6 +1986,7 @@ const AssistantMessageBody = React.memo(({
|
||||
</ToolRevealOnMount>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
flushSegmentsAfterTool(toolPartId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -1835,6 +1995,17 @@ const AssistantMessageBody = React.memo(({
|
||||
i++;
|
||||
}
|
||||
|
||||
// Any segments whose anchor tool never got flushed (filtered parts,
|
||||
// unexpected ordering) must still render rather than disappear.
|
||||
segmentsAfterLocalTool.forEach((segments) => {
|
||||
segments.forEach((segment) => {
|
||||
const block = renderSegmentBlock(segment);
|
||||
if (block) {
|
||||
rendered.push(block);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return rendered;
|
||||
}, [
|
||||
activityByPart,
|
||||
@@ -1844,7 +2015,6 @@ const AssistantMessageBody = React.memo(({
|
||||
animateActivityRows,
|
||||
chatRenderMode,
|
||||
collapsibleThinkingBlocks,
|
||||
groupReasoningBlocks,
|
||||
collapsedPreviewCount,
|
||||
expandedTools,
|
||||
isMobile,
|
||||
@@ -1939,6 +2109,29 @@ const AssistantMessageBody = React.memo(({
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onToggleContextPin && hasCopyableText ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
|
||||
)}
|
||||
disabled={contextPinPending}
|
||||
aria-pressed={contextPinned}
|
||||
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
|
||||
>
|
||||
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isMiniChatSurface && !isReviewSessionView ? <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -2045,13 +2238,46 @@ const AssistantMessageBody = React.memo(({
|
||||
)}
|
||||
{shouldShowTurnFooter && (
|
||||
<div
|
||||
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-1.5"
|
||||
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-x-3 gap-y-1.5"
|
||||
style={MESSAGE_FOOTER_CONTAINER_STYLE}
|
||||
>
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{messageActionButtons}
|
||||
{finalTurnActionButtons}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted-foreground/60">
|
||||
{footerModelName ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{footerHasLogo && footerLogoSrc ? (
|
||||
<img
|
||||
src={footerLogoSrc}
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 flex-shrink-0"
|
||||
style={{
|
||||
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
|
||||
}}
|
||||
onError={handleFooterLogoError}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
name="brain-ai-3"
|
||||
className="h-3.5 w-3.5 flex-shrink-0"
|
||||
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{footerModelName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="brain-ai-3" className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="message-footer__label">
|
||||
{footerVariant[0].toLowerCase() + footerVariant.slice(1)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{footerAgentName ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="ai-agent" className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="message-footer__label">{footerAgentName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{turnDurationText ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2081,8 +2307,24 @@ const AssistantMessageBody = React.memo(({
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilePills files={turnGroupingContext?.changedFiles} />
|
||||
<TurnChangedFilePills
|
||||
files={turnGroupingContext?.changedFiles}
|
||||
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5',
|
||||
alwaysShowMessageActions || isTouchContext
|
||||
? undefined
|
||||
: 'pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100'
|
||||
)}
|
||||
data-message-action-group="true"
|
||||
>
|
||||
{messageActionButtons}
|
||||
{finalTurnActionButtons}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2098,6 +2340,7 @@ const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
|
||||
<UserMessageBody
|
||||
messageId={props.messageId}
|
||||
parts={props.parts}
|
||||
messageCreatedAt={props.messageCreatedAt}
|
||||
isMobile={props.isMobile}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
hasTouchInput={props.hasTouchInput}
|
||||
@@ -2108,6 +2351,9 @@ const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
|
||||
agentMention={props.agentMention}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
contextPinned={props.contextPinned}
|
||||
contextPinPending={props.contextPinPending}
|
||||
onToggleContextPin={props.onToggleContextPin}
|
||||
userActionsMode={props.userActionsMode}
|
||||
stickyUserHeaderEnabled={props.stickyUserHeaderEnabled}
|
||||
/>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
interface MessageHeaderProps {
|
||||
isUser: boolean;
|
||||
providerID: string | null;
|
||||
agentName: string | undefined;
|
||||
modelName: string | undefined;
|
||||
variant?: string;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agentName, modelName, variant, isDarkTheme }) => {
|
||||
const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
|
||||
|
||||
return (
|
||||
<div className={cn('mb-2')}>
|
||||
<div className={cn('flex items-center justify-between gap-2')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
{isUser ? (
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Icon name="user-3" className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
{hasLogo && logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={`${providerID} logo`}
|
||||
className="h-4 w-4"
|
||||
style={{
|
||||
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
|
||||
}}
|
||||
onError={handleLogoError}
|
||||
/>
|
||||
) : (
|
||||
<Icon name="brain-ai-3" className="h-4 w-4"
|
||||
style={{ color: `var(${getAgentColor(agentName).var})` }}/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3
|
||||
className={cn(
|
||||
'font-bold typography-ui-header tracking-tight leading-none',
|
||||
isUser ? 'text-primary' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{isUser ? 'You' : (modelName || 'Assistant')}
|
||||
</h3>
|
||||
{!isUser && agentName && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
|
||||
'agent-badge typography-meta',
|
||||
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
|
||||
getAgentColor(agentName).class
|
||||
)}
|
||||
>
|
||||
<Icon name="ai-agent" className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium">{agentName}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isUser && variant && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
|
||||
'agent-badge typography-meta',
|
||||
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
|
||||
variant === 'Default' ? undefined : 'agent-info'
|
||||
)}
|
||||
style={
|
||||
variant === 'Default'
|
||||
? ({
|
||||
'--agent-color': 'var(--muted-foreground)',
|
||||
'--agent-color-bg': 'var(--muted-foreground)',
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon name="brain-ai-3" className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium">{variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageHeader);
|
||||
@@ -10,10 +10,12 @@ 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 { 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';
|
||||
|
||||
interface TextSelectionMenuProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -43,167 +45,6 @@ const appendDistilledInsightToNotes = (existingNotes: string, insight: string):
|
||||
|
||||
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
const BLOCK_TAGS = new Set([
|
||||
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
|
||||
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
|
||||
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
|
||||
'section', 'table', 'ul',
|
||||
]);
|
||||
|
||||
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
|
||||
|
||||
const trimSelectionValue = (value: string): string => normalizeLineBreaks(value).trim();
|
||||
|
||||
const textToMarkdownInline = (value: string): string => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const renderInlineMarkdownNode = (node: Node): string => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return textToMarkdownInline(node.textContent || '');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const childText = Array.from(element.childNodes)
|
||||
.map((child) => renderInlineMarkdownNode(child))
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!childText && tag !== 'br') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (tag === 'br') return '\n';
|
||||
if (tag === 'strong' || tag === 'b') return `**${childText}**`;
|
||||
if (tag === 'em' || tag === 'i') return `*${childText}*`;
|
||||
if (tag === 'code') return `\`${childText.replace(/`/g, '\\`')}\``;
|
||||
if (tag === 'a') {
|
||||
const href = element.getAttribute('href');
|
||||
return href ? `[${childText}](${href})` : childText;
|
||||
}
|
||||
|
||||
return childText;
|
||||
};
|
||||
|
||||
const renderListMarkdown = (list: HTMLElement, ordered: boolean): string => {
|
||||
const items = Array.from(list.children).filter(
|
||||
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName.toLowerCase() === 'li'
|
||||
);
|
||||
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const prefix = ordered ? `${index + 1}. ` : '- ';
|
||||
const body = Array.from(item.childNodes)
|
||||
.map((child) => renderInlineMarkdownNode(child))
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return body ? `${prefix}${body}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const renderBlockMarkdownNode = (node: Node): string => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return trimSelectionValue(node.textContent || '');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
|
||||
if (tag === 'pre') {
|
||||
const codeElement = element.querySelector('code');
|
||||
const languageClass = codeElement?.className || '';
|
||||
const language = (languageClass.match(/language-([\w-]+)/)?.[1] || '').trim();
|
||||
const code = normalizeLineBreaks(codeElement?.textContent || element.textContent || '').replace(/\n$/, '');
|
||||
return `\`\`\`${language}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
if (tag === 'code') {
|
||||
const code = normalizeLineBreaks(element.textContent || '').trim();
|
||||
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
|
||||
}
|
||||
|
||||
if (tag === 'ul') return renderListMarkdown(element, false);
|
||||
if (tag === 'ol') return renderListMarkdown(element, true);
|
||||
|
||||
if (tag === 'blockquote') {
|
||||
const content = trimSelectionValue(
|
||||
Array.from(element.childNodes).map((child) => renderBlockMarkdownNode(child)).join('\n')
|
||||
);
|
||||
return content
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
const level = Number.parseInt(tag[1], 10);
|
||||
const text = trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
return text ? `${'#'.repeat(level)} ${text}` : '';
|
||||
}
|
||||
|
||||
if (tag === 'p' || tag === 'div' || tag === 'li') {
|
||||
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
}
|
||||
|
||||
const blockChildren = Array.from(element.childNodes)
|
||||
.map((child) => renderBlockMarkdownNode(child))
|
||||
.filter((child) => child.length > 0);
|
||||
if (blockChildren.length > 0) {
|
||||
return blockChildren.join('\n\n');
|
||||
}
|
||||
|
||||
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
};
|
||||
|
||||
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
|
||||
return Array.from(fragment.childNodes).every((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return true;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
const rangeToMarkdown = (range: Range, plainText: string): string => {
|
||||
const fragment = range.cloneContents();
|
||||
|
||||
if (isInlineSelectionFragment(fragment)) {
|
||||
const inlineMarkdown = trimSelectionValue(
|
||||
Array.from(fragment.childNodes)
|
||||
.map((node) => renderInlineMarkdownNode(node))
|
||||
.join('')
|
||||
);
|
||||
if (inlineMarkdown) {
|
||||
return inlineMarkdown;
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = Array.from(fragment.childNodes)
|
||||
.map((node) => renderBlockMarkdownNode(node))
|
||||
.filter((value) => value.length > 0)
|
||||
.join('\n\n')
|
||||
.trim();
|
||||
|
||||
return markdown || trimSelectionValue(plainText);
|
||||
};
|
||||
|
||||
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
||||
const { t } = useI18n();
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||
@@ -461,7 +302,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const handleAddToChat = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
|
||||
const markdownBlock = `\`\`\`md\n${selectedTextMarkdown}\n\`\`\``;
|
||||
const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown);
|
||||
setPendingInputText(markdownBlock, 'append');
|
||||
|
||||
hideMenu();
|
||||
@@ -518,7 +359,9 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
try {
|
||||
setIsAddingToNotes(true);
|
||||
const noteText = selectedTextMarkdown || selectedText;
|
||||
// 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, {
|
||||
@@ -541,7 +384,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
} finally {
|
||||
setIsAddingToNotes(false);
|
||||
}
|
||||
}, [currentProjectRef, hideMenu, selectedText, selectedTextMarkdown, t]);
|
||||
}, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]);
|
||||
|
||||
if (!position.show) return null;
|
||||
|
||||
@@ -717,5 +560,3 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default TextSelectionMenu;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
describe('getMermaidDataUrlSourcePromise', () => {
|
||||
test('turns malformed data URLs into rejected promises', async () => {
|
||||
const sourcePromise = getMermaidDataUrlSourcePromise('data:text/plain;base64');
|
||||
|
||||
await sourcePromise.then(
|
||||
() => {
|
||||
throw new Error('expected malformed data URL to reject');
|
||||
},
|
||||
(error) => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(MermaidLoadFailure);
|
||||
expect(error.key).toBe('chat.toolOutputDialog.mermaid.dataUrlMalformed');
|
||||
expect(error.params).toBe(undefined);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mermaid load request ids', () => {
|
||||
test('invalidates stale async loads when a newer load starts', () => {
|
||||
const firstRequest = nextMermaidLoadRequestId(0);
|
||||
const secondRequest = nextMermaidLoadRequestId(firstRequest);
|
||||
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, firstRequest)).toBe(false);
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,9 @@ import { DiffViewToggle } from './DiffViewToggle';
|
||||
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
@@ -35,6 +36,8 @@ interface ToolOutputDialogProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
|
||||
|
||||
const getToolIcon = (toolName: string) => {
|
||||
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||
const tool = toolName.toLowerCase();
|
||||
@@ -97,7 +100,7 @@ const MERMAID_ASPECT_MAX_RETRIES = 3;
|
||||
|
||||
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
|
||||
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, showPanZoomControls: true };
|
||||
|
||||
type PierreThemeConfig = {
|
||||
theme: { light: string; dark: string };
|
||||
@@ -694,22 +697,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return isSafeLocalPath(decoded) ? decoded : (isSafeLocalPath(stripped) ? stripped : null);
|
||||
}, []);
|
||||
|
||||
const decodeDataUrl = React.useCallback((value: string): string => {
|
||||
const commaIndex = value.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
throw new Error('Malformed data URL');
|
||||
}
|
||||
|
||||
const metadata = value.slice(0, commaIndex).toLowerCase();
|
||||
const payload = value.slice(commaIndex + 1);
|
||||
if (metadata.includes(';base64')) {
|
||||
return atob(payload);
|
||||
}
|
||||
return decodeURIComponent(payload);
|
||||
}, []);
|
||||
|
||||
const loadMermaidSource = React.useCallback(async () => {
|
||||
const target = popup.mermaid;
|
||||
const requestId = nextMermaidLoadRequestId(requestIdRef.current);
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
if (!target?.url) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
|
||||
@@ -723,24 +715,21 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
|
||||
let sourcePromise: Promise<string>;
|
||||
if (target.url.startsWith('data:')) {
|
||||
sourcePromise = Promise.resolve(decodeDataUrl(target.url));
|
||||
sourcePromise = getMermaidDataUrlSourcePromise(target.url);
|
||||
} else if (target.url.toLowerCase().startsWith('file://')) {
|
||||
const normalizedPath = normalizeFilePath(target.url);
|
||||
if (!normalizedPath) {
|
||||
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.invalidLocalPath'));
|
||||
} else {
|
||||
sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.readFileFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -752,12 +741,12 @@ const MermaidPreviewDialog: React.FC<{
|
||||
const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null;
|
||||
|
||||
if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) {
|
||||
sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.unsupportedUrlProtocol'));
|
||||
} else {
|
||||
sourcePromise = fetch(resolvedUrl.toString())
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to load diagram (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.loadFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -766,7 +755,7 @@ const MermaidPreviewDialog: React.FC<{
|
||||
|
||||
await sourcePromise
|
||||
.then((resolvedSource) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -774,13 +763,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
setStatus('ready');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
setErrorMessage(isMermaidLoadFailure(error) ? t(error.key, error.params) : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
});
|
||||
}, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]);
|
||||
}, [normalizeFilePath, popup.mermaid, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popup.open || !popup.mermaid) {
|
||||
@@ -896,10 +885,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute inset-0 bg-black/40',
|
||||
'absolute inset-0',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-background) 70%, transparent)' }}
|
||||
onMouseDown={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
@@ -941,7 +931,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
|
||||
<div
|
||||
className="rounded-xl border p-3 space-y-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--status-error-background)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}
|
||||
>
|
||||
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
|
||||
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
|
||||
</p>
|
||||
@@ -966,8 +962,8 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<SimpleMarkdownRenderer
|
||||
content={mermaidMarkdown}
|
||||
variant="tool"
|
||||
allowMermaidWheelZoom
|
||||
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
||||
allowMermaidWheelEvents
|
||||
className="markdown-mermaid-fullscreen h-full"
|
||||
mermaidControls={MERMAID_CONTROLS}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { deriveMessageRole } from './messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './partUtils';
|
||||
import { normalizeUserDisplayParts } from './normalizeUserDisplayParts';
|
||||
|
||||
/**
|
||||
* A user message is hidden when none of its parts survive display
|
||||
* normalization (e.g. synthetic subagent-completion nudges). Turns separated
|
||||
* only by such messages should render as one continuous flow.
|
||||
*/
|
||||
// Streaming recomputes turn projections often; cache by parts reference so
|
||||
// unchanged messages resolve without re-running display normalization.
|
||||
const hiddenByPartsPlanMode = new WeakMap<Part[], boolean>();
|
||||
const hiddenByPartsNoPlanMode = new WeakMap<Part[], boolean>();
|
||||
|
||||
export const isHiddenUserMessage = (
|
||||
entry: { info: Message; parts: Part[] } | null | undefined,
|
||||
options: { planModeEnabled: boolean }
|
||||
): boolean => {
|
||||
if (!entry) return false;
|
||||
if (!deriveMessageRole(entry.info).isUser) return false;
|
||||
|
||||
const cache = options.planModeEnabled ? hiddenByPartsPlanMode : hiddenByPartsNoPlanMode;
|
||||
const cached = cache.get(entry.parts);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const parts = normalizeUserDisplayParts(normalizeParts(entry.parts), { planModeEnabled: options.planModeEnabled });
|
||||
const hidden = filterVisibleParts(parts, { includeReasoning: true }).length === 0;
|
||||
cache.set(entry.parts, hidden);
|
||||
return hidden;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
export const isValidPart = (part: unknown): part is Part => {
|
||||
const isValidPart = (part: unknown): part is Part => {
|
||||
return Boolean(part && typeof part === 'object' && typeof (part as { type?: unknown }).type === 'string');
|
||||
};
|
||||
|
||||
@@ -67,13 +67,3 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
|
||||
return !isPatchPart;
|
||||
});
|
||||
};
|
||||
|
||||
type PartWithTime = Part & { time?: { start?: number; end?: number } };
|
||||
|
||||
export const isFinalizedTextPart = (part: Part): boolean => {
|
||||
if (part.type !== 'text') {
|
||||
return false;
|
||||
}
|
||||
const time = (part as PartWithTime).time;
|
||||
return Boolean(time && typeof time.end !== 'undefined');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import type { EditorAPI } from '@/lib/api/types';
|
||||
|
||||
import { ApplyPatchFileButtons } from './ApplyPatchFileButtons';
|
||||
import { openApplyPatchFileInEditor } from './applyPatchEditorAction';
|
||||
|
||||
const makePatch = (path: string, line: number, before: string, after: string) => [
|
||||
`--- a/${path}`,
|
||||
`+++ b/${path}`,
|
||||
`@@ -${line} +${line} @@`,
|
||||
`-${before}`,
|
||||
`+${after}`,
|
||||
].join('\n');
|
||||
|
||||
const files = [
|
||||
{
|
||||
filePath: '/workspace/project/src/first.ts',
|
||||
relativePath: 'src/first.ts',
|
||||
patch: makePatch('src/first.ts', 4, 'first old', 'first new'),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
type: 'update',
|
||||
},
|
||||
{
|
||||
filePath: '/workspace/project/src/second.ts',
|
||||
relativePath: 'src/second.ts',
|
||||
patch: makePatch('src/second.ts', 12, 'second old', 'second new'),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
type: 'update',
|
||||
},
|
||||
];
|
||||
|
||||
describe('ApplyPatchFileButtons', () => {
|
||||
test('renders one labeled button per non-deleted file', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ApplyPatchFileButtons
|
||||
metadata={{ files }}
|
||||
openDiffLabel="Open file diff"
|
||||
onFileClick={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup.match(/<button/g)).toHaveLength(2);
|
||||
expect(markup).toContain('aria-label="Open file diff: src/first.ts"');
|
||||
expect(markup).toContain('aria-label="Open file diff: src/second.ts"');
|
||||
});
|
||||
|
||||
test('opens each clicked file with its own authoritative path, patch, and line', () => {
|
||||
const openDiffCalls: Parameters<EditorAPI['openDiff']>[] = [];
|
||||
const editor: EditorAPI = {
|
||||
openDiff: async (...args) => { openDiffCalls.push(args); },
|
||||
openFile: async () => undefined,
|
||||
};
|
||||
let propagationStops = 0;
|
||||
const stopPropagation = () => { propagationStops += 1; };
|
||||
const tree = ApplyPatchFileButtons({
|
||||
metadata: { files },
|
||||
openDiffLabel: 'Open file diff',
|
||||
onFileClick: (file, event) => {
|
||||
event.stopPropagation();
|
||||
const targetPath = typeof file.relativePath === 'string' ? file.relativePath : '';
|
||||
openApplyPatchFileInEditor({
|
||||
currentDirectory: '/workspace/project',
|
||||
diffLabel: `${targetPath} (changes)`,
|
||||
editor,
|
||||
file,
|
||||
isVSCode: true,
|
||||
});
|
||||
},
|
||||
}) as React.ReactElement<{ children: React.ReactNode }>;
|
||||
const buttons = React.Children.toArray(tree.props.children) as React.ReactElement<{
|
||||
onClick: (event: { stopPropagation: () => void }) => void;
|
||||
}>[];
|
||||
|
||||
buttons[0]?.props.onClick({ stopPropagation });
|
||||
buttons[1]?.props.onClick({ stopPropagation });
|
||||
|
||||
expect(propagationStops).toBe(2);
|
||||
expect(openDiffCalls).toEqual([
|
||||
['', '/workspace/project/src/first.ts', 'src/first.ts (changes)', {
|
||||
line: 4,
|
||||
patch: files[0]?.patch,
|
||||
}],
|
||||
['', '/workspace/project/src/second.ts', 'src/second.ts (changes)', {
|
||||
line: 12,
|
||||
patch: files[1]?.patch,
|
||||
}],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getApplyPatchFilePath } from './toolDiffUtils';
|
||||
|
||||
type ApplyPatchFileEntry = {
|
||||
file: Record<string, unknown>;
|
||||
path: string;
|
||||
name: string;
|
||||
added: number | null;
|
||||
removed: number | null;
|
||||
};
|
||||
|
||||
const parseCount = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.trunc(value));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const combineCounts = (base: number | null, incoming: number | null): number | null => {
|
||||
if (base === null) return incoming;
|
||||
if (incoming === null) return base;
|
||||
return base + incoming;
|
||||
};
|
||||
|
||||
const getApplyPatchFileEntries = (metadata: Record<string, unknown> | undefined): ApplyPatchFileEntry[] => {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
const entriesByPath = new Map<string, ApplyPatchFileEntry>();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file || typeof file !== 'object') continue;
|
||||
const fileRecord = file as Record<string, unknown>;
|
||||
const displayPath = typeof fileRecord.relativePath === 'string'
|
||||
? fileRecord.relativePath
|
||||
: typeof fileRecord.filePath === 'string'
|
||||
? fileRecord.filePath
|
||||
: '';
|
||||
if (!displayPath) continue;
|
||||
|
||||
const added = parseCount(fileRecord.additions);
|
||||
const removed = parseCount(fileRecord.deletions);
|
||||
const existing = entriesByPath.get(displayPath);
|
||||
if (existing) {
|
||||
existing.added = combineCounts(existing.added, added);
|
||||
existing.removed = combineCounts(existing.removed, removed);
|
||||
continue;
|
||||
}
|
||||
|
||||
entriesByPath.set(displayPath, {
|
||||
file: fileRecord,
|
||||
path: displayPath,
|
||||
name: displayPath.split('/').pop() || displayPath,
|
||||
added,
|
||||
removed,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(entriesByPath.values());
|
||||
};
|
||||
|
||||
export const ApplyPatchFileButtons = ({
|
||||
animate = true,
|
||||
metadata,
|
||||
onFileClick,
|
||||
openDiffLabel,
|
||||
showFileIcons = true,
|
||||
textClassName,
|
||||
}: {
|
||||
animate?: boolean;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
onFileClick?: (file: Record<string, unknown>, event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
openDiffLabel: string;
|
||||
showFileIcons?: boolean;
|
||||
textClassName?: string;
|
||||
}): React.ReactNode => {
|
||||
const entries = getApplyPatchFileEntries(metadata);
|
||||
if (entries.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => {
|
||||
const hasPerFileDiff = entry.added !== null || entry.removed !== null;
|
||||
const content = (
|
||||
<>
|
||||
{showFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
|
||||
<Text
|
||||
variant={animate ? 'generate-effect' : 'static'}
|
||||
className={cn('min-w-0 max-w-full truncate', textClassName)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.name}
|
||||
</Text>
|
||||
{hasPerFileDiff ? (
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{entry.added ?? 0}</span>
|
||||
<span style={{ color: 'var(--tools-description)' }}>/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{entry.removed ?? 0}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const canOpen = onFileClick && entry.file.type !== 'delete' && getApplyPatchFilePath(entry.file);
|
||||
const actionLabel = `${openDiffLabel}: ${entry.path}`;
|
||||
return canOpen ? (
|
||||
<Button
|
||||
key={entry.path}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn('min-w-0 max-w-full gap-1 normal-case font-normal tracking-normal', textClassName)}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
onClick={(event) => onFileClick(entry.file, event)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
) : (
|
||||
<span key={entry.path} className={cn('inline-flex min-w-0 max-w-full items-center gap-1', textClassName)} style={{ color: 'var(--tools-description)' }}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,12 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
- Controls expandable header title/description/diff stats/timer and expanded output body.
|
||||
- If you want to change expandable tool layout, edit here.
|
||||
|
||||
- `taskToolModel.ts`
|
||||
- Owns Task metadata parsing and child-session summary projection.
|
||||
- `part.state.metadata.sessionId` is the only live identity contract between a Task and its child session.
|
||||
- A running Task may briefly have no `sessionId`; render it as waiting until the authoritative part update arrives. Never match parallel children by order, title, timestamp, or status.
|
||||
- Part-level metadata and output parsing exist only for older persisted records and never override state metadata.
|
||||
|
||||
- `toolPresentation.tsx`
|
||||
- Shared icon mapping for tool names (`getToolIcon`).
|
||||
- Used by both `ProgressiveGroup.tsx` and `ToolPart.tsx`.
|
||||
@@ -45,26 +51,32 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
|
||||
## Current important behavior
|
||||
|
||||
- `read` and most search/fetch tools are treated as **static tools** and usually render via `StaticToolRow`.
|
||||
- `bash/edit/write/question/task` are **expandable tools** and render via `ToolPart`.
|
||||
- `perplexity` is currently treated as static and grouped into search/web-search style rows (through static grouping + short description extraction).
|
||||
- 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.
|
||||
- `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 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)
|
||||
|
||||
If task is: "change text shown near Perplexity tool header/description":
|
||||
If task is: "change text shown near Read or Skill in compact mode":
|
||||
|
||||
1. Edit `ProgressiveGroup.tsx` -> `getToolShortDescription(activity)`.
|
||||
2. Update the branch that handles web-search tools (`websearch`, `web-search`, `search_web`, `codesearch`, `perplexity`, etc.).
|
||||
3. If needed, update group rendering in `StaticToolRow` (search/fetch specific rendering branches).
|
||||
2. Update the branch that handles `read` or `skill` in `StaticToolRow`.
|
||||
3. Keep all other tool header/output behavior in `ToolPart.tsx`.
|
||||
4. Keep icon changes (if any) in `toolPresentation.tsx`.
|
||||
|
||||
Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the primary path.
|
||||
Why: only navigation tools use the compact static path; all other tools need observable input and output.
|
||||
|
||||
## "I want tool to become expandable" (example)
|
||||
|
||||
1. Update `toolRenderUtils.ts`:
|
||||
- add/remove tool name in `EXPANDABLE_TOOL_NAMES`
|
||||
- add/remove a tool name from `STATIC_TOOL_NAMES` only when it has a reliable direct in-app navigation action
|
||||
2. Ensure `ToolPart.tsx` supports desired header + expanded output format for that tool.
|
||||
3. Validate both modes (`sorted` and `live`).
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { JsonSummaryView } from './JsonSummaryView';
|
||||
|
||||
describe('JsonSummaryView', () => {
|
||||
test('prioritizes a record identity and makes URLs navigable', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<JsonSummaryView
|
||||
data={{
|
||||
id: 'OPE-266',
|
||||
title: 'Refresh git status',
|
||||
url: 'https://linear.app/openchamber/issue/OPE-266',
|
||||
relations: { blocks: [] },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('OPE-266 · Refresh git status');
|
||||
expect(html).toContain('href="https://linear.app/openchamber/issue/OPE-266"');
|
||||
expect(html).toContain('Relations');
|
||||
expect(html).not.toContain('surface-elevated');
|
||||
});
|
||||
|
||||
test('summarizes record arrays as expandable sections', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<JsonSummaryView data={{ issues: [{ identifier: 'OPE-1', name: 'Example issue' }] }} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('Issues (1)');
|
||||
expect(html).toContain('OPE-1 · Example issue');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const IDENTITY_KEYS = new Set(['id', 'identifier', 'title', 'name']);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
);
|
||||
|
||||
const formatKey = (key: string) => key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/^./, (character) => character.toUpperCase());
|
||||
|
||||
const getIdentity = (record: Record<string, unknown>): string | null => {
|
||||
const id = typeof record.id === 'string' ? record.id : typeof record.identifier === 'string' ? record.identifier : '';
|
||||
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
|
||||
if (id && title) return `${id} · ${title}`;
|
||||
return title || id || null;
|
||||
};
|
||||
|
||||
const isUrl = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const JsonSummaryValue = React.memo(({
|
||||
value,
|
||||
label,
|
||||
depth,
|
||||
}: {
|
||||
value: unknown;
|
||||
label?: string;
|
||||
depth: number;
|
||||
}) => {
|
||||
if (Array.isArray(value)) {
|
||||
const summary = label ? `${formatKey(label)} (${value.length})` : `(${value.length})`;
|
||||
return (
|
||||
<details open={depth < 2} className="group/json-summary">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1.5 typography-meta text-[var(--surface-foreground)] hover:text-[var(--surface-mutedForeground)]">
|
||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform group-open/json-summary:rotate-90" />
|
||||
<span className="min-w-0 truncate font-medium">{summary}</span>
|
||||
</summary>
|
||||
<div className="relative ml-1 pl-3 pb-1">
|
||||
<span aria-hidden="true" className="pointer-events-none absolute bottom-1 left-0 top-0 w-px bg-[var(--tools-border)]" />
|
||||
<div className="space-y-1">
|
||||
{value.map((item, index) => <JsonSummaryValue key={index} value={item} depth={depth + 1} />)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const identity = getIdentity(value);
|
||||
const entries = Object.entries(value).filter(([key]) => !IDENTITY_KEYS.has(key));
|
||||
const summary = label ? `${formatKey(label)}${identity ? ` · ${identity}` : ''}` : identity;
|
||||
const content = (
|
||||
<div className="space-y-1">
|
||||
{entries.map(([key, entry]) => <JsonSummaryValue key={key} label={key} value={entry} depth={depth + 1} />)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!label && depth === 0) {
|
||||
return <div className="space-y-2">{identity ? <div className="typography-meta font-medium text-[var(--surface-foreground)]">{identity}</div> : null}{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<details open={depth < 2} className="group/json-summary">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1.5 typography-meta text-[var(--surface-foreground)] hover:text-[var(--surface-mutedForeground)]">
|
||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform group-open/json-summary:rotate-90" />
|
||||
<span className="min-w-0 truncate font-medium">{summary ?? (label ? formatKey(label) : '{}')}</span>
|
||||
</summary>
|
||||
<div className="relative ml-1 pl-3 pb-1">
|
||||
<span aria-hidden="true" className="pointer-events-none absolute bottom-1 left-0 top-0 w-px bg-[var(--tools-border)]" />
|
||||
{content}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
const text = value === null ? 'null' : typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
|
||||
const renderedValue = typeof value === 'string' && isUrl(value) ? (
|
||||
<a href={value} target="_blank" rel="noopener noreferrer" className="truncate text-[var(--status-info)] underline underline-offset-2 hover:opacity-80" title={value}>{value}</a>
|
||||
) : (
|
||||
<span className={cn('min-w-0 break-words', value === null ? 'text-[var(--surface-mutedForeground)]' : 'text-[var(--surface-foreground)]')}>{text}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(6rem,auto)_minmax(0,1fr)] gap-x-2 py-1 typography-meta">
|
||||
{label ? <span className="truncate text-[var(--surface-mutedForeground)]" title={label}>{formatKey(label)}</span> : <span />}
|
||||
{renderedValue}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
JsonSummaryValue.displayName = 'JsonSummaryValue';
|
||||
|
||||
export const JsonSummaryView = React.memo(({ data }: { data: unknown }) => (
|
||||
<div className="space-y-1">
|
||||
<JsonSummaryValue value={data} depth={0} />
|
||||
</div>
|
||||
));
|
||||
|
||||
JsonSummaryView.displayName = 'JsonSummaryView';
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MigratingPartProps {
|
||||
|
||||
isMigrating: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MigratingPart: React.FC<MigratingPartProps> = ({
|
||||
isMigrating,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
'w-full overflow-hidden',
|
||||
isMigrating && 'pointer-events-none',
|
||||
className
|
||||
)}
|
||||
style={isMigrating ? { animation: 'oc-migrate-up 220ms ease-out forwards' } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MigratingPart);
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
||||
@@ -573,6 +574,7 @@ const StaticToolRowInner: React.FC<{
|
||||
const icon = getToolIcon(toolName);
|
||||
const isReadGroup = toolName.toLowerCase() === 'read';
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const mobileActions = useMobileAppActions();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const skills = useSkillsStore((state) => state.skills);
|
||||
const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]);
|
||||
@@ -623,7 +625,7 @@ const StaticToolRowInner: React.FC<{
|
||||
return entries;
|
||||
}, [activities, currentDirectory, isReadGroup]);
|
||||
|
||||
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
|
||||
const handleFileClick = React.useCallback((filePath: string, offset?: number) => {
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||
if (!absolutePath) {
|
||||
return;
|
||||
@@ -634,6 +636,21 @@ const StaticToolRowInner: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedicated mobile app: stage the same pending file focus/navigation
|
||||
// desktop uses, then surface the Files pane (workspace drawer tab),
|
||||
// which consumes it. Desktop grant flows don't apply here.
|
||||
if (mobileActions) {
|
||||
const uiStore = useUIStore.getState();
|
||||
const contextDirectory = currentDirectory || getDirectoryForFilePath(currentDirectory, absolutePath);
|
||||
if (offset && Number.isFinite(offset)) {
|
||||
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
|
||||
} else {
|
||||
uiStore.openContextFile(contextDirectory, absolutePath);
|
||||
}
|
||||
mobileActions.openFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFilePathWithinDirectory(absolutePath, currentDirectory)) {
|
||||
void ensureOutsideFileGrantForDesktop(absolutePath, currentDirectory).then(() => {
|
||||
const uiStore = useUIStore.getState();
|
||||
@@ -654,15 +671,7 @@ const StaticToolRowInner: React.FC<{
|
||||
return;
|
||||
}
|
||||
uiStore.openContextFile(contextDirectory, absolutePath);
|
||||
}, [currentDirectory, runtime]);
|
||||
|
||||
const handleSkillClick = React.useCallback((skillPath: string) => {
|
||||
if (!skillPath) {
|
||||
return;
|
||||
}
|
||||
const uiStore = useUIStore.getState();
|
||||
uiStore.openContextFile(currentDirectory || getDirectoryForFilePath('', skillPath), skillPath);
|
||||
}, [currentDirectory]);
|
||||
}, [currentDirectory, mobileActions, runtime]);
|
||||
|
||||
const normalizedToolName = toolName.toLowerCase();
|
||||
const isSearchGroup = normalizedToolName === 'grep'
|
||||
@@ -675,8 +684,11 @@ const StaticToolRowInner: React.FC<{
|
||||
|
||||
return (
|
||||
<div
|
||||
// oc-static-tool-row: on touch devices mobile.css raises this to the
|
||||
// same 36px floor the [role="button"] expandable/reasoning rows get,
|
||||
// so static and expandable rows have identical rhythm.
|
||||
className={cn(
|
||||
'flex w-full items-center gap-x-1.5 pr-2 pl-px py-1.5 rounded-xl min-w-0'
|
||||
'oc-static-tool-row flex w-full items-center gap-x-1.5 pr-2 pl-px py-1.5 rounded-xl min-w-0'
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-icon)' }}>
|
||||
@@ -699,7 +711,7 @@ const StaticToolRowInner: React.FC<{
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleReadFileClick(entry.path, entry.offset);
|
||||
handleFileClick(entry.path, entry.offset);
|
||||
}}
|
||||
className={cn('inline-flex !min-h-0 items-center justify-start gap-1 min-w-0 flex-1 text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
@@ -751,7 +763,7 @@ const StaticToolRowInner: React.FC<{
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleSkillClick(entry.path);
|
||||
handleFileClick(entry.path);
|
||||
}}
|
||||
className={cn('!min-h-0 min-w-0 flex-1 truncate whitespace-nowrap text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
|
||||
@@ -96,4 +96,20 @@ describe('ReasoningTimelineBlock', () => {
|
||||
// The ellipsis character marks that the text was truncated
|
||||
expect(markup).toContain('…');
|
||||
});
|
||||
|
||||
test('omits trailing empty HTML comments from the header summary', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<ReasoningTimelineBlock
|
||||
text={'Planning accessible icon labels with translations <!-- -->'}
|
||||
variant="thinking"
|
||||
blockId="reasoning-comment-test"
|
||||
showDuration={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('Planning accessible icon labels with translations');
|
||||
expect(markup).not.toContain('<!-- -->');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS);
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
export type ReasoningVariant = 'thinking' | 'justification';
|
||||
type ReasoningVariant = 'thinking' | 'justification';
|
||||
|
||||
const cleanReasoningText = (text: string): string => {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
@@ -40,6 +40,8 @@ const EXPANDED_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeOut' as const };
|
||||
/** Strip common markdown syntax so the header preview reads as plain text. */
|
||||
const stripMarkdown = (text: string): string =>
|
||||
text
|
||||
// Empty HTML comments are frequently appended by model tool wrappers.
|
||||
.replace(/<!--\s*-->/g, '')
|
||||
// Fenced code blocks → keep inner text on one line
|
||||
.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, inner: string) => inner.trim())
|
||||
// Inline code
|
||||
@@ -118,10 +120,14 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
: expansion.expanded;
|
||||
const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand);
|
||||
const contentId = React.useId();
|
||||
const scrollRef = React.useRef<HTMLElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
|
||||
const contentMountedRef = React.useRef(false);
|
||||
// Stable handle to onContentChange so the height-animation layout effect can
|
||||
// signal auto-follow without taking onContentChange as a dependency (which
|
||||
// would risk re-running — and thus restarting — the animation on re-render).
|
||||
const onContentChangeRef = React.useRef(onContentChange);
|
||||
onContentChangeRef.current = onContentChange;
|
||||
|
||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||
const toggleAriaLabel = isExpanded
|
||||
@@ -160,12 +166,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [text, isStreaming, isExpanded]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isExpanded || isStreaming) {
|
||||
setShouldRenderExpandedContent(true);
|
||||
@@ -239,6 +239,11 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
element.style.height = '0px';
|
||||
} else {
|
||||
element.style.height = `${element.scrollHeight}px`;
|
||||
// Only the COLLAPSE animation needs the guard: it shrinks the
|
||||
// timeline and the trailing async scroll events can be misread as a
|
||||
// user scroll-away. Expansion grows the timeline and re-pins cleanly,
|
||||
// and guarding it caused a faint scroll fight while thinking streams.
|
||||
onContentChangeRef.current?.('animation');
|
||||
}
|
||||
|
||||
const animation = animate(
|
||||
@@ -280,6 +285,27 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningBody = (
|
||||
<>
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
messageId={blockId}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
variant="reasoning"
|
||||
/>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-reasoning-block-id={blockId} data-message-text-export-root="true">
|
||||
<div
|
||||
@@ -379,32 +405,28 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
className="pointer-events-none absolute left-0 top-0 bottom-0 w-px"
|
||||
style={{ backgroundColor: 'var(--tools-border)' }}
|
||||
/>
|
||||
<ScrollableOverlay
|
||||
ref={scrollRef}
|
||||
as="div"
|
||||
outerClassName="max-h-80"
|
||||
className="p-0"
|
||||
useScrollShadow
|
||||
scrollShadowSize={36}
|
||||
userIntentOnly
|
||||
>
|
||||
<div data-message-text-export-source="true">
|
||||
<MarkdownRenderer
|
||||
content={text}
|
||||
messageId={blockId}
|
||||
isAnimated={false}
|
||||
isStreaming={isStreaming}
|
||||
variant="reasoning"
|
||||
/>
|
||||
{isStreaming ? (
|
||||
// While streaming, let the thinking grow inline — no
|
||||
// capped, independently-scrollable box. The chat's own
|
||||
// auto-follow then handles following / releasing, so the
|
||||
// box never captures the wheel or fights the user's
|
||||
// scroll. The max-height scroll box is applied only once
|
||||
// the thinking has finished (the branch below).
|
||||
<div className="p-0">
|
||||
{reasoningBody}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5" data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollableOverlay>
|
||||
) : (
|
||||
<ScrollableOverlay
|
||||
as="div"
|
||||
outerClassName="max-h-80"
|
||||
className="p-0"
|
||||
useScrollShadow
|
||||
scrollShadowSize={36}
|
||||
userIntentOnly
|
||||
>
|
||||
{reasoningBody}
|
||||
</ScrollableOverlay>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -456,87 +478,4 @@ const ReasoningPart = React.memo(({
|
||||
);
|
||||
});
|
||||
|
||||
type MergedReasoningPartProps = {
|
||||
parts: Part[];
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
messageId: string;
|
||||
streamPhase?: StreamPhase;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders ALL reasoning parts for a message as a single collapsible block,
|
||||
* merging their text and spanning their combined time range.
|
||||
* This matches the VSCode Copilot pattern of showing one "Thought" block per turn.
|
||||
*/
|
||||
export const MergedReasoningPart = React.memo(({
|
||||
parts,
|
||||
onContentChange,
|
||||
messageId,
|
||||
streamPhase,
|
||||
}: MergedReasoningPartProps) => {
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
|
||||
const mergedText = React.useMemo(() => {
|
||||
return parts
|
||||
.map((part) => {
|
||||
const p = part as PartWithText;
|
||||
return cleanReasoningText(p.text || p.content || '');
|
||||
})
|
||||
.filter((t) => t.length > 0)
|
||||
.join('\n\n');
|
||||
}, [parts]);
|
||||
|
||||
const mergedTime = React.useMemo(() => {
|
||||
let earliestStart: number | undefined;
|
||||
let latestEnd: number | undefined;
|
||||
|
||||
for (const part of parts) {
|
||||
const time = (part as PartWithText).time;
|
||||
if (typeof time?.start === 'number' && Number.isFinite(time.start)) {
|
||||
if (earliestStart === undefined || time.start < earliestStart) {
|
||||
earliestStart = time.start;
|
||||
}
|
||||
}
|
||||
if (typeof time?.end === 'number' && Number.isFinite(time.end)) {
|
||||
if (latestEnd === undefined || time.end > latestEnd) {
|
||||
latestEnd = time.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined;
|
||||
}, [parts]);
|
||||
|
||||
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
|
||||
const isStreaming = chatRenderMode === 'live' && canBeStreaming && parts.some(
|
||||
(part) => typeof (part as PartWithText).time?.end !== 'number',
|
||||
);
|
||||
|
||||
const throttledMergedText = useStreamingTextThrottle({
|
||||
text: mergedText,
|
||||
isStreaming,
|
||||
identityKey: `${messageId}:reasoning-merged`,
|
||||
});
|
||||
|
||||
const blockId = parts[0]?.id ?? `${messageId}-reasoning-merged`;
|
||||
|
||||
if (!throttledMergedText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningTimelineBlock
|
||||
text={throttledMergedText}
|
||||
variant="thinking"
|
||||
onContentChange={onContentChange}
|
||||
blockId={blockId}
|
||||
time={mergedTime}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
||||
|
||||
export default ReasoningPart;
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 5x5 grid letter patterns (indices 0-24).
|
||||
* Grid layout:
|
||||
* 0 1 2 3 4
|
||||
* 5 6 7 8 9
|
||||
* 10 11 12 13 14
|
||||
* 15 16 17 18 19
|
||||
* 20 21 22 23 24
|
||||
*
|
||||
* Each letter is represented as an array of "on" cell indices.
|
||||
*/
|
||||
const LETTER_PATTERNS: Record<string, readonly number[]> = {
|
||||
// 0 1 2 3 4
|
||||
// 5 6 7 8 9
|
||||
// 10 11 12 13 14
|
||||
// 15 16 17 18 19
|
||||
// 20 21 22 23 24
|
||||
A: [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
|
||||
B: [0, 1, 2, 3, 5, 9, 10, 11, 12, 13, 15, 19, 20, 21, 22, 23],
|
||||
C: [1, 2, 3, 5, 10, 15, 21, 22, 23],
|
||||
D: [0, 1, 2, 3, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23],
|
||||
E: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20, 21, 22, 23],
|
||||
F: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20],
|
||||
G: [1, 2, 3, 5, 10, 12, 13, 15, 18, 19, 21, 22, 23],
|
||||
H: [0, 4, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
|
||||
I: [1, 2, 3, 7, 12, 17, 21, 22, 23],
|
||||
J: [1, 2, 3, 8, 13, 15, 18, 21, 22],
|
||||
K: [0, 3, 5, 7, 10, 11, 15, 17, 20, 23],
|
||||
L: [0, 5, 10, 15, 20, 21, 22, 23],
|
||||
M: [0, 4, 5, 6, 8, 9, 10, 12, 14, 15, 19, 20, 24],
|
||||
N: [0, 4, 5, 6, 9, 10, 12, 14, 15, 18, 19, 20, 24],
|
||||
O: [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
P: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 20],
|
||||
Q: [1, 2, 3, 5, 9, 10, 14, 15, 18, 19, 21, 22, 24],
|
||||
R: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 17, 20, 23],
|
||||
S: [1, 2, 3, 5, 11, 12, 13, 19, 21, 22, 23],
|
||||
T: [0, 1, 2, 3, 4, 7, 12, 17, 22],
|
||||
U: [0, 4, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
V: [0, 4, 5, 9, 10, 14, 16, 18, 22],
|
||||
W: [0, 4, 5, 9, 10, 12, 14, 15, 16, 18, 19, 21, 23],
|
||||
X: [0, 4, 6, 8, 12, 16, 18, 20, 24],
|
||||
Y: [0, 4, 6, 8, 12, 17, 22],
|
||||
Z: [0, 1, 2, 3, 4, 8, 12, 16, 20, 21, 22, 23, 24],
|
||||
'0': [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
|
||||
'1': [2, 6, 7, 12, 17, 20, 21, 22, 23, 24],
|
||||
'2': [1, 2, 3, 9, 11, 12, 13, 16, 20, 21, 22, 23, 24],
|
||||
'3': [0, 1, 2, 3, 9, 11, 12, 13, 19, 20, 21, 22, 23],
|
||||
'4': [0, 4, 5, 9, 10, 11, 12, 13, 14, 19, 24],
|
||||
'5': [0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 19, 20, 21, 22, 23],
|
||||
'6': [1, 2, 3, 5, 10, 11, 12, 13, 15, 19, 21, 22, 23],
|
||||
'7': [0, 1, 2, 3, 4, 9, 13, 17, 22],
|
||||
'8': [1, 2, 3, 5, 9, 11, 12, 13, 15, 19, 21, 22, 23],
|
||||
'9': [1, 2, 3, 5, 9, 11, 12, 13, 19, 21, 22, 23],
|
||||
' ': [],
|
||||
};
|
||||
|
||||
// Build Set versions for O(1) lookups
|
||||
const LETTER_SETS: Record<string, Set<number>> = {};
|
||||
for (const [key, indices] of Object.entries(LETTER_PATTERNS)) {
|
||||
LETTER_SETS[key] = new Set(indices);
|
||||
}
|
||||
|
||||
/** Duration each letter is displayed (ms) */
|
||||
const LETTER_DURATION_MS = 800;
|
||||
/** Crossfade transition duration (ms) */
|
||||
const TRANSITION_MS = 500;
|
||||
/** Pause between full cycles (ms) */
|
||||
const CYCLE_PAUSE_MS = 1000;
|
||||
|
||||
/** Spacing between dot centers in SVG units */
|
||||
const DOT_SPACING = 4;
|
||||
/** Dot radius */
|
||||
const DOT_RADIUS = 1.2;
|
||||
|
||||
/**
|
||||
* Octagonal grid layout (7 rows):
|
||||
*
|
||||
* • • • row 0: 3 dots (cols 2-4)
|
||||
* • • • • • row 1: 5 dots (cols 1-5) → letter row 0
|
||||
* • • • • • • • row 2: 7 dots (cols 0-6) → letter row 1
|
||||
* • • • • • • • row 3: 7 dots (cols 0-6) → letter row 2
|
||||
* • • • • • • • row 4: 7 dots (cols 0-6) → letter row 3
|
||||
* • • • • • row 5: 5 dots (cols 1-5) → letter row 4
|
||||
* • • • row 6: 3 dots (cols 2-4)
|
||||
*
|
||||
* Letter indices (0-24) map to the inner 5x5 zone:
|
||||
* rows 1-5, cols 1-5
|
||||
*/
|
||||
const OCTAGON_ROWS: { row: number; cols: number[] }[] = [
|
||||
{ row: 0, cols: [2, 3, 4] },
|
||||
{ row: 1, cols: [1, 2, 3, 4, 5] },
|
||||
{ row: 2, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 3, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 4, cols: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ row: 5, cols: [1, 2, 3, 4, 5] },
|
||||
{ row: 6, cols: [2, 3, 4] },
|
||||
];
|
||||
|
||||
interface OctCell {
|
||||
id: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
/** Index into the 5x5 letter grid (0-24), or -1 for border-only dots */
|
||||
letterIndex: number;
|
||||
// Stable random timing
|
||||
shimmerDuration: number;
|
||||
shimmerDelay: number;
|
||||
idleDuration: number;
|
||||
idleDelay: number;
|
||||
}
|
||||
|
||||
const CELLS: OctCell[] = [];
|
||||
let cellId = 0;
|
||||
for (const { row, cols } of OCTAGON_ROWS) {
|
||||
for (const col of cols) {
|
||||
const cx = col * DOT_SPACING;
|
||||
const cy = row * DOT_SPACING;
|
||||
|
||||
// Letter zone: rows 1-5 (octagon), cols 1-5 (octagon)
|
||||
// maps to 5x5 letter index
|
||||
let letterIndex = -1;
|
||||
const letterRow = row - 1;
|
||||
const letterCol = col - 1;
|
||||
if (letterRow >= 0 && letterRow < 5 && letterCol >= 0 && letterCol < 5) {
|
||||
letterIndex = letterRow * 5 + letterCol;
|
||||
}
|
||||
|
||||
CELLS.push({
|
||||
id: cellId++,
|
||||
cx,
|
||||
cy,
|
||||
letterIndex,
|
||||
shimmerDuration: 3 + Math.random() * 3,
|
||||
shimmerDelay: Math.random() * 3,
|
||||
idleDuration: 1 + Math.random(),
|
||||
idleDelay: Math.random() * 1.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const VIEW_SIZE = 6 * DOT_SPACING + DOT_RADIUS * 2;
|
||||
const VIEW_OFFSET = -DOT_RADIUS;
|
||||
|
||||
interface SessionActiveSpinnerProps {
|
||||
className?: string;
|
||||
/** Text to spell out letter by letter. Falls back to idle pulse when empty/undefined. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle mode: random pulsing octagonal dot grid.
|
||||
* Text mode: cycles through characters of `text`, morphing between letter shapes.
|
||||
*/
|
||||
export function SessionActiveSpinner({ className, text }: SessionActiveSpinnerProps) {
|
||||
const normalizedText = text?.toUpperCase().replace(/[^A-Z0-9 ]/g, '') || '';
|
||||
const hasText = normalizedText.length > 0;
|
||||
|
||||
const [charIndex, setCharIndex] = React.useState(0);
|
||||
const [phase, setPhase] = React.useState<'hold' | 'morph'>('hold');
|
||||
|
||||
// Intro fade: foreground starts invisible and fades in
|
||||
const [introReady, setIntroReady] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setIntroReady(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
// Reset on text change
|
||||
React.useEffect(() => {
|
||||
setCharIndex(0);
|
||||
setPhase('hold');
|
||||
}, [normalizedText]);
|
||||
|
||||
// Letter cycling timer
|
||||
React.useEffect(() => {
|
||||
if (!hasText) return;
|
||||
|
||||
const total = normalizedText.length;
|
||||
|
||||
if (phase === 'hold') {
|
||||
const isLastChar = charIndex === total - 1;
|
||||
const delay = LETTER_DURATION_MS + (isLastChar ? CYCLE_PAUSE_MS : 0);
|
||||
const timer = setTimeout(() => setPhase('morph'), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCharIndex((prev) => (prev + 1) % total);
|
||||
setPhase('hold');
|
||||
}, TRANSITION_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hasText, charIndex, normalizedText, phase]);
|
||||
|
||||
// Compute current and next letter sets for morphing
|
||||
const total = normalizedText.length;
|
||||
const currentSet = hasText
|
||||
? (LETTER_SETS[normalizedText[charIndex]] ?? LETTER_SETS[' '])
|
||||
: null;
|
||||
const nextIndex = hasText ? (charIndex + 1) % total : 0;
|
||||
const nextSet = hasText
|
||||
? (LETTER_SETS[normalizedText[nextIndex]] ?? LETTER_SETS[' '])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`${VIEW_OFFSET} ${VIEW_OFFSET} ${VIEW_SIZE} ${VIEW_SIZE}`}
|
||||
data-component="session-active-spinner"
|
||||
className={className}
|
||||
fill="var(--foreground)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Background layer: all dots with shimmer animation */}
|
||||
{CELLS.map((cell) => (
|
||||
<circle
|
||||
key={cell.id}
|
||||
cx={cell.cx}
|
||||
cy={cell.cy}
|
||||
r={DOT_RADIUS}
|
||||
style={{
|
||||
animation: `${currentSet ? 'pulse-opacity-dim' : 'pulse-opacity'} ${currentSet ? cell.shimmerDuration : cell.idleDuration}s ease-in-out infinite`,
|
||||
animationDelay: `${currentSet ? cell.shimmerDelay : cell.idleDelay}s`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Foreground layer: morphing letter dots (only on letter-zone cells) */}
|
||||
<g fill="var(--primary)">
|
||||
{currentSet && nextSet && CELLS.map((cell) => {
|
||||
if (cell.letterIndex < 0) return null;
|
||||
|
||||
const inCurrent = currentSet.has(cell.letterIndex);
|
||||
const inNext = nextSet.has(cell.letterIndex);
|
||||
|
||||
if (!inCurrent && !inNext) return null;
|
||||
|
||||
let opacity: number;
|
||||
if (!introReady) {
|
||||
opacity = 0;
|
||||
} else if (phase === 'hold') {
|
||||
opacity = inCurrent ? 1 : 0;
|
||||
} else {
|
||||
if (inCurrent && inNext) {
|
||||
opacity = 1;
|
||||
} else if (inCurrent) {
|
||||
opacity = 0;
|
||||
} else {
|
||||
opacity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={`fg-${cell.id}`}
|
||||
cx={cell.cx}
|
||||
cy={cell.cy}
|
||||
r={DOT_RADIUS}
|
||||
style={{
|
||||
opacity,
|
||||
transition: `opacity ${TRANSITION_MS}ms ease-in-out`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,48 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
import { tryParseJsonOutput } from '../toolRenderers';
|
||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||
import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||
|
||||
describe('getToolOutput', () => {
|
||||
test('prefers authoritative state output', () => {
|
||||
expect(getToolOutput('bash', 'final output', 'streamed output')).toBe('final output');
|
||||
expect(getToolOutput('bash', '', 'streamed output')).toBe('');
|
||||
});
|
||||
|
||||
test('falls back to streamed metadata output for bash', () => {
|
||||
expect(getToolOutput('bash', undefined, 'streamed output')).toBe('streamed output');
|
||||
expect(getToolOutput('bash', undefined, '')).toBe(undefined);
|
||||
});
|
||||
|
||||
test('does not expose metadata output for other tools', () => {
|
||||
expect(getToolOutput('read', undefined, 'metadata output')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStreamingOutputAppend', () => {
|
||||
test('returns only newly appended output', () => {
|
||||
expect(getStreamingOutputAppend('first\n', 'first\nsecond\n')).toBe('second\n');
|
||||
});
|
||||
|
||||
test('requires replacement when output is rewritten or shortened', () => {
|
||||
expect(getStreamingOutputAppend('progress 10%', 'progress 20%')).toBe(undefined);
|
||||
expect(getStreamingOutputAppend('long output', 'short')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming output transitions', () => {
|
||||
test('allows bash snapshots to be rewritten or shortened while running', () => {
|
||||
expect(getStreamingThrottleText('progress 10%', 'progress 20%', true, true)).toBe('progress 20%');
|
||||
expect(getStreamingThrottleText('long output', 'short', true, true)).toBe('short');
|
||||
});
|
||||
|
||||
test('preserves monotonic streaming text by default', () => {
|
||||
expect(getStreamingThrottleText('long output', 'short', true, false)).toBe('long output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTaskTagSessionIdFromOutput', () => {
|
||||
test('parses task tags without state attributes', () => {
|
||||
@@ -11,3 +53,27 @@ describe('readTaskTagSessionIdFromOutput', () => {
|
||||
expect(readTaskTagSessionIdFromOutput('<task id="ses_def456" state="completed">')).toBe('ses_def456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenChamber tool output', () => {
|
||||
test('keeps the result envelope in the generic JSON rendering pipeline', () => {
|
||||
const result = {
|
||||
schemaVersion: 1,
|
||||
ok: true,
|
||||
action: 'projects.list',
|
||||
data: { projects: [] },
|
||||
};
|
||||
expect(tryParseJsonOutput(JSON.stringify(result))).toEqual({ data: result, isJson: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolDescriptionFallback', () => {
|
||||
test('uses the glob pattern when the provided description and title are empty', () => {
|
||||
expect(getToolDescriptionFallback('glob', '', { pattern: 'packages/electron/README.md' }))
|
||||
.toBe('packages/electron/README.md');
|
||||
});
|
||||
|
||||
test('prefers an existing glob description over the pattern', () => {
|
||||
expect(getToolDescriptionFallback('glob', 'Electron docs', { pattern: 'packages/electron/README.md' }))
|
||||
.toBe('Electron docs');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
||||
parseSkillHref,
|
||||
} from '@/lib/messages/inlineMessageLinks';
|
||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -31,7 +32,9 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
const terminalContextState = React.useMemo(() => extractTerminalContexts(serializedText), [serializedText]);
|
||||
const textContent = terminalContextState.visibleText;
|
||||
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||
@@ -190,7 +193,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
});
|
||||
}, [agentMention, openSkill, skillByName, textContent]);
|
||||
|
||||
if (!textContent || textContent.trim().length === 0) {
|
||||
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -230,10 +233,13 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
"[&_[data-component='markdown-code']]:bg-transparent",
|
||||
"[&_[data-component='markdown-code']>*:first-child]:hidden",
|
||||
"[&_[data-component='markdown-code']>div]:inline",
|
||||
"[&_[data-component='markdown-code']>div]:p-0",
|
||||
"[&_[data-component='markdown-code']_pre]:inline",
|
||||
"[&_[data-component='markdown-code']_code]:inline",
|
||||
]
|
||||
"[&_[data-component='markdown-code']>div]:p-0",
|
||||
"[&_[data-component='markdown-code']_pre]:inline",
|
||||
"[&_[data-component='markdown-code']_code]:inline",
|
||||
"[&_[data-md-code-line]]:!inline",
|
||||
"[&_[data-md-code-line-number]]:hidden",
|
||||
"[&_[data-md-code-line-break]]:!inline",
|
||||
]
|
||||
)}
|
||||
disableLinkSafety
|
||||
enableFileReferences={false}
|
||||
@@ -242,6 +248,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
plainTextContent
|
||||
)}
|
||||
</div>
|
||||
{terminalContextState.contexts.length > 0 ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{terminalContextState.contexts.map((context, index) => (
|
||||
<details key={`${context.terminalLabel}-${context.startLine}-${index}`} className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-1.5 text-xs">
|
||||
<summary className="cursor-pointer text-[var(--surface-mutedForeground)]">
|
||||
{t('chat.message.terminalContext', { terminal: context.terminalLabel, start: context.startLine, end: context.endLine })}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[var(--surface-foreground)]">{context.text}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Renders large code/read outputs without mounting one highlighter per line:
|
||||
* 1. ONE worker tokenization of the whole block (off the main thread)
|
||||
* 2. virtua to only render visible rows
|
||||
* 2. @tanstack/react-virtual to only render visible rows
|
||||
*
|
||||
* Tokenizing the whole block at once also preserves cross-line syntax context
|
||||
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Virtualizer } from 'virtua';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
@@ -114,28 +114,41 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: lines.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 20,
|
||||
});
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="typography-code font-mono w-full min-w-0"
|
||||
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
<Virtualizer
|
||||
data={lines}
|
||||
itemSize={ROW_HEIGHT}
|
||||
bufferSize={ROW_HEIGHT * 20}
|
||||
scrollRef={parentRef}
|
||||
>
|
||||
{(line, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
line={line}
|
||||
html={highlighted?.[index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
)}
|
||||
</Virtualizer>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualItems.map((item) => {
|
||||
const line = lines[item.index];
|
||||
if (!line) return null;
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<Row
|
||||
line={line}
|
||||
html={highlighted?.[item.index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { BusyDots } from './BusyDots';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
interface WorkingPlaceholderProps {
|
||||
isWorking: boolean;
|
||||
@@ -8,6 +11,8 @@ interface WorkingPlaceholderProps {
|
||||
isWaitingForPermission?: boolean;
|
||||
retryInfo?: { attempt?: number; next?: number } | null;
|
||||
agentName?: string;
|
||||
modelName?: string | null;
|
||||
providerId?: string | null;
|
||||
}
|
||||
|
||||
const STATUS_DISPLAY_TIME_MS = 1200;
|
||||
@@ -58,7 +63,13 @@ export function WorkingPlaceholder({
|
||||
isGenericStatus,
|
||||
isWaitingForPermission,
|
||||
retryInfo,
|
||||
modelName,
|
||||
providerId,
|
||||
}: WorkingPlaceholderProps) {
|
||||
const { t } = useI18n();
|
||||
const { src: providerLogoSrc, onError: handleProviderLogoError, hasLogo: hasProviderLogo } = useProviderLogo(providerId ?? null);
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const isDarkTheme = currentTheme?.metadata.variant === 'dark';
|
||||
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
|
||||
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
|
||||
const displayedTextRef = React.useRef(displayedText);
|
||||
@@ -211,7 +222,10 @@ export function WorkingPlaceholder({
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
|
||||
const trimmedModelName = typeof modelName === 'string' ? modelName.trim() : '';
|
||||
const label = trimmedModelName.length > 0
|
||||
? t('chat.statusRow.modelStatus', { model: trimmedModelName, status: displayedText })
|
||||
: displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -224,6 +238,18 @@ export function WorkingPlaceholder({
|
||||
data-waiting={displayedPermission ? 'true' : undefined}
|
||||
>
|
||||
<span className="typography-ui-header">
|
||||
{hasProviderLogo && providerLogoSrc ? (
|
||||
<img
|
||||
src={providerLogoSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="inline-block h-3.5 w-3.5 mr-1.5 align-[-2px]"
|
||||
style={{
|
||||
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
|
||||
}}
|
||||
onError={handleProviderLogoError}
|
||||
/>
|
||||
) : null}
|
||||
{label}
|
||||
<BusyDots />
|
||||
</span>
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { coerceToText, renderTodoOutput } from '../../toolRenderers';
|
||||
|
||||
describe('coerceToText (issue #2011)', () => {
|
||||
test('returns strings unchanged', () => {
|
||||
expect(coerceToText('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
test('coerces plain objects to JSON strings', () => {
|
||||
// The exact shape that produced React error #31: object with {TODO} key
|
||||
const result = coerceToText({ TODO: 'Review the diff' });
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('TODO');
|
||||
expect(result).toContain('Review the diff');
|
||||
});
|
||||
|
||||
test('coerces nested objects to JSON strings', () => {
|
||||
const result = coerceToText({ todos: [{ TODO: 'a' }, { content: 'b' }] });
|
||||
expect(typeof result).toBe('string');
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toBeTruthy();
|
||||
});
|
||||
|
||||
test('coerces numbers and booleans', () => {
|
||||
expect(coerceToText(42)).toBe('42');
|
||||
expect(coerceToText(true)).toBe('true');
|
||||
expect(coerceToText(false)).toBe('false');
|
||||
});
|
||||
|
||||
test('returns fallback for null/undefined', () => {
|
||||
expect(coerceToText(null)).toBe('');
|
||||
expect(coerceToText(undefined)).toBe('');
|
||||
expect(coerceToText(null, 'oops')).toBe('oops');
|
||||
});
|
||||
|
||||
test('handles circular structures without throwing', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
obj.self = obj;
|
||||
// Must not throw, must not recurse forever
|
||||
const result = coerceToText(obj);
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTodoOutput (issue #2011)', () => {
|
||||
const labels = {
|
||||
total: 'Total',
|
||||
inProgress: 'In progress',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
test('returns null for invalid JSON', () => {
|
||||
expect(renderTodoOutput('not json', labels)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when parsed value is not an array', () => {
|
||||
expect(renderTodoOutput(JSON.stringify({ foo: 'bar' }), labels)).toBeNull();
|
||||
});
|
||||
|
||||
test('renders valid todo arrays', () => {
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: 'Do the thing', status: 'pending', priority: 'high' },
|
||||
]);
|
||||
const result = renderTodoOutput(output, labels);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('filters out todos with non-string content (the {TODO} object case)', () => {
|
||||
// The exact pathological shape from the issue: a todo where content
|
||||
// is an object instead of a string. Previously this triggered
|
||||
// React error #31 when rendered as {todo.content}.
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: { TODO: 'Review the diff' }, status: 'pending' },
|
||||
{ id: '2', content: 'Real string content', status: 'completed' },
|
||||
]);
|
||||
// Must not throw. Either returns valid React element (with bad row
|
||||
// filtered out) or null.
|
||||
const result = renderTodoOutput(output, labels);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when all todos have non-string content', () => {
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: { TODO: 'x' }, status: 'pending' },
|
||||
{ id: '2', content: { foo: 'bar' }, status: 'completed' },
|
||||
]);
|
||||
expect(renderTodoOutput(output, labels)).toBeNull();
|
||||
});
|
||||
|
||||
test('filters out todos with non-string status', () => {
|
||||
const output = JSON.stringify([
|
||||
{ id: '1', content: 'Valid', status: { broken: true } },
|
||||
{ id: '2', content: 'Valid', status: 'pending' },
|
||||
]);
|
||||
const result = renderTodoOutput(output, labels);
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { resolveFallbackTaskSessionId } from '../resolveFallbackTaskSessionId';
|
||||
|
||||
const busyStatus = { type: 'busy' };
|
||||
const retryStatus = { type: 'retry', attempt: 1, message: '', next: Date.now() + 5000 };
|
||||
|
||||
const makeSession = (overrides) => ({
|
||||
slug: overrides.id,
|
||||
projectID: 'proj',
|
||||
directory: '/test',
|
||||
title: overrides.title ?? `Session ${overrides.id}`,
|
||||
version: '1',
|
||||
time: {
|
||||
created: overrides.time?.created ?? Date.now(),
|
||||
updated: overrides.time?.updated ?? Date.now(),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('resolveFallbackTaskSessionId', () => {
|
||||
const parentSessionId = 'parent-session-1';
|
||||
const taskStartTime = 1000000;
|
||||
|
||||
it('returns undefined when not a task tool', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: false,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when multiple idle candidates are ambiguous', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [
|
||||
makeSession({ id: 'child-a', parentID: parentSessionId, time: { created: taskStartTime + 100 } }),
|
||||
makeSession({ id: 'child-b', parentID: parentSessionId, time: { created: taskStartTime + 200 } }),
|
||||
],
|
||||
sessionStatusMap: {},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when parentSessionId is missing', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId: undefined,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no sessions exist', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the child session id when exactly one child matches parent and time', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBe('child-1');
|
||||
});
|
||||
|
||||
it('returns undefined when child was created before task start', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime - 1, updated: taskStartTime - 1 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when child was created too long after task start', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 5000, updated: taskStartTime + 5000 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when multiple children match and are ambiguous', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the busy child when multiple children match but only one is busy', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
sessionStatusMap: {
|
||||
'child-2': busyStatus,
|
||||
},
|
||||
});
|
||||
expect(result).toBe('child-2');
|
||||
});
|
||||
|
||||
it('returns undefined when multiple children are both busy (ambiguous)', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
sessionStatusMap: {
|
||||
'child-1': busyStatus,
|
||||
'child-2': busyStatus,
|
||||
},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores sessions with different parentID', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: 'other-parent',
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores sessions without parentID', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers exactly one live candidate (retry status) over ambiguous total', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
sessionStatusMap: {
|
||||
'child-1': retryStatus,
|
||||
},
|
||||
});
|
||||
expect(result).toBe('child-1');
|
||||
});
|
||||
|
||||
it('returns undefined when taskStartTime is undefined', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: 100, updated: 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime: undefined,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { EditorAPI } from '@/lib/api/types';
|
||||
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
import { extractFirstChangedLineFromDiff, getApplyPatchFilePath, getPatchText } from './toolDiffUtils';
|
||||
|
||||
export const openApplyPatchFileInEditor = ({
|
||||
currentDirectory,
|
||||
diffLabel,
|
||||
editor,
|
||||
file,
|
||||
isVSCode,
|
||||
}: {
|
||||
currentDirectory: string;
|
||||
diffLabel: string;
|
||||
editor: EditorAPI;
|
||||
file: Record<string, unknown>;
|
||||
isVSCode: boolean;
|
||||
}): boolean => {
|
||||
const filePath = getApplyPatchFilePath(file);
|
||||
if (!filePath || file.type === 'delete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const patch = getPatchText(file.patch) ?? getPatchText(file.diff);
|
||||
const line = patch ? extractFirstChangedLineFromDiff(patch) : undefined;
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||
if (isVSCode && patch) {
|
||||
void editor.openDiff('', absolutePath, diffLabel, { line, patch });
|
||||
} else {
|
||||
void editor.openFile(absolutePath, line);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { parseGeneratedJsonResult } from './generatedJsonResult';
|
||||
|
||||
describe('parseGeneratedJsonResult', () => {
|
||||
test('parses a full pull request JSON result', () => {
|
||||
expect(parseGeneratedJsonResult('{"title":"Side task","body":"Details"}')).toEqual({
|
||||
kind: 'pr',
|
||||
title: 'Side task',
|
||||
body: 'Details',
|
||||
raw: JSON.stringify({ title: 'Side task', body: 'Details' }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test('parses a full fenced JSON result', () => {
|
||||
expect(parseGeneratedJsonResult('```json\n{"subject":"Fix parser","highlights":["Narrow detection"]}\n```')).toEqual({
|
||||
kind: 'commit',
|
||||
subject: 'Fix parser',
|
||||
highlights: ['Narrow detection'],
|
||||
raw: JSON.stringify({ subject: 'Fix parser', highlights: ['Narrow detection'] }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores JSON examples embedded in markdown prose', () => {
|
||||
const markdown = [
|
||||
'Recommended endpoint:',
|
||||
'',
|
||||
'```json',
|
||||
'{',
|
||||
' "title": "Side task",',
|
||||
' "prompt": "Investigate X"',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'This should stay markdown.',
|
||||
].join('\n');
|
||||
|
||||
expect(parseGeneratedJsonResult(markdown)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
export type GeneratedCommitResult = {
|
||||
type GeneratedCommitResult = {
|
||||
kind: 'commit';
|
||||
subject: string;
|
||||
highlights: string[];
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export type GeneratedPrResult = {
|
||||
type GeneratedPrResult = {
|
||||
kind: 'pr';
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -16,21 +16,15 @@ export type GeneratedResult = GeneratedCommitResult | GeneratedPrResult;
|
||||
|
||||
const parseJsonObjects = (value: string): Record<string, unknown>[] => {
|
||||
const text = value.trim();
|
||||
const candidates = new Set<string>();
|
||||
const candidates: string[] = [];
|
||||
|
||||
const fencedMatches = text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi);
|
||||
for (const match of fencedMatches) {
|
||||
if (match[1]) candidates.add(match[1].trim());
|
||||
const fencedMatch = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
|
||||
if (fencedMatch?.[1]) {
|
||||
candidates.push(fencedMatch[1].trim());
|
||||
}
|
||||
|
||||
const firstObjectStart = text.indexOf('{');
|
||||
if (firstObjectStart >= 0) {
|
||||
for (let end = text.length; end > firstObjectStart; end -= 1) {
|
||||
if (text[end - 1] === '}') {
|
||||
candidates.add(text.slice(firstObjectStart, end));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (text.startsWith('{') && text.endsWith('}')) {
|
||||
candidates.push(text);
|
||||
}
|
||||
|
||||
const parsed: Record<string, unknown>[] = [];
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* resolveFallbackTaskSessionId — pure helper that resolves a pending task tool
|
||||
* to a child session from the directory session store when explicit taskSessionId
|
||||
* metadata is delayed.
|
||||
*
|
||||
* Conservative: only returns a session id when the match is unambiguous.
|
||||
*/
|
||||
|
||||
import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
/**
|
||||
* Fallback is intentionally narrow: only sessions created shortly after the
|
||||
* task started are eligible. This avoids binding to earlier or later sibling
|
||||
* subagent sessions when explicit task metadata is delayed.
|
||||
*/
|
||||
/**
|
||||
* Narrow initial window avoids binding to wrong sessions on first attempt.
|
||||
* Wide window on retry handles late-appearing child sessions under load.
|
||||
*/
|
||||
const TASK_SESSION_MATCH_WINDOW_MS = 3000;
|
||||
const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000;
|
||||
|
||||
const LIVE_STATUSES = new Set<string>(['busy', 'retry']);
|
||||
|
||||
export interface ResolveFallbackParams {
|
||||
/** True when this tool is a task tool */
|
||||
isTaskTool: boolean;
|
||||
/** The parent session id (current session) */
|
||||
parentSessionId: string | undefined;
|
||||
/** When the task tool started (ms timestamp) */
|
||||
taskStartTime: number | undefined;
|
||||
/** Sessions from the directory store */
|
||||
sessions: Session[];
|
||||
/** Session status map from the sync store */
|
||||
sessionStatusMap?: Record<string, SessionStatus>;
|
||||
/** True when a previous resolution attempt has already failed (enables wider window) */
|
||||
hasRetried?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to resolve a child session id for a pending task tool by matching
|
||||
* against sessions in the directory store.
|
||||
*
|
||||
* Returns `undefined` when:
|
||||
* - Not a task tool
|
||||
* - Parent session is unknown
|
||||
* - Task start time is unknown
|
||||
* - No unambiguous match found
|
||||
*/
|
||||
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
|
||||
const {
|
||||
isTaskTool,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions,
|
||||
sessionStatusMap,
|
||||
hasRetried = false,
|
||||
} = params;
|
||||
|
||||
if (!isTaskTool || !parentSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof taskStartTime !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Filter candidate sessions: parentID matches the current session.
|
||||
let candidates = sessions.filter((session) => {
|
||||
if (!session?.id || session.parentID !== parentSessionId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Apply the time window even while running. Without it, a newly rendered task
|
||||
// can briefly bind to the previous child session before its own child exists.
|
||||
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
|
||||
const latestAllowed = taskStartTime + windowMs;
|
||||
candidates = candidates.filter((session) => {
|
||||
const created = session.time?.created;
|
||||
return typeof created === 'number' && created >= taskStartTime && created <= latestAllowed;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If exactly one candidate, return it regardless of status
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].id;
|
||||
}
|
||||
|
||||
// Multiple candidates: try to disambiguate by finding exactly one live (busy/retry)
|
||||
const liveCandidates = candidates.filter((session) => {
|
||||
const status = sessionStatusMap?.[session.id];
|
||||
return status != null && LIVE_STATUSES.has(status.type);
|
||||
});
|
||||
|
||||
if (liveCandidates.length === 1) {
|
||||
return liveCandidates[0].id;
|
||||
}
|
||||
|
||||
// Ambiguous — do not guess
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import {
|
||||
buildTaskSummaryEntriesFromSession,
|
||||
parseTaskMetadataBlock,
|
||||
readTaskSessionIdFromRecord,
|
||||
readTaskSessionIdFromOutput,
|
||||
} from './taskToolModel';
|
||||
|
||||
describe('taskToolModel', () => {
|
||||
test('reads the current OpenCode running-state identity contract', () => {
|
||||
expect(readTaskSessionIdFromRecord({ sessionId: 'child-live' })).toBe('child-live');
|
||||
expect(readTaskSessionIdFromRecord({})).toBe(undefined);
|
||||
});
|
||||
|
||||
test('reads authoritative session and summary metadata', () => {
|
||||
const output = 'result\n<task_metadata>{"sessionID":"child-1","calls":[{"id":"tool-1","tool":"read","title":"a.ts"}]}</task_metadata>';
|
||||
expect(parseTaskMetadataBlock(output)).toEqual({
|
||||
sessionId: 'child-1',
|
||||
summaryEntries: [{ id: 'tool-1', tool: 'read', state: { status: undefined, title: 'a.ts', input: undefined } }],
|
||||
});
|
||||
expect(readTaskSessionIdFromOutput(output)).toBe('child-1');
|
||||
});
|
||||
|
||||
test('projects tool calls while excluding nested task and todo bookkeeping', () => {
|
||||
const message = {
|
||||
info: { id: 'message-1', role: 'assistant' } as Message,
|
||||
parts: [
|
||||
{ id: 'read-1', type: 'tool', tool: 'read', state: { status: 'completed', input: { filePath: 'a.ts' } } },
|
||||
{ id: 'task-1', type: 'tool', tool: 'task', state: { status: 'running' } },
|
||||
{ id: 'todo-1', type: 'tool', tool: 'todowrite', state: { status: 'completed' } },
|
||||
] as unknown as Part[],
|
||||
};
|
||||
|
||||
expect(buildTaskSummaryEntriesFromSession([message])).toEqual([{
|
||||
id: 'read-1',
|
||||
tool: 'read',
|
||||
state: { status: 'completed', title: undefined, input: { filePath: 'a.ts' } },
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { MessageRecord } from '@/lib/messageCompletion';
|
||||
|
||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||
|
||||
export type TaskToolSummaryEntry = {
|
||||
id?: string;
|
||||
tool?: string;
|
||||
state?: {
|
||||
status?: string;
|
||||
title?: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSessionIdCandidate = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
export const readTaskSessionIdFromRecord = (value: unknown): string | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
return normalizeSessionIdCandidate(record.sessionID) ?? normalizeSessionIdCandidate(record.sessionId);
|
||||
};
|
||||
|
||||
export const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const normalized: TaskToolSummaryEntry[] = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry === 'string') {
|
||||
normalized.push({ tool: 'tool', state: { status: 'completed', title: entry } });
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
|
||||
const record = entry as {
|
||||
id?: unknown;
|
||||
tool?: unknown;
|
||||
title?: unknown;
|
||||
status?: unknown;
|
||||
state?: { status?: unknown; title?: unknown; input?: unknown };
|
||||
};
|
||||
normalized.push({
|
||||
id: typeof record.id === 'string' ? record.id : undefined,
|
||||
tool: typeof record.tool === 'string' ? record.tool : 'tool',
|
||||
state: {
|
||||
status: typeof record.state?.status === 'string'
|
||||
? record.state.status
|
||||
: typeof record.status === 'string' ? record.status : undefined,
|
||||
title: typeof record.state?.title === 'string'
|
||||
? record.state.title
|
||||
: typeof record.title === 'string' ? record.title : undefined,
|
||||
input: record.state?.input && typeof record.state.input === 'object'
|
||||
? record.state.input as Record<string, unknown>
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const parseTaskMetadataBlock = (output: string | undefined): {
|
||||
sessionId?: string;
|
||||
summaryEntries: TaskToolSummaryEntry[];
|
||||
} => {
|
||||
if (typeof output !== 'string' || output.trim().length === 0) return { summaryEntries: [] };
|
||||
const blockMatch = output.match(/<task_metadata>\s*([\s\S]*?)\s*<\/task_metadata>/i);
|
||||
if (!blockMatch?.[1]) return { summaryEntries: [] };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(blockMatch[1].trim()) as Record<string, unknown>;
|
||||
return {
|
||||
sessionId: normalizeSessionIdCandidate(parsed.sessionId) ?? normalizeSessionIdCandidate(parsed.sessionID),
|
||||
summaryEntries: normalizeTaskSummaryEntries(parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls),
|
||||
};
|
||||
} catch {
|
||||
return { summaryEntries: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => {
|
||||
if (typeof output !== 'string' || output.trim().length === 0) return undefined;
|
||||
const parsedMetadata = parseTaskMetadataBlock(output);
|
||||
if (parsedMetadata.sessionId) return parsedMetadata.sessionId;
|
||||
|
||||
const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i);
|
||||
const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i);
|
||||
const candidate = taskMatch?.[1] ?? sessionMatch?.[1];
|
||||
if (candidate) return normalizeSessionIdCandidate(candidate);
|
||||
return normalizeSessionIdCandidate(readTaskTagSessionIdFromOutput(output));
|
||||
};
|
||||
|
||||
const messageSummaryCache = new WeakMap<MessageRecord, TaskToolSummaryEntry[]>();
|
||||
|
||||
const projectMessageSummaryEntries = (message: MessageRecord): TaskToolSummaryEntry[] => {
|
||||
const cached = messageSummaryCache.get(message);
|
||||
if (cached) return cached;
|
||||
|
||||
const entries: TaskToolSummaryEntry[] = [];
|
||||
if (message.info.role === 'assistant') {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool') continue;
|
||||
const toolName = part.tool?.trim().toLowerCase();
|
||||
if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') continue;
|
||||
const state = part.state as { status?: string; title?: string; input?: unknown } | undefined;
|
||||
entries.push({
|
||||
id: part.id,
|
||||
tool: part.tool,
|
||||
state: {
|
||||
status: state?.status,
|
||||
title: state?.title,
|
||||
input: state?.input && typeof state.input === 'object'
|
||||
? state.input as Record<string, unknown>
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
messageSummaryCache.set(message, entries);
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const buildTaskSummaryEntriesFromSession = (messages: MessageRecord[]): TaskToolSummaryEntry[] => {
|
||||
const entries: TaskToolSummaryEntry[] = [];
|
||||
for (const message of messages) entries.push(...projectMessageSummaryEntries(message));
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||
};
|
||||
@@ -1,10 +1,87 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getDiffPatchEntries, getRenderablePatchInfo } from './toolDiffUtils';
|
||||
import {
|
||||
getApplyPatchFilePath,
|
||||
getDiffPatchEntries,
|
||||
getFirstChangedLineFromMetadata,
|
||||
getPrimaryDiffFromMetadata,
|
||||
getPrimaryToolPath,
|
||||
getRenderablePatchInfo,
|
||||
} from './toolDiffUtils';
|
||||
|
||||
const identity = (path: string) => path;
|
||||
|
||||
describe('toolDiffUtils', () => {
|
||||
test('prefers the absolute apply_patch path over its worktree-relative label', () => {
|
||||
expect(getPrimaryToolPath('apply_patch', undefined, {
|
||||
files: [{
|
||||
filePath: '/workspace/project/src/file.ts',
|
||||
relativePath: 'workspace/project/src/file.ts',
|
||||
type: 'update',
|
||||
}],
|
||||
})).toBe('/workspace/project/src/file.ts');
|
||||
});
|
||||
|
||||
test('opens the move destination and skips deleted apply_patch files', () => {
|
||||
expect(getPrimaryToolPath('apply_patch', undefined, {
|
||||
files: [
|
||||
{ filePath: '/workspace/deleted.ts', relativePath: 'deleted.ts', type: 'delete' },
|
||||
{
|
||||
filePath: '/workspace/old.ts',
|
||||
relativePath: 'new.ts',
|
||||
movePath: '/workspace/new.ts',
|
||||
type: 'move',
|
||||
},
|
||||
],
|
||||
})).toBe('/workspace/new.ts');
|
||||
});
|
||||
|
||||
test('falls back to the relative apply_patch path for legacy metadata', () => {
|
||||
expect(getPrimaryToolPath('apply_patch', undefined, {
|
||||
files: [{ relativePath: 'src/file.ts', type: 'update' }],
|
||||
})).toBe('src/file.ts');
|
||||
});
|
||||
|
||||
test('resolves each apply_patch file independently', () => {
|
||||
expect(getApplyPatchFilePath({
|
||||
filePath: '/workspace/project/src/first.ts',
|
||||
relativePath: 'workspace/project/src/first.ts',
|
||||
})).toBe('/workspace/project/src/first.ts');
|
||||
expect(getApplyPatchFilePath({
|
||||
filePath: '/workspace/project/src/old.ts',
|
||||
movePath: '/workspace/project/src/second.ts',
|
||||
relativePath: 'src/second.ts',
|
||||
})).toBe('/workspace/project/src/second.ts');
|
||||
});
|
||||
|
||||
test('selects the move patch and line from the same non-deleted file', () => {
|
||||
const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted';
|
||||
const movedPatch = '@@ -42 +42 @@\n-before\n+after';
|
||||
const metadata = {
|
||||
patch: deletedPatch,
|
||||
files: [
|
||||
{
|
||||
filePath: '/workspace/project/src/deleted.ts',
|
||||
relativePath: 'src/deleted.ts',
|
||||
patch: deletedPatch,
|
||||
type: 'delete',
|
||||
},
|
||||
{
|
||||
filePath: '/workspace/project/src/old.ts',
|
||||
movePath: '/workspace/project/src/moved.ts',
|
||||
relativePath: 'src/moved.ts',
|
||||
patch: movedPatch,
|
||||
type: 'move',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getPrimaryDiffFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts'))
|
||||
.toBe(movedPatch);
|
||||
expect(getFirstChangedLineFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts'))
|
||||
.toBe(42);
|
||||
});
|
||||
|
||||
test('treats raw apply_patch envelopes as text, not visual diffs', () => {
|
||||
const entries = getDiffPatchEntries(undefined, [
|
||||
'*** Begin Patch',
|
||||
@@ -57,6 +134,27 @@ describe('toolDiffUtils', () => {
|
||||
expect(entries[0]?.title).toBe('src/file.ts');
|
||||
});
|
||||
|
||||
test('keeps the authoritative path for every metadata file entry', () => {
|
||||
const patch = [
|
||||
'--- a/src/file.ts',
|
||||
'+++ b/src/file.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old',
|
||||
'+new',
|
||||
].join('\n');
|
||||
const entries = getDiffPatchEntries({
|
||||
files: [
|
||||
{ filePath: '/workspace/project/src/first.ts', relativePath: 'src/first.ts', patch },
|
||||
{ filePath: '/workspace/project/src/second.ts', relativePath: 'src/second.ts', patch },
|
||||
],
|
||||
}, undefined, identity);
|
||||
|
||||
expect(entries.map((entry) => entry.filePath)).toEqual([
|
||||
'/workspace/project/src/first.ts',
|
||||
'/workspace/project/src/second.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
test('synthesizes headers for valid headerless hunks', () => {
|
||||
const entries = getDiffPatchEntries(undefined, [
|
||||
'@@ -1 +1 @@',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parsePatchFiles } from '@pierre/diffs';
|
||||
export type DiffPatchEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
filePath?: string;
|
||||
patch: string;
|
||||
renderMode: 'diff' | 'text';
|
||||
};
|
||||
@@ -18,10 +19,113 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null;
|
||||
};
|
||||
|
||||
export const normalizePatchText = (patch: string): string => {
|
||||
const normalizePatchText = (patch: string): string => {
|
||||
return patch.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
||||
};
|
||||
|
||||
const normalizeBareUnifiedHeaderLines = (patch: string): string => {
|
||||
let reachedHunk = false;
|
||||
return patch.split('\n').map((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
reachedHunk = true;
|
||||
return line;
|
||||
}
|
||||
|
||||
if (reachedHunk) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const headerMatch = line.match(/^\s*(---|\+\+\+)\s+(.+)$/);
|
||||
if (!headerMatch) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const marker = headerMatch[1];
|
||||
const rawPath = headerMatch[2] ?? '';
|
||||
if (rawPath.trim() === '/dev/null') {
|
||||
return `${marker} /dev/null`;
|
||||
}
|
||||
|
||||
return `${marker} ${rawPath.replace(/\\/g, '/')}`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
const normalizeLooseUnifiedHunkBody = (patch: string): string => {
|
||||
let inHunk = false;
|
||||
return patch.split('\n').map((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
inHunk = true;
|
||||
return line;
|
||||
}
|
||||
|
||||
if (!inHunk || line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('diff --git')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
if (line.length === 0) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
const first = line[0];
|
||||
if (first === ' ' || first === '+' || first === '-' || first === '\\') {
|
||||
return line;
|
||||
}
|
||||
|
||||
return ` ${line}`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
const formatHunkRange = (start: string, count: number): string => {
|
||||
return count === 1 ? start : `${start},${count}`;
|
||||
};
|
||||
|
||||
const recountUnifiedHunkHeaders = (patch: string): string => {
|
||||
const lines = patch.split('\n');
|
||||
const result = [...lines];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const header = lines[index] ?? '';
|
||||
const match = header.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let oldCount = 0;
|
||||
let newCount = 0;
|
||||
for (let bodyIndex = index + 1; bodyIndex < lines.length; bodyIndex += 1) {
|
||||
const line = lines[bodyIndex] ?? '';
|
||||
if (line.startsWith('@@') || line.startsWith('--- ') || line.startsWith('diff --git')) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.startsWith('\\')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
newCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
oldCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
oldCount += 1;
|
||||
newCount += 1;
|
||||
}
|
||||
|
||||
result[index] = `@@ -${formatHunkRange(match[1] ?? '0', oldCount)} +${formatHunkRange(match[2] ?? '0', newCount)} @@${match[3] ?? ''}`;
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
};
|
||||
|
||||
const normalizeLooseUnifiedPatch = (patch: string): string => {
|
||||
return recountUnifiedHunkHeaders(normalizeLooseUnifiedHunkBody(normalizeBareUnifiedHeaderLines(normalizePatchText(patch))));
|
||||
};
|
||||
|
||||
export const getPatchText = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
return /\S/.test(value) ? value : undefined;
|
||||
@@ -37,6 +141,172 @@ export const getPatchText = (value: unknown): string | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getApplyPatchFilePath = (file: unknown): string | null => {
|
||||
if (!isRecord(file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeof file.movePath === 'string'
|
||||
? file.movePath
|
||||
: typeof file.filePath === 'string'
|
||||
? file.filePath
|
||||
: typeof file.relativePath === 'string'
|
||||
? file.relativePath
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getPrimaryToolPath = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): string | null => {
|
||||
if (toolName === 'apply_patch') {
|
||||
const files = Array.isArray(metadata?.files) ? metadata.files : [];
|
||||
for (const file of files) {
|
||||
if (isRecord(file) && file.type !== 'delete') {
|
||||
const filePath = getApplyPatchFilePath(file);
|
||||
if (filePath) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (toolName === 'edit' || toolName === 'multiedit') {
|
||||
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
|
||||
if (fileDiff && typeof fileDiff.file === 'string') {
|
||||
return fileDiff.file;
|
||||
}
|
||||
return typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: typeof input?.file_path === 'string'
|
||||
? input.file_path
|
||||
: typeof input?.path === 'string'
|
||||
? input.path
|
||||
: null;
|
||||
}
|
||||
|
||||
if (toolName === 'write') {
|
||||
return typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: typeof input?.file_path === 'string'
|
||||
? input.file_path
|
||||
: typeof input?.path === 'string'
|
||||
? input.path
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const supportsDiffMetadata = (toolName: string): boolean => (
|
||||
toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch'
|
||||
);
|
||||
|
||||
const getMetadataFileForPath = (
|
||||
metadata: Record<string, unknown>,
|
||||
preferredPath?: string,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const files = Array.isArray(metadata.files) ? metadata.files : [];
|
||||
if (!preferredPath) {
|
||||
const first = files[0];
|
||||
return isRecord(first) ? first : undefined;
|
||||
}
|
||||
|
||||
return files.find((file): file is Record<string, unknown> => (
|
||||
isRecord(file)
|
||||
&& (file.relativePath === preferredPath || file.filePath === preferredPath || file.movePath === preferredPath)
|
||||
));
|
||||
};
|
||||
|
||||
export const getPrimaryDiffFromMetadata = (
|
||||
toolName: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
preferredPath?: string,
|
||||
): string | undefined => {
|
||||
if (!metadata || !supportsDiffMetadata(toolName)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedFile = getMetadataFileForPath(metadata, preferredPath);
|
||||
const filePatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff);
|
||||
if (filePatch) {
|
||||
return filePatch;
|
||||
}
|
||||
|
||||
return getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
|
||||
};
|
||||
|
||||
export const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
|
||||
if (!diffText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let currentNewLine: number | undefined;
|
||||
let firstHunkStart: number | undefined;
|
||||
for (const rawLine of diffText.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
|
||||
if (hunkMatch) {
|
||||
const parsed = Number.parseInt(hunkMatch[1] ?? '', 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
currentNewLine = Math.max(1, parsed);
|
||||
firstHunkStart ??= currentNewLine;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentNewLine === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
return currentNewLine;
|
||||
}
|
||||
if (line.startsWith(' ')) {
|
||||
currentNewLine += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return firstHunkStart;
|
||||
};
|
||||
|
||||
export const getFirstChangedLineFromMetadata = (
|
||||
toolName: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
preferredPath?: string,
|
||||
): number | undefined => {
|
||||
if (!metadata || !supportsDiffMetadata(toolName)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (preferredPath) {
|
||||
const matchedFile = getMetadataFileForPath(metadata, preferredPath);
|
||||
const matchedPatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff);
|
||||
if (matchedPatch) {
|
||||
const matchedLine = extractFirstChangedLineFromDiff(matchedPatch);
|
||||
if (matchedLine !== undefined) {
|
||||
return matchedLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topLevelPatch = getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
|
||||
if (topLevelPatch) {
|
||||
const topLevelLine = extractFirstChangedLineFromDiff(topLevelPatch);
|
||||
if (topLevelLine !== undefined) {
|
||||
return topLevelLine;
|
||||
}
|
||||
}
|
||||
|
||||
const firstFile = getMetadataFileForPath(metadata);
|
||||
const firstPatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff);
|
||||
return firstPatch ? extractFirstChangedLineFromDiff(firstPatch) : undefined;
|
||||
};
|
||||
|
||||
const normalizeParsedPath = (path: string | undefined): string => {
|
||||
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
|
||||
if (!trimmed || trimmed === '/dev/null') {
|
||||
@@ -75,7 +345,7 @@ const hasOnlyUnifiedDiffBodyLines = (patch: string): boolean => {
|
||||
};
|
||||
|
||||
export const getRenderablePatchInfo = (patch: string): { patch: string; title?: string } | null => {
|
||||
const normalized = normalizePatchText(patch);
|
||||
const normalized = normalizeLooseUnifiedPatch(patch);
|
||||
if (
|
||||
!normalized
|
||||
|| APPLY_PATCH_ENVELOPE_PATTERN.test(normalized)
|
||||
@@ -125,7 +395,7 @@ const getPatchEntriesFromText = (
|
||||
idPrefix: string,
|
||||
resolveTitle: (path: string) => string,
|
||||
): DiffPatchEntry[] => {
|
||||
const normalized = normalizePatchText(patch);
|
||||
const normalized = normalizeLooseUnifiedPatch(patch);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
@@ -184,7 +454,7 @@ const getPatchEntriesFromText = (
|
||||
}];
|
||||
};
|
||||
|
||||
const getFilePatch = (file: unknown): { patch: string; title: string } | null => {
|
||||
const getFilePatch = (file: unknown): { filePath?: string; patch: string; title: string } | null => {
|
||||
if (!isRecord(file)) {
|
||||
return null;
|
||||
}
|
||||
@@ -201,6 +471,7 @@ const getFilePatch = (file: unknown): { patch: string; title: string } | null =>
|
||||
: '';
|
||||
|
||||
return {
|
||||
filePath: getApplyPatchFilePath(file) ?? undefined,
|
||||
patch,
|
||||
title: rawPath,
|
||||
};
|
||||
@@ -222,7 +493,7 @@ export const getDiffPatchEntries = (
|
||||
filePatch.title || `File ${index + 1}`,
|
||||
`file-${index}`,
|
||||
resolveTitle,
|
||||
);
|
||||
).map((entry) => ({ ...entry, filePath: filePatch.filePath }));
|
||||
});
|
||||
|
||||
if (fileEntries.length > 0) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const getToolOutput = (
|
||||
tool: string,
|
||||
stateOutput: unknown,
|
||||
metadataOutput: unknown,
|
||||
): string | undefined => {
|
||||
if (typeof stateOutput === 'string') {
|
||||
return stateOutput;
|
||||
}
|
||||
|
||||
if (tool === 'bash' && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
|
||||
return metadataOutput;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getStreamingOutputAppend = (previous: string, next: string): string | undefined => {
|
||||
return next.startsWith(previous) ? next.slice(previous.length) : undefined;
|
||||
};
|
||||
@@ -53,6 +53,9 @@ export const getToolIcon = (toolName: string) => {
|
||||
if (tool === 'task') {
|
||||
return <Icon name="ai-agent" className={iconClass} />;
|
||||
}
|
||||
if (tool === 'openchamber') {
|
||||
return <Icon name="openchamber" className={iconClass} />;
|
||||
}
|
||||
if (tool === 'question') {
|
||||
return <Icon name="survey" className={iconClass} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { isExpandableTool, isStaticTool } from './toolRenderUtils';
|
||||
|
||||
describe('tool rendering classification', () => {
|
||||
test('keeps navigation tools compact', () => {
|
||||
expect(isStaticTool('read')).toBe(true);
|
||||
expect(isStaticTool('skill')).toBe(true);
|
||||
expect(isExpandableTool('read')).toBe(false);
|
||||
expect(isExpandableTool('skill')).toBe(false);
|
||||
});
|
||||
|
||||
test('expands built-in tools without direct navigation', () => {
|
||||
expect(isExpandableTool('grep')).toBe(true);
|
||||
expect(isExpandableTool('webfetch')).toBe(true);
|
||||
expect(isExpandableTool('todowrite')).toBe(true);
|
||||
expect(isExpandableTool('plan_exit')).toBe(true);
|
||||
});
|
||||
|
||||
test('expands custom and MCP tools', () => {
|
||||
expect(isExpandableTool('linear_list_issues')).toBe(true);
|
||||
expect(isExpandableTool('my-plugin_publish')).toBe(true);
|
||||
expect(isStaticTool('linear_list_issues')).toBe(false);
|
||||
});
|
||||
|
||||
test('normalizes dotted and indexed tool names', () => {
|
||||
expect(isStaticTool('runtime.read:2')).toBe(true);
|
||||
expect(isExpandableTool('runtime.custom_tool:2')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,10 @@
|
||||
const EXPANDABLE_TOOL_NAMES = new Set<string>([
|
||||
'edit', 'multiedit', 'apply_patch', 'str_replace', 'str_replace_based_edit_tool',
|
||||
'bash', 'shell', 'cmd', 'terminal',
|
||||
'write', 'create', 'file_write',
|
||||
'question', 'task', 'lsp',
|
||||
]);
|
||||
// Keep only tools with a direct in-app navigation destination compact. Every
|
||||
// other tool uses ToolPart so custom, plugin, and MCP calls expose their input
|
||||
// and output through the common expandable renderer.
|
||||
const STATIC_TOOL_NAMES = new Set<string>(['read', 'skill']);
|
||||
|
||||
const STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
||||
|
||||
const SEARCH_TOOL_NAMES = new Set<string>(['grep', 'search', 'find', 'ripgrep', 'glob']);
|
||||
|
||||
const normalizeToolName = (toolName: unknown): string => {
|
||||
if (typeof toolName !== 'string') return '';
|
||||
const trimmed = toolName.trim().toLowerCase();
|
||||
@@ -23,7 +19,7 @@ const normalizeToolName = (toolName: unknown): string => {
|
||||
};
|
||||
|
||||
export const isExpandableTool = (toolName: unknown): boolean => {
|
||||
return EXPANDABLE_TOOL_NAMES.has(normalizeToolName(toolName));
|
||||
return !isStaticTool(toolName);
|
||||
};
|
||||
|
||||
export const isStandaloneTool = (toolName: unknown): boolean => {
|
||||
@@ -31,14 +27,18 @@ export const isStandaloneTool = (toolName: unknown): boolean => {
|
||||
};
|
||||
|
||||
export const isStaticTool = (toolName: unknown): boolean => {
|
||||
if (typeof toolName !== 'string') return false;
|
||||
return !isExpandableTool(toolName) && !isStandaloneTool(toolName);
|
||||
return STATIC_TOOL_NAMES.has(normalizeToolName(toolName));
|
||||
};
|
||||
|
||||
export const getStaticGroupToolName = (toolName: string): string => {
|
||||
const normalized = normalizeToolName(toolName);
|
||||
if (SEARCH_TOOL_NAMES.has(normalized)) {
|
||||
return 'grep';
|
||||
export const getToolDescriptionFallback = (
|
||||
toolName: unknown,
|
||||
description: unknown,
|
||||
input: Record<string, unknown> | undefined,
|
||||
): string => {
|
||||
if (typeof description === 'string' && description.trim().length > 0) {
|
||||
return description;
|
||||
}
|
||||
return normalized;
|
||||
|
||||
const globPattern = normalizeToolName(toolName) === 'glob' ? input?.pattern : undefined;
|
||||
return typeof globPattern === 'string' ? globPattern : '';
|
||||
};
|
||||
|
||||
@@ -100,13 +100,25 @@ export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolea
|
||||
if (readPartText(leftPart) !== readPartText(rightPart)) {
|
||||
return false;
|
||||
}
|
||||
// Shell-mode user messages carry their live command state in an
|
||||
// injected `shellAction` payload on a synthetic text part; without
|
||||
// comparing it, a running→completed transition never re-renders.
|
||||
const leftShell = (leftPart as { shellAction?: { command?: unknown; output?: unknown; status?: unknown } }).shellAction;
|
||||
const rightShell = (rightPart as { shellAction?: { command?: unknown; output?: unknown; status?: unknown } }).shellAction;
|
||||
if (leftShell || rightShell) {
|
||||
if (leftShell?.command !== rightShell?.command
|
||||
|| leftShell?.output !== rightShell?.output
|
||||
|| leftShell?.status !== rightShell?.status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => {
|
||||
const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => {
|
||||
if (left === right) return true;
|
||||
|
||||
return left.id === right.id
|
||||
@@ -280,6 +292,7 @@ export const areRelevantTurnGroupingContextsEqual = (
|
||||
if (left.turnId !== right.turnId) return false;
|
||||
if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) return false;
|
||||
if (left.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false;
|
||||
if (left.isLatestTurn !== right.isLatestTurn) return false;
|
||||
if (left.isWorking !== right.isWorking) return false;
|
||||
if (left.hasTools !== right.hasTools) return false;
|
||||
if (left.hasReasoning !== right.hasReasoning) return false;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
formatCodeSelectionMarkdown,
|
||||
selectionNodesToMarkdown,
|
||||
trimSelectionValue,
|
||||
wrapMarkdownSelectionForChat,
|
||||
} from './selectionMarkdown';
|
||||
|
||||
type TestNode =
|
||||
| { type: 'text'; value: string }
|
||||
| {
|
||||
type: 'element';
|
||||
tag: string;
|
||||
className: string;
|
||||
href: string;
|
||||
component: string;
|
||||
markdownLanguage: string;
|
||||
isMarkdownBlock: boolean;
|
||||
isCodeLines: boolean;
|
||||
isCodeLineNumber: boolean;
|
||||
children: TestNode[];
|
||||
};
|
||||
|
||||
const multiline = (...lines: string[]): string => lines.join('\n');
|
||||
const text = (value: string): TestNode => ({ type: 'text', value });
|
||||
const element = (
|
||||
tag: string,
|
||||
children: TestNode[],
|
||||
options: Partial<Omit<Extract<TestNode, { type: 'element' }>, 'type' | 'tag' | 'children'>> = {},
|
||||
): TestNode => ({
|
||||
type: 'element',
|
||||
tag,
|
||||
className: '',
|
||||
href: '',
|
||||
component: '',
|
||||
markdownLanguage: '',
|
||||
isMarkdownBlock: false,
|
||||
isCodeLines: false,
|
||||
isCodeLineNumber: false,
|
||||
children,
|
||||
...options,
|
||||
});
|
||||
|
||||
const codeLine = (number: number, content: string): TestNode => element('span', [
|
||||
element('span', [text(String(number))], { isCodeLineNumber: true }),
|
||||
element('span', [text(content)]),
|
||||
]);
|
||||
|
||||
const markdownBlock = (children: TestNode[]): TestNode => element('div', children, { isMarkdownBlock: true });
|
||||
|
||||
const codeWrapper = (lines: string[], language = 'ts'): TestNode => element('div', [
|
||||
element('div', [text(language)]),
|
||||
element('div', [
|
||||
element('pre', [
|
||||
element('code', lines.flatMap((line, index) => [
|
||||
codeLine(index + 12, line),
|
||||
...(index < lines.length - 1 ? [element('span', [text('\n')])] : []),
|
||||
]), { isCodeLines: true }),
|
||||
], { markdownLanguage: language }),
|
||||
]),
|
||||
], { component: 'markdown-code' });
|
||||
|
||||
describe('selectionNodesToMarkdown', () => {
|
||||
test('serializes a complete grid code block without its header or line numbers', () => {
|
||||
expect(selectionNodesToMarkdown([codeWrapper(['range.cloneContents()', 'next()'])], '')).toBe(multiline(
|
||||
'```ts',
|
||||
'range.cloneContents()',
|
||||
'next()',
|
||||
'```',
|
||||
));
|
||||
});
|
||||
|
||||
test('preserves a code block nested between production Markdown block wrappers', () => {
|
||||
const nodes = [
|
||||
markdownBlock([element('p', [
|
||||
text('Method:'),
|
||||
element('code', [text('Selection.toString()')]),
|
||||
text('. Add to Chat uses a cloned range.'),
|
||||
])]),
|
||||
markdownBlock([codeWrapper(['range.cloneContents()'])]),
|
||||
markdownBlock([element('p', [text('Following explanation')])]),
|
||||
];
|
||||
|
||||
expect(selectionNodesToMarkdown(nodes, '')).toBe(multiline(
|
||||
'Method:`Selection.toString()`. Add to Chat uses a cloned range.',
|
||||
'',
|
||||
'```ts',
|
||||
'range.cloneContents()',
|
||||
'```',
|
||||
'',
|
||||
'Following explanation',
|
||||
));
|
||||
});
|
||||
|
||||
test('preserves a partial code block selected before prose', () => {
|
||||
expect(selectionNodesToMarkdown([
|
||||
codeWrapper(['range.cloneContents()']),
|
||||
element('p', [text('Following explanation')]),
|
||||
], '')).toBe(multiline(
|
||||
'```ts',
|
||||
'range.cloneContents()',
|
||||
'```',
|
||||
'',
|
||||
'Following explanation',
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCodeSelectionMarkdown', () => {
|
||||
test('preserves indentation and blank lines', () => {
|
||||
expect(formatCodeSelectionMarkdown(multiline(
|
||||
'if (ready) {',
|
||||
' run();',
|
||||
'',
|
||||
' stop();',
|
||||
'}',
|
||||
), 'ts')).toBe(multiline(
|
||||
'```ts',
|
||||
'if (ready) {',
|
||||
' run();',
|
||||
'',
|
||||
' stop();',
|
||||
'}',
|
||||
'```',
|
||||
));
|
||||
});
|
||||
|
||||
test('normalizes line endings without duplicating a trailing newline', () => {
|
||||
expect(formatCodeSelectionMarkdown('first\r\nsecond\r\n', 'text')).toBe(multiline(
|
||||
'```text',
|
||||
'first',
|
||||
'second',
|
||||
'```',
|
||||
));
|
||||
});
|
||||
|
||||
test('uses a longer fence when selected code contains backtick fences', () => {
|
||||
expect(formatCodeSelectionMarkdown(multiline(
|
||||
'before',
|
||||
'```',
|
||||
'after',
|
||||
), 'md')).toBe(multiline(
|
||||
'````md',
|
||||
'before',
|
||||
'```',
|
||||
'after',
|
||||
'````',
|
||||
));
|
||||
});
|
||||
|
||||
test('preserves punctuation in language identifiers', () => {
|
||||
expect(selectionNodesToMarkdown([codeWrapper(['std::vector<int> values;'], 'c++')], '')).toBe(multiline(
|
||||
'```c++',
|
||||
'std::vector<int> values;',
|
||||
'```',
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe('trimSelectionValue', () => {
|
||||
test('normalizes line endings before trimming the selection', () => {
|
||||
expect(trimSelectionValue(' first\r\nsecond ')).toBe(multiline('first', 'second'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapMarkdownSelectionForChat', () => {
|
||||
test('uses a longer outer fence when the selection contains fenced code', () => {
|
||||
const selectedMarkdown = multiline(
|
||||
'```ts',
|
||||
'run();',
|
||||
'```',
|
||||
);
|
||||
|
||||
expect(wrapMarkdownSelectionForChat(selectedMarkdown)).toBe(multiline(
|
||||
'````md',
|
||||
'```ts',
|
||||
'run();',
|
||||
'```',
|
||||
'````',
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
type SelectionNode =
|
||||
| { type: 'text'; value: string }
|
||||
| {
|
||||
type: 'element';
|
||||
tag: string;
|
||||
className: string;
|
||||
href: string;
|
||||
component: string;
|
||||
markdownLanguage: string;
|
||||
isMarkdownBlock: boolean;
|
||||
isCodeLines: boolean;
|
||||
isCodeLineNumber: boolean;
|
||||
children: SelectionNode[];
|
||||
};
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
|
||||
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
|
||||
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
|
||||
'section', 'table', 'ul',
|
||||
]);
|
||||
|
||||
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
|
||||
export const trimSelectionValue = (value: string): string => normalizeLineBreaks(value).trim();
|
||||
const textToMarkdownInline = (value: string): string => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const getCodeLanguageFromClassName = (className: string): string => {
|
||||
return (className.match(/language-([\w+#.-]+)/)?.[1] || '').trim();
|
||||
};
|
||||
|
||||
const getBlockCodeLanguage = (code: HTMLElement): string => {
|
||||
return code.closest('pre')?.getAttribute('data-md-lang')
|
||||
|| getCodeLanguageFromClassName(code.className);
|
||||
};
|
||||
|
||||
const toSelectionNode = (node: Node): SelectionNode | null => {
|
||||
if (node.nodeType === 3) {
|
||||
return { type: 'text', value: node.textContent || '' };
|
||||
}
|
||||
if (node.nodeType !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const element = node as Element;
|
||||
return {
|
||||
type: 'element',
|
||||
tag: element.tagName.toLowerCase(),
|
||||
className: element.getAttribute('class') || '',
|
||||
href: element.getAttribute('href') || '',
|
||||
component: element.getAttribute('data-component') || '',
|
||||
markdownLanguage: element.getAttribute('data-md-lang') || '',
|
||||
isMarkdownBlock: element.hasAttribute('data-md-block'),
|
||||
isCodeLines: element.hasAttribute('data-md-code-lines'),
|
||||
isCodeLineNumber: element.hasAttribute('data-md-code-line-number'),
|
||||
children: Array.from(element.childNodes)
|
||||
.map((child) => toSelectionNode(child))
|
||||
.filter((child): child is SelectionNode => child !== null),
|
||||
};
|
||||
};
|
||||
|
||||
export const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
|
||||
return nodes
|
||||
.filter((node) => node.type === 'text' || !node.isCodeLineNumber)
|
||||
.map((node) => node.type === 'text'
|
||||
? node
|
||||
: { ...node, children: trimSelectionNodes(node.children) });
|
||||
};
|
||||
|
||||
const toSelectionNodes = (root: ParentNode): SelectionNode[] => {
|
||||
return Array.from(root.childNodes)
|
||||
.map((child) => toSelectionNode(child))
|
||||
.filter((child): child is SelectionNode => child !== null);
|
||||
};
|
||||
|
||||
const getSelectionText = (node: SelectionNode): string => {
|
||||
return node.type === 'text'
|
||||
? node.value
|
||||
: node.children.map((child) => getSelectionText(child)).join('');
|
||||
};
|
||||
|
||||
const findElement = (
|
||||
node: SelectionNode,
|
||||
predicate: (element: Extract<SelectionNode, { type: 'element' }>) => boolean,
|
||||
): Extract<SelectionNode, { type: 'element' }> | null => {
|
||||
if (node.type === 'text') return null;
|
||||
if (predicate(node)) return node;
|
||||
for (const child of node.children) {
|
||||
const match = findElement(child, predicate);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatCodeSelectionMarkdown = (code: string, language = ''): string => {
|
||||
const normalizedCode = normalizeLineBreaks(code).replace(/\n$/, '');
|
||||
const longestBacktickRun = Math.max(0, ...Array.from(normalizedCode.matchAll(/`+/g), (match) => match[0].length));
|
||||
const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
|
||||
return `${fence}${language}\n${normalizedCode}\n${fence}`;
|
||||
};
|
||||
|
||||
const renderInlineMarkdownNode = (node: SelectionNode): string => {
|
||||
if (node.type === 'text') {
|
||||
return textToMarkdownInline(node.value);
|
||||
}
|
||||
|
||||
const childText = node.children
|
||||
.map((child) => renderInlineMarkdownNode(child))
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!childText && node.tag !== 'br') return '';
|
||||
if (node.tag === 'br') return '\n';
|
||||
if (node.tag === 'strong' || node.tag === 'b') return `**${childText}**`;
|
||||
if (node.tag === 'em' || node.tag === 'i') return `*${childText}*`;
|
||||
if (node.tag === 'code') return `\`${childText.replace(/`/g, '\\`')}\``;
|
||||
if (node.tag === 'a') return node.href ? `[${childText}](${node.href})` : childText;
|
||||
return childText;
|
||||
};
|
||||
|
||||
const renderListMarkdown = (list: Extract<SelectionNode, { type: 'element' }>, ordered: boolean): string => {
|
||||
return list.children
|
||||
.filter((child): child is Extract<SelectionNode, { type: 'element' }> => child.type === 'element' && child.tag === 'li')
|
||||
.map((item, index) => {
|
||||
const body = item.children
|
||||
.map((child) => renderInlineMarkdownNode(child))
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return body ? `${ordered ? `${index + 1}.` : '-'} ${body}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const renderBlockMarkdownNode = (node: SelectionNode): string => {
|
||||
if (node.type === 'text') return trimSelectionValue(node.value);
|
||||
|
||||
if (node.isMarkdownBlock) {
|
||||
return node.children
|
||||
.map((child) => renderBlockMarkdownNode(child))
|
||||
.filter((child) => child.length > 0)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
if (node.component === 'markdown-code') {
|
||||
const pre = findElement(node, (element) => element.tag === 'pre');
|
||||
return pre ? renderBlockMarkdownNode(pre) : '';
|
||||
}
|
||||
|
||||
if (node.tag === 'pre' || (node.tag === 'code' && node.isCodeLines)) {
|
||||
const code = node.tag === 'code'
|
||||
? node
|
||||
: findElement(node, (element) => element.tag === 'code');
|
||||
return formatCodeSelectionMarkdown(
|
||||
code ? getSelectionText(code) : getSelectionText(node),
|
||||
node.markdownLanguage || getCodeLanguageFromClassName(code?.className || ''),
|
||||
);
|
||||
}
|
||||
|
||||
if (node.tag === 'code') {
|
||||
const code = normalizeLineBreaks(getSelectionText(node)).trim();
|
||||
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
|
||||
}
|
||||
if (node.tag === 'ul') return renderListMarkdown(node, false);
|
||||
if (node.tag === 'ol') return renderListMarkdown(node, true);
|
||||
|
||||
if (node.tag === 'blockquote') {
|
||||
const content = trimSelectionValue(node.children.map((child) => renderBlockMarkdownNode(child)).join('\n'));
|
||||
return content
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (/^h[1-6]$/.test(node.tag)) {
|
||||
const level = Number.parseInt(node.tag[1], 10);
|
||||
const text = trimSelectionValue(node.children.map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
return text ? `${'#'.repeat(level)} ${text}` : '';
|
||||
}
|
||||
|
||||
if (node.tag === 'p' || node.tag === 'div' || node.tag === 'li') {
|
||||
return trimSelectionValue(node.children.map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
}
|
||||
|
||||
const blockChildren = node.children
|
||||
.map((child) => renderBlockMarkdownNode(child))
|
||||
.filter((child) => child.length > 0);
|
||||
return blockChildren.length > 0
|
||||
? blockChildren.join('\n\n')
|
||||
: trimSelectionValue(node.children.map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
};
|
||||
|
||||
const isInlineSelectionNode = (node: SelectionNode): boolean => {
|
||||
if (node.type === 'text') return true;
|
||||
return !node.isMarkdownBlock && !node.isCodeLines && node.component !== 'markdown-code' && !BLOCK_TAGS.has(node.tag);
|
||||
};
|
||||
|
||||
export const selectionNodesToMarkdown = (nodes: SelectionNode[], plainText: string): string => {
|
||||
const trimmedNodes = trimSelectionNodes(nodes);
|
||||
if (trimmedNodes.every((node) => isInlineSelectionNode(node))) {
|
||||
const inlineMarkdown = trimSelectionValue(trimmedNodes.map((node) => renderInlineMarkdownNode(node)).join(''));
|
||||
if (inlineMarkdown) return inlineMarkdown;
|
||||
}
|
||||
|
||||
const markdown = trimmedNodes
|
||||
.map((node) => renderBlockMarkdownNode(node))
|
||||
.filter((value) => value.length > 0)
|
||||
.join('\n\n')
|
||||
.trim();
|
||||
return markdown || trimSelectionValue(plainText);
|
||||
};
|
||||
|
||||
const getContainingBlockCode = (node: Node): HTMLElement | null => {
|
||||
const element = node.nodeType === 1 ? node as Element : node.parentElement;
|
||||
return element?.closest<HTMLElement>('pre code') ?? null;
|
||||
};
|
||||
|
||||
export const rangeToMarkdown = (range: Range, plainText: string): string => {
|
||||
const startCode = getContainingBlockCode(range.startContainer);
|
||||
const endCode = getContainingBlockCode(range.endContainer);
|
||||
const nodes = trimSelectionNodes(toSelectionNodes(range.cloneContents()));
|
||||
|
||||
if (startCode && startCode === endCode) {
|
||||
return formatCodeSelectionMarkdown(
|
||||
nodes.map((node) => getSelectionText(node)).join(''),
|
||||
getBlockCodeLanguage(startCode),
|
||||
);
|
||||
}
|
||||
|
||||
return selectionNodesToMarkdown(nodes, plainText);
|
||||
};
|
||||
|
||||
export const wrapMarkdownSelectionForChat = (markdown: string): string => {
|
||||
const longestBacktickRun = Math.max(0, ...Array.from(markdown.matchAll(/`+/g), (match) => match[0].length));
|
||||
const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
|
||||
return `${fence}md\n${markdown}\n${fence}`;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||||
|
||||
export class MermaidLoadFailure extends Error {
|
||||
key: I18nKey;
|
||||
params?: I18nParams;
|
||||
|
||||
constructor(key: I18nKey, params?: I18nParams) {
|
||||
super(key);
|
||||
this.name = 'MermaidLoadFailure';
|
||||
this.key = key;
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
|
||||
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
|
||||
|
||||
export const isMermaidLoadFailure = (value: unknown): value is MermaidLoadFailure => value instanceof MermaidLoadFailure;
|
||||
|
||||
export const nextMermaidLoadRequestId = (current: number): number => current + 1;
|
||||
|
||||
export const isCurrentMermaidLoadRequest = (current: number, requestId: number): boolean => current === requestId;
|
||||
|
||||
const decodeMermaidDataUrl = (value: string): string => {
|
||||
const commaIndex = value.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
throw mermaidLoadFailure('chat.toolOutputDialog.mermaid.dataUrlMalformed');
|
||||
}
|
||||
|
||||
const metadata = value.slice(0, commaIndex).toLowerCase();
|
||||
const payload = value.slice(commaIndex + 1);
|
||||
if (metadata.includes(';base64')) {
|
||||
return atob(payload);
|
||||
}
|
||||
return decodeURIComponent(payload);
|
||||
};
|
||||
|
||||
export const getMermaidDataUrlSourcePromise = (value: string): Promise<string> => Promise.resolve().then(() => decodeMermaidDataUrl(value));
|
||||
@@ -11,7 +11,18 @@ const cleanOutput = (output: string) => {
|
||||
return cleaned.trim();
|
||||
};
|
||||
|
||||
export const hasLspDiagnostics = (output: string): boolean => {
|
||||
export const coerceToText = (value: unknown, fallback = ''): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === null || value === undefined) return fallback;
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const hasLspDiagnostics = (output: string): boolean => {
|
||||
if (!output) return false;
|
||||
return output.includes('<diagnostics')
|
||||
|| output.includes('<file_diagnostics>')
|
||||
@@ -124,7 +135,7 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export interface ParsedReadOutputLine {
|
||||
interface ParsedReadOutputLine {
|
||||
text: string;
|
||||
lineNumber: number | null;
|
||||
isInfo: boolean;
|
||||
@@ -387,8 +398,18 @@ export const renderTodoOutput = (
|
||||
options?: { unstyled?: boolean },
|
||||
) => {
|
||||
try {
|
||||
const todos = JSON.parse(output) as Todo[];
|
||||
if (!Array.isArray(todos)) {
|
||||
const raw: unknown = JSON.parse(output);
|
||||
if (!Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const todos: Todo[] = raw.filter(
|
||||
(t): t is Todo =>
|
||||
!!t &&
|
||||
typeof t === 'object' &&
|
||||
typeof (t as { content?: unknown }).content === 'string' &&
|
||||
typeof (t as { status?: unknown }).status === 'string',
|
||||
);
|
||||
if (todos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -446,7 +467,7 @@ export const renderTodoOutput = (
|
||||
{todosByStatus.in_progress.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
{getPriorityDot(todo.priority)}
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -463,7 +484,7 @@ export const renderTodoOutput = (
|
||||
{todosByStatus.pending.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
{getPriorityDot(todo.priority)}
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -480,7 +501,7 @@ export const renderTodoOutput = (
|
||||
{todosByStatus.completed.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
<Icon name="check" className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--status-success)', opacity: 0.7 }}/>
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-foreground flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -497,7 +518,7 @@ export const renderTodoOutput = (
|
||||
{todosByStatus.cancelled.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
<span className="w-3 h-3 text-muted-foreground/50 mt-0.5 flex-shrink-0">×</span>
|
||||
<span className="typography-code text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
|
||||
<span className="typography-code text-muted-foreground/50 line-through flex-1 leading-relaxed">{coerceToText(todo.content)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -528,9 +549,9 @@ export const renderWebSearchOutput = (output: string, options?: { unstyled?: boo
|
||||
}
|
||||
};
|
||||
|
||||
export type DiffLineType = 'context' | 'added' | 'removed';
|
||||
type DiffLineType = 'context' | 'added' | 'removed';
|
||||
|
||||
export interface UnifiedDiffLine {
|
||||
interface UnifiedDiffLine {
|
||||
type: DiffLineType;
|
||||
lineNumber: number | null;
|
||||
content: string;
|
||||
@@ -543,18 +564,6 @@ export interface UnifiedDiffHunk {
|
||||
lines: UnifiedDiffLine[];
|
||||
}
|
||||
|
||||
export interface SideBySideDiffLine {
|
||||
leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
|
||||
rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
|
||||
}
|
||||
|
||||
export interface SideBySideDiffHunk {
|
||||
file: string;
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
lines: SideBySideDiffLine[];
|
||||
}
|
||||
|
||||
export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile = '';
|
||||
@@ -615,199 +624,6 @@ export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => {
|
||||
return hunks;
|
||||
};
|
||||
|
||||
export const parseDiffToLines = (diffText: string): SideBySideDiffHunk[] => {
|
||||
const lines = diffText.split('\n');
|
||||
let currentFile = '';
|
||||
const hunks: SideBySideDiffHunk[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) {
|
||||
if (line.startsWith('Index:')) {
|
||||
currentFile = line.split(' ')[1].split('/').pop() || 'file';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/);
|
||||
const oldStart = match ? parseInt(match[1]) : 0;
|
||||
const newStart = match ? parseInt(match[2]) : 0;
|
||||
|
||||
const changes: Array<{
|
||||
type: 'context' | 'added' | 'removed';
|
||||
content: string;
|
||||
oldLine?: number;
|
||||
newLine?: number;
|
||||
}> = [];
|
||||
|
||||
let oldLineNum = oldStart;
|
||||
let newLineNum = newStart;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
|
||||
const contentLine = lines[j];
|
||||
if (contentLine.startsWith('+')) {
|
||||
changes.push({ type: 'added', content: contentLine.substring(1), newLine: newLineNum });
|
||||
newLineNum++;
|
||||
} else if (contentLine.startsWith('-')) {
|
||||
changes.push({ type: 'removed', content: contentLine.substring(1), oldLine: oldLineNum });
|
||||
oldLineNum++;
|
||||
} else if (contentLine.startsWith(' ')) {
|
||||
changes.push({
|
||||
type: 'context',
|
||||
content: contentLine.substring(1),
|
||||
oldLine: oldLineNum,
|
||||
newLine: newLineNum,
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
const alignedLines: Array<{
|
||||
leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string };
|
||||
rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string };
|
||||
}> = [];
|
||||
|
||||
const leftSide: Array<{ type: 'context' | 'removed'; lineNumber: number; content: string }> = [];
|
||||
const rightSide: Array<{ type: 'context' | 'added'; lineNumber: number; content: string }> = [];
|
||||
|
||||
changes.forEach((change) => {
|
||||
if (change.type === 'context') {
|
||||
leftSide.push({ type: 'context', lineNumber: change.oldLine!, content: change.content });
|
||||
rightSide.push({ type: 'context', lineNumber: change.newLine!, content: change.content });
|
||||
} else if (change.type === 'removed') {
|
||||
leftSide.push({ type: 'removed', lineNumber: change.oldLine!, content: change.content });
|
||||
} else if (change.type === 'added') {
|
||||
rightSide.push({ type: 'added', lineNumber: change.newLine!, content: change.content });
|
||||
}
|
||||
});
|
||||
|
||||
const alignmentPoints: Array<{ leftIdx: number; rightIdx: number }> = [];
|
||||
|
||||
leftSide.forEach((leftItem, leftIdx) => {
|
||||
if (leftItem.type === 'context') {
|
||||
const rightIdx = rightSide.findIndex((rightItem, rIdx) =>
|
||||
rightItem.type === 'context' &&
|
||||
rightItem.content === leftItem.content &&
|
||||
!alignmentPoints.some((ap) => ap.rightIdx === rIdx)
|
||||
);
|
||||
if (rightIdx >= 0) {
|
||||
alignmentPoints.push({ leftIdx, rightIdx });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
alignmentPoints.sort((a, b) => a.leftIdx - b.leftIdx);
|
||||
|
||||
let leftIdx = 0;
|
||||
let rightIdx = 0;
|
||||
let alignIdx = 0;
|
||||
|
||||
while (leftIdx < leftSide.length || rightIdx < rightSide.length) {
|
||||
const nextAlign = alignIdx < alignmentPoints.length ? alignmentPoints[alignIdx] : null;
|
||||
|
||||
if (nextAlign && leftIdx === nextAlign.leftIdx && rightIdx === nextAlign.rightIdx) {
|
||||
const leftItem = leftSide[leftIdx];
|
||||
const rightItem = rightSide[rightIdx];
|
||||
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: 'context',
|
||||
lineNumber: leftItem.lineNumber,
|
||||
content: leftItem.content,
|
||||
},
|
||||
rightLine: {
|
||||
type: 'context',
|
||||
lineNumber: rightItem.lineNumber,
|
||||
content: rightItem.content,
|
||||
},
|
||||
});
|
||||
|
||||
leftIdx++;
|
||||
rightIdx++;
|
||||
alignIdx++;
|
||||
} else {
|
||||
const needProcessLeft = leftIdx < leftSide.length && (!nextAlign || leftIdx < nextAlign.leftIdx);
|
||||
const needProcessRight = rightIdx < rightSide.length && (!nextAlign || rightIdx < nextAlign.rightIdx);
|
||||
|
||||
if (needProcessLeft && needProcessRight) {
|
||||
const leftItem = leftSide[leftIdx];
|
||||
const rightItem = rightSide[rightIdx];
|
||||
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: leftItem.type,
|
||||
lineNumber: leftItem.lineNumber,
|
||||
content: leftItem.content,
|
||||
},
|
||||
rightLine: {
|
||||
type: rightItem.type,
|
||||
lineNumber: rightItem.lineNumber,
|
||||
content: rightItem.content,
|
||||
},
|
||||
});
|
||||
|
||||
leftIdx++;
|
||||
rightIdx++;
|
||||
} else if (needProcessLeft) {
|
||||
const leftItem = leftSide[leftIdx];
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: leftItem.type,
|
||||
lineNumber: leftItem.lineNumber,
|
||||
content: leftItem.content,
|
||||
},
|
||||
rightLine: {
|
||||
type: 'empty',
|
||||
lineNumber: null,
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
leftIdx++;
|
||||
} else if (needProcessRight) {
|
||||
const rightItem = rightSide[rightIdx];
|
||||
alignedLines.push({
|
||||
leftLine: {
|
||||
type: 'empty',
|
||||
lineNumber: null,
|
||||
content: '',
|
||||
},
|
||||
rightLine: {
|
||||
type: rightItem.type,
|
||||
lineNumber: rightItem.lineNumber,
|
||||
content: rightItem.content,
|
||||
},
|
||||
});
|
||||
rightIdx++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hunks.push({
|
||||
file: currentFile,
|
||||
oldStart,
|
||||
newStart,
|
||||
lines: alignedLines,
|
||||
});
|
||||
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
export const detectLanguageFromOutput = (output: string, toolName: string, input?: Record<string, unknown>) => {
|
||||
return detectToolOutputLanguage(toolName, output, input);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user