Initial public release
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk';
|
||||
import { MarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import type { StreamPhase } from '../types';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { ReasoningTimelineBlock, formatReasoningText } from './ReasoningPart';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
interface AssistantTextPartProps {
|
||||
part: Part;
|
||||
messageId: string;
|
||||
streamPhase: StreamPhase;
|
||||
allowAnimation: boolean;
|
||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||
renderAsReasoning?: boolean;
|
||||
}
|
||||
|
||||
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
part,
|
||||
messageId,
|
||||
streamPhase,
|
||||
allowAnimation,
|
||||
onContentChange,
|
||||
renderAsReasoning = false,
|
||||
}) => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const baseTextContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
const textContent = React.useMemo(() => {
|
||||
if (renderAsReasoning) {
|
||||
return formatReasoningText(baseTextContent);
|
||||
}
|
||||
return baseTextContent;
|
||||
}, [baseTextContent, renderAsReasoning]);
|
||||
const isStreamingPhase = streamPhase === 'streaming';
|
||||
const isCooldownPhase = streamPhase === 'cooldown';
|
||||
const wasStreamingRef = React.useRef(isStreamingPhase);
|
||||
|
||||
if (isStreamingPhase || isCooldownPhase) {
|
||||
wasStreamingRef.current = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = partWithText.time;
|
||||
const isFinalized = time && typeof time.end !== 'undefined';
|
||||
|
||||
if (!isFinalized && (!textContent || textContent.trim().length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!textContent || textContent.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (renderAsReasoning) {
|
||||
return (
|
||||
<ReasoningTimelineBlock
|
||||
key={part.id || `${messageId}-text`}
|
||||
text={textContent}
|
||||
variant="justification"
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-reasoning-text`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/assistant-text relative break-words" key={part.id || `${messageId}-text`}>
|
||||
<MarkdownRenderer
|
||||
content={textContent}
|
||||
part={part}
|
||||
messageId={messageId}
|
||||
isAnimated={allowAnimation}
|
||||
isStreaming={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantTextPart;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string };
|
||||
|
||||
const cleanJustificationText = (text: string): string => {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line: string) => line.replace(/^>\s?/, '').trimEnd())
|
||||
.filter((line: string) => line.trim().length > 0)
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
interface JustificationBlockProps {
|
||||
part: Part;
|
||||
messageId: string;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
}
|
||||
|
||||
const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
||||
part,
|
||||
messageId,
|
||||
onContentChange,
|
||||
}) => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text || partWithText.content || '';
|
||||
const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]);
|
||||
|
||||
const timeInfo = 'time' in part ? (part.time as { start: number; end?: number }) : null;
|
||||
if (!timeInfo?.end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningTimelineBlock
|
||||
text={textContent}
|
||||
variant="justification"
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-justification`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(JustificationBlock);
|
||||
@@ -0,0 +1,31 @@
|
||||
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);
|
||||
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiStackLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TurnActivityPart } from '../../hooks/useTurnGrouping';
|
||||
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import type { ToolPopupContent } from '../types';
|
||||
import ToolPart from './ToolPart';
|
||||
import ReasoningPart from './ReasoningPart';
|
||||
import JustificationBlock from './JustificationBlock';
|
||||
import { FadeInOnReveal } from '../FadeInOnReveal';
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
files: number;
|
||||
}
|
||||
|
||||
interface ProgressiveGroupProps {
|
||||
parts: TurnActivityPart[];
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
isMobile: boolean;
|
||||
expandedTools: Set<string>;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
isWorking: boolean;
|
||||
previewedPartIds: Set<string>;
|
||||
diffStats?: DiffStats;
|
||||
}
|
||||
|
||||
const getGroupSummary = (parts: TurnActivityPart[]): string => {
|
||||
const counts = {
|
||||
tools: parts.filter((p) => p.kind === 'tool').length,
|
||||
reasoning: parts.filter((p) => p.kind === 'reasoning').length,
|
||||
justifications: parts.filter((p) => p.kind === 'justification').length,
|
||||
};
|
||||
|
||||
const segments: string[] = [];
|
||||
if (counts.tools > 0) {
|
||||
segments.push(`${counts.tools} tool${counts.tools > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (counts.reasoning > 0) {
|
||||
segments.push(`${counts.reasoning} reasoning`);
|
||||
}
|
||||
if (counts.justifications > 0) {
|
||||
segments.push(`${counts.justifications} justification${counts.justifications > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
return segments.join(', ');
|
||||
};
|
||||
|
||||
const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => {
|
||||
return [...parts].sort((a, b) => {
|
||||
const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined;
|
||||
const bTime = typeof b.endedAt === 'number' ? b.endedAt : undefined;
|
||||
|
||||
if (aTime === undefined && bTime === undefined) return 0;
|
||||
if (aTime === undefined) return 1;
|
||||
if (bTime === undefined) return -1;
|
||||
|
||||
return aTime - bTime;
|
||||
});
|
||||
};
|
||||
|
||||
const getToolConnections = (
|
||||
parts: TurnActivityPart[]
|
||||
): Record<string, { hasPrev: boolean; hasNext: boolean }> => {
|
||||
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
|
||||
const toolParts = parts.filter((p) => p.kind === 'tool');
|
||||
|
||||
toolParts.forEach((activity, index) => {
|
||||
const partId = activity.part.id;
|
||||
if (partId) {
|
||||
connections[partId] = {
|
||||
hasPrev: index > 0,
|
||||
hasNext: index < toolParts.length - 1,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return connections;
|
||||
};
|
||||
|
||||
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
||||
parts,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
syntaxTheme,
|
||||
isMobile,
|
||||
expandedTools,
|
||||
onToggleTool,
|
||||
onContentChange,
|
||||
isWorking,
|
||||
previewedPartIds,
|
||||
diffStats,
|
||||
}) => {
|
||||
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousExpandedRef.current === isExpanded) return;
|
||||
previousExpandedRef.current = isExpanded;
|
||||
onContentChange?.('structural');
|
||||
}, [isExpanded, onContentChange]);
|
||||
|
||||
const displayParts = React.useMemo(() => {
|
||||
if (!isWorking) {
|
||||
|
||||
return sortPartsByTime(parts);
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
|
||||
return sortPartsByTime(parts);
|
||||
}
|
||||
|
||||
return sortPartsByTime(
|
||||
parts.filter((activity) => {
|
||||
const partId = activity.part.id;
|
||||
return partId && previewedPartIds.has(activity.id);
|
||||
})
|
||||
);
|
||||
}, [parts, isWorking, isExpanded, previewedPartIds]);
|
||||
|
||||
const summary = getGroupSummary(displayParts);
|
||||
const toolConnections = getToolConnections(displayParts);
|
||||
|
||||
if (displayParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FadeInOnReveal>
|
||||
<div className="my-1">
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl cursor-pointer'
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{}
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
>
|
||||
<RiStackLine className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && isMobile && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="typography-meta font-medium">Activity</span>
|
||||
</div>
|
||||
|
||||
{(summary || diffStats) && (
|
||||
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
|
||||
{summary && (
|
||||
<span className="truncate block">{summary}</span>
|
||||
)}
|
||||
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
|
||||
<span className="flex-shrink-0 leading-none">
|
||||
<span className="text-[color:var(--status-success)]">
|
||||
+{Math.max(0, diffStats.additions)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="text-destructive">
|
||||
-{Math.max(0, diffStats.deletions)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative pr-2 pb-1 pt-1 pl-[1.4375rem]',
|
||||
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
|
||||
'before:top-[-0.25rem] before:bottom-0'
|
||||
)}
|
||||
>
|
||||
{displayParts.map((activity, index) => {
|
||||
const partId = activity.part.id || `group-part-${index}`;
|
||||
const connection = toolConnections[partId];
|
||||
|
||||
switch (activity.kind) {
|
||||
case 'tool':
|
||||
return (
|
||||
<FadeInOnReveal key={partId}>
|
||||
<ToolPart
|
||||
part={activity.part as ToolPartType}
|
||||
isExpanded={expandedTools.has(partId)}
|
||||
onToggle={onToggleTool}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
onContentChange={onContentChange}
|
||||
hasPrevTool={connection?.hasPrev ?? false}
|
||||
hasNextTool={connection?.hasNext ?? false}
|
||||
/>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
|
||||
case 'reasoning':
|
||||
return (
|
||||
<FadeInOnReveal key={partId}>
|
||||
<ReasoningPart
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
|
||||
case 'justification':
|
||||
return (
|
||||
<FadeInOnReveal key={partId}>
|
||||
<JustificationBlock
|
||||
part={activity.part}
|
||||
messageId={activity.messageId}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ProgressiveGroup);
|
||||
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string };
|
||||
|
||||
export type ReasoningVariant = 'thinking' | 'justification';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type IconComponent = ComponentType<any>;
|
||||
|
||||
const variantConfig: Record<
|
||||
ReasoningVariant,
|
||||
{ label: string; Icon: IconComponent }
|
||||
> = {
|
||||
thinking: { label: 'Thinking', Icon: RiBrainAi3Line },
|
||||
justification: { label: 'Justification', Icon: RiChatAi3Line },
|
||||
};
|
||||
|
||||
const cleanReasoningText = (text: string): string => {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line: string) => line.replace(/^>\s?/, '').trimEnd())
|
||||
.filter((line: string) => line.trim().length > 0)
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const getReasoningSummary = (text: string): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
const newlineIndex = trimmed.indexOf('\n');
|
||||
const periodIndex = trimmed.indexOf('.');
|
||||
|
||||
const cutoffCandidates = [
|
||||
newlineIndex >= 0 ? newlineIndex : Infinity,
|
||||
periodIndex >= 0 ? periodIndex : Infinity,
|
||||
];
|
||||
const cutoff = Math.min(...cutoffCandidates);
|
||||
|
||||
if (!Number.isFinite(cutoff)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed.substring(0, cutoff).trim();
|
||||
};
|
||||
|
||||
type ReasoningTimelineBlockProps = {
|
||||
text: string;
|
||||
variant: ReasoningVariant;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
blockId: string;
|
||||
};
|
||||
|
||||
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||
text,
|
||||
variant,
|
||||
onContentChange,
|
||||
blockId,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||
const { label, Icon } = variantConfig[variant];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
onContentChange?.('structural');
|
||||
}, [onContentChange, isExpanded, text]);
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1" data-reasoning-block-id={blockId}>
|
||||
<div
|
||||
className={cn(
|
||||
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
|
||||
)}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
<span className="typography-meta font-medium">{label}</span>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
||||
<span className="truncate block">{summary}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative pr-2 pb-2 pt-2 pl-[1.4375rem]',
|
||||
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
|
||||
'before:top-[-0.25rem] before:bottom-0'
|
||||
)}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
as="blockquote"
|
||||
outerClassName="max-h-80"
|
||||
className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70 p-0"
|
||||
>
|
||||
{text}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ReasoningPartProps = {
|
||||
part: Part;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
||||
part,
|
||||
onContentChange,
|
||||
messageId,
|
||||
}) => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text || partWithText.content || '';
|
||||
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
||||
|
||||
const timeInfo = 'time' in part ? (part.time as { start: number; end?: number }) : null;
|
||||
if (!timeInfo?.end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningTimelineBlock
|
||||
text={textContent}
|
||||
variant="thinking"
|
||||
onContentChange={onContentChange}
|
||||
blockId={part.id || `${messageId}-reasoning`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const formatReasoningText = (text: string): string => cleanReasoningText(text);
|
||||
|
||||
export default ReasoningPart;
|
||||
@@ -0,0 +1,765 @@
|
||||
|
||||
import React from 'react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getToolMetadata, getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk';
|
||||
import { toolDisplayStyles } from '@/lib/typography';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
|
||||
import {
|
||||
renderListOutput,
|
||||
renderGrepOutput,
|
||||
renderGlobOutput,
|
||||
renderTodoOutput,
|
||||
renderWebSearchOutput,
|
||||
parseDiffToUnified,
|
||||
formatEditOutput,
|
||||
detectLanguageFromOutput,
|
||||
formatInputForDisplay,
|
||||
hasLspDiagnostics,
|
||||
} from '../toolRenderers';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
|
||||
interface ToolPartProps {
|
||||
part: ToolPartType;
|
||||
isExpanded: boolean;
|
||||
onToggle: (toolId: string) => void;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
isMobile: boolean;
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
hasPrevTool?: boolean;
|
||||
hasNextTool?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const getToolIcon = (toolName: string) => {
|
||||
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||
const tool = toolName.toLowerCase();
|
||||
|
||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||
return <RiPencilLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||
return <RiFileEditLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
|
||||
return <RiFileTextLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
|
||||
return <RiTerminalBoxLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
|
||||
return <RiFolder6Line className={iconClass} />;
|
||||
}
|
||||
if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
|
||||
return <RiMenuSearchLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'glob') {
|
||||
return <RiFileSearchLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
|
||||
return <RiGlobalLine className={iconClass} />;
|
||||
}
|
||||
if (
|
||||
tool === 'web-search' ||
|
||||
tool === 'websearch' ||
|
||||
tool === 'search_web' ||
|
||||
tool === 'codesearch' ||
|
||||
tool === 'google' ||
|
||||
tool === 'bing' ||
|
||||
tool === 'duckduckgo' ||
|
||||
tool === 'perplexity'
|
||||
) {
|
||||
return <RiGlobalLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'todowrite' || tool === 'todoread') {
|
||||
return <RiListCheck3 className={iconClass} />;
|
||||
}
|
||||
if (tool.startsWith('git')) {
|
||||
return <RiGitBranchLine className={iconClass} />;
|
||||
}
|
||||
return <RiToolsLine className={iconClass} />;
|
||||
};
|
||||
|
||||
const formatDuration = (start: number, end?: number) => {
|
||||
const duration = end ? end - start : Date.now() - start;
|
||||
const seconds = duration / 1000;
|
||||
|
||||
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
|
||||
return `${displaySeconds.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => {
|
||||
if (!metadata?.diff || typeof metadata.diff !== 'string') return null;
|
||||
|
||||
const lines = metadata.diff.split('\n');
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) added++;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) removed++;
|
||||
}
|
||||
|
||||
if (added === 0 && removed === 0) return null;
|
||||
return { added, removed };
|
||||
};
|
||||
|
||||
const getRelativePath = (absolutePath: string, currentDirectory: string, isMobile: boolean): string => {
|
||||
|
||||
if (isMobile) {
|
||||
return absolutePath.split('/').pop() || absolutePath;
|
||||
}
|
||||
|
||||
if (absolutePath.startsWith(currentDirectory)) {
|
||||
const relativePath = absolutePath.substring(currentDirectory.length);
|
||||
|
||||
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
};
|
||||
|
||||
const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: boolean, currentDirectory: string): string => {
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
const input = stateWithData.input;
|
||||
|
||||
if ((part.tool === 'edit' || part.tool === 'multiedit') && input) {
|
||||
const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
|
||||
if (typeof filePath === 'string') {
|
||||
return getRelativePath(filePath, currentDirectory, isMobile);
|
||||
}
|
||||
}
|
||||
|
||||
if ((part.tool === 'read' || part.tool === 'write') && input) {
|
||||
const filePath = input?.filePath || input?.file_path || input?.path;
|
||||
if (typeof filePath === 'string') {
|
||||
return getRelativePath(filePath, currentDirectory, isMobile);
|
||||
}
|
||||
}
|
||||
|
||||
if (part.tool === 'bash' && input?.command && typeof input.command === 'string') {
|
||||
const firstLine = input.command.split('\n')[0];
|
||||
return isMobile ? firstLine.substring(0, 50) : firstLine.substring(0, 100);
|
||||
}
|
||||
|
||||
if (part.tool === 'task' && input?.description && typeof input.description === 'string') {
|
||||
return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80);
|
||||
}
|
||||
|
||||
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
|
||||
return typeof desc === 'string' ? desc : '';
|
||||
};
|
||||
|
||||
interface ToolScrollableSectionProps {
|
||||
children: React.ReactNode;
|
||||
maxHeightClass?: string;
|
||||
className?: string;
|
||||
outerClassName?: string;
|
||||
disableHorizontal?: boolean;
|
||||
}
|
||||
|
||||
const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
|
||||
children,
|
||||
maxHeightClass = 'max-h-[60vh]',
|
||||
className,
|
||||
outerClassName,
|
||||
disableHorizontal = false,
|
||||
}) => (
|
||||
<ScrollableOverlay
|
||||
outerClassName={cn('w-full min-w-0 flex-none overflow-hidden', maxHeightClass, outerClassName)}
|
||||
className={cn('p-2 rounded-xl w-full min-w-0 border border-border/20 bg-muted/30', className)}
|
||||
disableHorizontal={disableHorizontal}
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
|
||||
interface DiffPreviewProps {
|
||||
diff: string;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
input?: ToolStateWithMetadata['input'];
|
||||
}
|
||||
|
||||
const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) => (
|
||||
<div className="typography-meta px-1 pb-1 pt-0 space-y-0">
|
||||
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
|
||||
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => (
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-meta font-mono px-2 py-0.5 flex -mx-2',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
)}
|
||||
style={
|
||||
line.type === 'removed'
|
||||
? { backgroundColor: 'var(--tools-edit-removed-bg)' }
|
||||
: line.type === 'added'
|
||||
? { backgroundColor: 'var(--tools-edit-added-bg)' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-2 self-start select-none">
|
||||
{line.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={getLanguageFromExtension(typeof input?.file_path === 'string' ? input.file_path : typeof input?.filePath === 'string' ? input.filePath : hunk.file) || 'text'}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent !important' },
|
||||
}}
|
||||
>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface WriteInputPreviewProps {
|
||||
content: string;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
filePath?: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTheme, filePath, displayPath }) => {
|
||||
const lines = content.split('\n');
|
||||
const language = getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined);
|
||||
|
||||
const lineCount = Math.max(lines.length, 1);
|
||||
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border border-border/10 rounded-lg mb-1">
|
||||
{`${displayPath} (${headerLineLabel})`}
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{lines.map((line, lineIdx) => (
|
||||
<div key={lineIdx} className="typography-meta font-mono px-2 py-0.5 flex -mx-1">
|
||||
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-2 self-start select-none">
|
||||
{lineIdx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={language || 'text'}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: { background: 'transparent !important' },
|
||||
}}
|
||||
>
|
||||
{line || ' '}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ToolExpandedContentProps {
|
||||
part: ToolPartType;
|
||||
state: ToolStateUnion;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
isMobile: boolean;
|
||||
currentDirectory: string;
|
||||
hasPrevTool: boolean;
|
||||
hasNextTool: boolean;
|
||||
}
|
||||
|
||||
const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
|
||||
part,
|
||||
state,
|
||||
syntaxTheme,
|
||||
isMobile,
|
||||
currentDirectory,
|
||||
hasPrevTool,
|
||||
hasNextTool,
|
||||
}) => {
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
const input = stateWithData.input;
|
||||
const rawOutput = stateWithData.output;
|
||||
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
|
||||
const outputString = typeof rawOutput === 'string' ? rawOutput : '';
|
||||
|
||||
const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null;
|
||||
const writeFilePath = part.tool === 'write'
|
||||
? typeof input?.filePath === 'string'
|
||||
? input.filePath
|
||||
: typeof input?.file_path === 'string'
|
||||
? input.file_path
|
||||
: typeof input?.path === 'string'
|
||||
? input.path
|
||||
: undefined
|
||||
: undefined;
|
||||
const writeInputContent = part.tool === 'write'
|
||||
? typeof (input as { content?: unknown })?.content === 'string'
|
||||
? (input as { content?: string }).content
|
||||
: typeof (input as { text?: unknown })?.text === 'string'
|
||||
? (input as { text?: string }).text
|
||||
: null
|
||||
: null;
|
||||
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
|
||||
const writeDisplayPath = shouldShowWriteInputPreview
|
||||
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory, isMobile) : 'New file')
|
||||
: null;
|
||||
|
||||
const inputTextContent = React.useMemo(() => {
|
||||
if (!input || typeof input !== 'object' || Object.keys(input).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ('command' in input && typeof input.command === 'string' && part.tool === 'bash') {
|
||||
return formatInputForDisplay(input, part.tool);
|
||||
}
|
||||
|
||||
if (typeof (input as { content?: unknown }).content === 'string') {
|
||||
return (input as { content?: string }).content ?? '';
|
||||
}
|
||||
|
||||
return formatInputForDisplay(input, part.tool);
|
||||
}, [input, part.tool]);
|
||||
const hasInputText = inputTextContent.trim().length > 0;
|
||||
|
||||
const renderScrollableBlock = (
|
||||
content: React.ReactNode,
|
||||
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
|
||||
) => (
|
||||
<ToolScrollableSection
|
||||
maxHeightClass={options?.maxHeightClass}
|
||||
className={options?.className}
|
||||
disableHorizontal={options?.disableHorizontal}
|
||||
outerClassName={options?.outerClassName}
|
||||
>
|
||||
{content}
|
||||
</ToolScrollableSection>
|
||||
);
|
||||
|
||||
const renderResultContent = () => {
|
||||
if (part.tool === 'todowrite' || part.tool === 'todoread') {
|
||||
if (state.status === 'completed' && hasStringOutput) {
|
||||
const todoContent = renderTodoOutput(outputString, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
todoContent ?? (
|
||||
<div className="typography-meta text-muted-foreground">Unable to parse todo list</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === 'error' && 'error' in state) {
|
||||
return (
|
||||
<div>
|
||||
<div className="typography-meta font-medium text-muted-foreground mb-1">Error:</div>
|
||||
<div className="typography-meta p-2 rounded-xl border" style={{
|
||||
backgroundColor: 'var(--status-error-background)',
|
||||
color: 'var(--status-error)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}>
|
||||
{state.error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="typography-meta text-muted-foreground">Processing todo list...</div>;
|
||||
}
|
||||
|
||||
if (part.tool === 'list' && hasStringOutput) {
|
||||
const listOutput = renderListOutput(outputString, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
listOutput ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (part.tool === 'grep' && hasStringOutput) {
|
||||
const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
grepOutput ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (part.tool === 'glob' && hasStringOutput) {
|
||||
const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
globOutput ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (part.tool === 'task' && hasStringOutput) {
|
||||
return renderScrollableBlock(
|
||||
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
|
||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
||||
{outputString}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((part.tool === 'web-search' || part.tool === 'websearch' || part.tool === 'search_web') && hasStringOutput) {
|
||||
const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true });
|
||||
return renderScrollableBlock(
|
||||
webSearchContent ?? (
|
||||
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
|
||||
{outputString}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (part.tool === 'codesearch' && hasStringOutput) {
|
||||
return renderScrollableBlock(
|
||||
<div className="w-full min-w-0" style={{ fontSize: 'var(--text-code)' }}>
|
||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
||||
{outputString}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((part.tool === 'edit' || part.tool === 'multiedit') && ((!hasStringOutput && diffContent) || (outputString.trim().length === 0 || hasLspDiagnostics(outputString))) && diffContent) {
|
||||
return renderScrollableBlock(
|
||||
<DiffPreview diff={diffContent} syntaxTheme={syntaxTheme} input={input} />,
|
||||
{ className: 'p-1' }
|
||||
);
|
||||
}
|
||||
|
||||
if (hasStringOutput && outputString.trim()) {
|
||||
if (part.tool === 'read') {
|
||||
const formattedOutput = formatEditOutput(outputString, part.tool, metadata);
|
||||
const lines = formattedOutput.split('\n');
|
||||
const offset = typeof input?.offset === 'number' ? input.offset : 0;
|
||||
const limit = typeof input?.limit === 'number' ? input.limit : undefined;
|
||||
const isInfoMessage = (line: string) => line.trim().startsWith('(');
|
||||
|
||||
return renderScrollableBlock(
|
||||
<div className="typography-meta w-full min-w-0 space-y-1">
|
||||
{lines.map((line: string, idx: number) => {
|
||||
const isInfo = isInfoMessage(line);
|
||||
const lineNumber = offset + idx + 1;
|
||||
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
|
||||
|
||||
return (
|
||||
<div key={idx} className={cn('typography-meta font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={detectLanguageFromOutput(formattedOutput, part.tool, input as Record<string, unknown>)}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
background: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
{ className: 'p-1' }
|
||||
);
|
||||
}
|
||||
|
||||
return renderScrollableBlock(
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={detectLanguageFromOutput(formatEditOutput(outputString, part.tool, metadata), part.tool, input)}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
...toolDisplayStyles.getCollapsedStyles(),
|
||||
padding: 0,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
background: 'transparent !important',
|
||||
},
|
||||
}}
|
||||
wrapLongLines
|
||||
>
|
||||
{formatEditOutput(outputString, part.tool, metadata)}
|
||||
</SyntaxHighlighter>,
|
||||
{ className: 'p-1' }
|
||||
);
|
||||
}
|
||||
|
||||
return renderScrollableBlock(
|
||||
<div className="typography-meta text-muted-foreground/70">No output produced</div>,
|
||||
{ maxHeightClass: 'max-h-60' }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]',
|
||||
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
|
||||
hasPrevTool ? 'before:top-[-0.45rem]' : 'before:top-[-0.25rem]',
|
||||
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
|
||||
)}
|
||||
>
|
||||
{(part.tool === 'todowrite' || part.tool === 'todoread') ? (
|
||||
renderResultContent()
|
||||
) : (
|
||||
<>
|
||||
{shouldShowWriteInputPreview ? (
|
||||
<div className="my-1">
|
||||
{renderScrollableBlock(
|
||||
<WriteInputPreview
|
||||
content={writeInputContent as string}
|
||||
syntaxTheme={syntaxTheme}
|
||||
filePath={writeFilePath}
|
||||
displayPath={writeDisplayPath ?? 'New file'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : hasInputText ? (
|
||||
<div className="my-1">
|
||||
{renderScrollableBlock(
|
||||
<blockquote className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
|
||||
{inputTextContent}
|
||||
</blockquote>,
|
||||
{ maxHeightClass: 'max-h-60' }
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
|
||||
<div>
|
||||
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">
|
||||
Result:
|
||||
</div>
|
||||
{renderResultContent()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && 'error' in state && (
|
||||
<div>
|
||||
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">Error:</div>
|
||||
<div className="typography-meta p-2 rounded-xl border" style={{
|
||||
backgroundColor: 'var(--status-error-background)',
|
||||
color: 'var(--status-error)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}>
|
||||
{state.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => {
|
||||
const state = part.state;
|
||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
|
||||
const isFinalized = state.status === 'completed' || state.status === 'error';
|
||||
const isRunning = state.status === 'running';
|
||||
const isError = state.status === 'error';
|
||||
|
||||
const [currentTime, setCurrentTime] = React.useState(Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isRunning) {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 100);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [isRunning]);
|
||||
|
||||
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isFinalized) {
|
||||
return;
|
||||
}
|
||||
if (previousExpandedRef.current === isExpanded) {
|
||||
return;
|
||||
}
|
||||
previousExpandedRef.current = isExpanded;
|
||||
if (typeof isExpanded === 'boolean') {
|
||||
onContentChange?.('structural');
|
||||
}
|
||||
}, [isExpanded, isFinalized, onContentChange]);
|
||||
|
||||
const stateWithData = state as ToolStateWithMetadata;
|
||||
const metadata = stateWithData.metadata;
|
||||
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null;
|
||||
const description = getToolDescription(part, state, isMobile, currentDirectory);
|
||||
const displayName = getToolMetadata(part.tool).displayName;
|
||||
|
||||
if (!isFinalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
|
||||
)}
|
||||
onClick={() => onToggle(part.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{}
|
||||
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity',
|
||||
isExpanded && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
|
||||
)}
|
||||
style={isError ? { color: 'var(--status-error)' } : {}}
|
||||
>
|
||||
{getToolIcon(part.tool)}
|
||||
</div>
|
||||
{}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-opacity flex items-center justify-center',
|
||||
isExpanded && 'opacity-100',
|
||||
!isExpanded && isMobile && 'opacity-0',
|
||||
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="typography-meta font-medium"
|
||||
style={isError ? { color: 'var(--status-error)' } : {}}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
||||
{description && (
|
||||
<span className={cn("truncate", isMobile && "max-w-[120px]")}>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
{diffStats && (
|
||||
<span className="text-muted-foreground/60 flex-shrink-0">
|
||||
<span style={{ color: 'var(--status-success)' }}>+{diffStats.added}</span>
|
||||
{' '}
|
||||
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
|
||||
</span>
|
||||
)}
|
||||
{'time' in state && state.time && (
|
||||
<span className="text-muted-foreground/80 flex-shrink-0">
|
||||
{formatDuration(state.time.start, isFinalized && 'end' in state.time ? state.time.end : currentTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{isExpanded && (
|
||||
<ToolExpandedContent
|
||||
part={part}
|
||||
state={state}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
currentDirectory={currentDirectory}
|
||||
hasPrevTool={hasPrevTool}
|
||||
hasNextTool={hasNextTool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolPart;
|
||||
@@ -0,0 +1,357 @@
|
||||
import React from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Part } from '@opencode-ai/sdk';
|
||||
import type { AgentMentionInfo } from '../types';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||
|
||||
const SHIKI_THEMES = ['vitesse-light', 'vitesse-dark'] as const;
|
||||
|
||||
const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!codeRef.current) return;
|
||||
const codeEl = codeRef.current.querySelector('code');
|
||||
const code = codeEl?.innerText || '';
|
||||
if (!code) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('group relative', className)} ref={codeRef}>
|
||||
{children}
|
||||
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Table utility functions
|
||||
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
|
||||
const headers: string[] = [];
|
||||
const rows: string[][] = [];
|
||||
|
||||
const thead = tableEl.querySelector('thead');
|
||||
if (thead) {
|
||||
const headerCells = thead.querySelectorAll('th');
|
||||
headerCells.forEach(cell => headers.push(cell.innerText.trim()));
|
||||
}
|
||||
|
||||
const tbody = tableEl.querySelector('tbody');
|
||||
if (tbody) {
|
||||
const rowEls = tbody.querySelectorAll('tr');
|
||||
rowEls.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
const rowData: string[] = [];
|
||||
cells.forEach(cell => rowData.push(cell.innerText.trim()));
|
||||
rows.push(rowData);
|
||||
});
|
||||
}
|
||||
|
||||
return { headers, rows };
|
||||
};
|
||||
|
||||
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||
const escapeCell = (cell: string): string => {
|
||||
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
if (headers.length > 0) {
|
||||
lines.push(headers.map(escapeCell).join(','));
|
||||
}
|
||||
rows.forEach(row => lines.push(row.map(escapeCell).join(',')));
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||
const escapeCell = (cell: string): string => {
|
||||
return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
if (headers.length > 0) {
|
||||
lines.push(headers.map(escapeCell).join('\t'));
|
||||
}
|
||||
rows.forEach(row => lines.push(row.map(escapeCell).join('\t')));
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
|
||||
if (headers.length === 0) return '';
|
||||
|
||||
const escapeCell = (cell: string): string => {
|
||||
return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`| ${headers.map(escapeCell).join(' | ')} |`);
|
||||
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
|
||||
rows.forEach(row => {
|
||||
const paddedRow = headers.map((_, i) => escapeCell(row[i] || ''));
|
||||
lines.push(`| ${paddedRow.join(' | ')} |`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const downloadFile = (filename: string, content: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Table copy button with dropdown
|
||||
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [showMenu, setShowMenu] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (format: 'csv' | 'tsv') => {
|
||||
const tableEl = tableRef.current?.querySelector('table');
|
||||
if (!tableEl) return;
|
||||
|
||||
try {
|
||||
const data = extractTableData(tableEl);
|
||||
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/plain': new Blob([content], { type: 'text/plain' }),
|
||||
'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }),
|
||||
}),
|
||||
]);
|
||||
setCopied(true);
|
||||
setShowMenu(false);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy table:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy table"
|
||||
>
|
||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||
onClick={() => handleCopy('csv')}
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||
onClick={() => handleCopy('tsv')}
|
||||
>
|
||||
TSV
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Table download button with dropdown
|
||||
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
|
||||
const [showMenu, setShowMenu] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleDownload = (format: 'csv' | 'markdown') => {
|
||||
const tableEl = tableRef.current?.querySelector('table');
|
||||
if (!tableEl) return;
|
||||
|
||||
try {
|
||||
const data = extractTableData(tableEl);
|
||||
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
|
||||
const filename = format === 'csv' ? 'table.csv' : 'table.md';
|
||||
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
|
||||
downloadFile(filename, content, mimeType);
|
||||
setShowMenu(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to download table:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Download table"
|
||||
>
|
||||
<RiDownloadLine className="size-3.5" />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||
onClick={() => handleDownload('csv')}
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted/40"
|
||||
onClick={() => handleDownload('markdown')}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Table wrapper with custom controls
|
||||
const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||
const tableRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="group my-4 flex flex-col space-y-2" data-streamdown="table-wrapper" ref={tableRef}>
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<TableCopyButton tableRef={tableRef} />
|
||||
<TableDownloadButton tableRef={tableRef} />
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className={cn('w-full border-collapse border border-border', className)} data-streamdown="table">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const streamdownComponents = {
|
||||
pre: CodeBlockWrapper,
|
||||
table: TableWrapper,
|
||||
};
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
type UserTextPartProps = {
|
||||
part: Part;
|
||||
messageId: string;
|
||||
isMobile: boolean;
|
||||
agentMention?: AgentMentionInfo;
|
||||
};
|
||||
|
||||
const buildMentionLink = (token: string, name: string): string => {
|
||||
const encoded = encodeURIComponent(name);
|
||||
return `[${token}](https://opencode.ai/docs/agents/#${encoded})`;
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, isMobile, agentMention }) => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||
const textRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const processedText = React.useMemo(() => {
|
||||
if (!agentMention) {
|
||||
return textContent;
|
||||
}
|
||||
const token = agentMention.token;
|
||||
if (!token || token.length === 0) {
|
||||
return textContent;
|
||||
}
|
||||
if (!textContent.includes(token)) {
|
||||
return textContent;
|
||||
}
|
||||
const link = buildMentionLink(token, agentMention.name);
|
||||
return textContent.replace(token, link);
|
||||
}, [agentMention, textContent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el && !isExpanded) {
|
||||
setIsTruncated(el.scrollHeight > el.clientHeight);
|
||||
}
|
||||
}, [processedText, isExpanded]);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (isTruncated || isExpanded) {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [isTruncated, isExpanded]);
|
||||
|
||||
if (!processedText || processedText.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"break-words",
|
||||
!isExpanded && "line-clamp-3",
|
||||
(isTruncated || isExpanded) && "cursor-pointer"
|
||||
)}
|
||||
ref={textRef}
|
||||
onClick={handleClick}
|
||||
key={part.id || `${messageId}-user-text`}
|
||||
>
|
||||
<Streamdown
|
||||
mode="static"
|
||||
shikiTheme={SHIKI_THEMES}
|
||||
className={cn('streamdown-content streamdown-user', isMobile && 'streamdown-mobile')}
|
||||
controls={{ code: false, table: false }}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
{processedText}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UserTextPart);
|
||||
@@ -0,0 +1,404 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Text } from '@/components/ui/text';
|
||||
|
||||
interface WorkingPlaceholderProps {
|
||||
statusText: string | null;
|
||||
isWaitingForPermission?: boolean;
|
||||
wasAborted?: boolean;
|
||||
completionId?: string | null;
|
||||
isComplete?: boolean;
|
||||
}
|
||||
|
||||
const MIN_DISPLAY_TIME = 2000;
|
||||
const DONE_DISPLAY_TIME = 1500;
|
||||
|
||||
type ResultState = 'success' | 'aborted' | null;
|
||||
|
||||
export function WorkingPlaceholder({
|
||||
statusText,
|
||||
isWaitingForPermission,
|
||||
wasAborted,
|
||||
completionId,
|
||||
isComplete,
|
||||
}: WorkingPlaceholderProps) {
|
||||
const [displayedStatus, setDisplayedStatus] = useState<string | null>(null);
|
||||
const [displayedPermission, setDisplayedPermission] = useState<boolean>(false);
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
const [isFadingOut, setIsFadingOut] = useState<boolean>(false);
|
||||
const [resultState, setResultState] = useState<ResultState>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
|
||||
|
||||
const displayStartTimeRef = useRef<number>(0);
|
||||
const statusQueueRef = useRef<Array<{ status: string; permission: boolean }>>([]);
|
||||
const removalPendingRef = useRef<boolean>(false);
|
||||
const fadeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resultTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const transitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastActiveStatusRef = useRef<string | null>(null);
|
||||
const hasShownActivityRef = useRef<boolean>(false);
|
||||
const wasAbortedRef = useRef<boolean>(false);
|
||||
const isCompleteRef = useRef<boolean>(false);
|
||||
const windowFocusRef = useRef<boolean>(true);
|
||||
const lastCompletionShownRef = useRef<string | null>(null);
|
||||
const resultShownAtRef = useRef<number | null>(null);
|
||||
|
||||
const activateStatus = (status: string, permission: boolean) => {
|
||||
if (fadeTimeoutRef.current) {
|
||||
clearTimeout(fadeTimeoutRef.current);
|
||||
fadeTimeoutRef.current = null;
|
||||
}
|
||||
if (resultTimeoutRef.current) {
|
||||
clearTimeout(resultTimeoutRef.current);
|
||||
resultTimeoutRef.current = null;
|
||||
}
|
||||
if (transitionTimeoutRef.current) {
|
||||
clearTimeout(transitionTimeoutRef.current);
|
||||
transitionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (status === 'aborted') {
|
||||
setDisplayedStatus(null);
|
||||
setDisplayedPermission(false);
|
||||
setIsFadingOut(false);
|
||||
setResultState('aborted');
|
||||
setIsTransitioning(false);
|
||||
lastActiveStatusRef.current = 'aborted';
|
||||
hasShownActivityRef.current = true;
|
||||
wasAbortedRef.current = true;
|
||||
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => setIsVisible(true));
|
||||
} else {
|
||||
setIsVisible(true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setResultState(null);
|
||||
setIsFadingOut(false);
|
||||
lastActiveStatusRef.current = status;
|
||||
hasShownActivityRef.current = true;
|
||||
|
||||
const isStatusChanging = displayedStatus !== null && displayedStatus !== status;
|
||||
|
||||
if (isStatusChanging) {
|
||||
|
||||
setIsTransitioning(true);
|
||||
transitionTimeoutRef.current = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
transitionTimeoutRef.current = null;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
setDisplayedStatus(status);
|
||||
setDisplayedPermission(permission);
|
||||
|
||||
if (!isVisible) {
|
||||
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => {
|
||||
setIsVisible(true);
|
||||
});
|
||||
} else {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
|
||||
if (statusText) {
|
||||
removalPendingRef.current = false;
|
||||
|
||||
if (!displayedStatus) {
|
||||
activateStatus(statusText, !!isWaitingForPermission);
|
||||
displayStartTimeRef.current = now;
|
||||
statusQueueRef.current = [];
|
||||
} else if (
|
||||
statusText !== displayedStatus ||
|
||||
!!isWaitingForPermission !== displayedPermission
|
||||
) {
|
||||
statusQueueRef.current.push({
|
||||
status: statusText,
|
||||
permission: !!isWaitingForPermission,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
removalPendingRef.current = true;
|
||||
}
|
||||
|
||||
}, [statusText, isWaitingForPermission, displayedStatus, displayedPermission, wasAborted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasAborted) {
|
||||
wasAbortedRef.current = true;
|
||||
}
|
||||
}, [wasAborted]);
|
||||
|
||||
useEffect(() => {
|
||||
isCompleteRef.current = !!isComplete;
|
||||
}, [isComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
removalPendingRef.current = true;
|
||||
}
|
||||
}, [isComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
const startFadeOut = (result: ResultState) => {
|
||||
if (isFadingOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hadActiveStatus =
|
||||
lastActiveStatusRef.current !== null || hasShownActivityRef.current;
|
||||
|
||||
if (result && hadActiveStatus) {
|
||||
|
||||
setIsFadingOut(false);
|
||||
setIsVisible(true);
|
||||
|
||||
setDisplayedStatus(null);
|
||||
setDisplayedPermission(false);
|
||||
setResultState(result);
|
||||
lastActiveStatusRef.current = null;
|
||||
|
||||
setIsTransitioning(false);
|
||||
|
||||
if (result === 'success' && completionId) {
|
||||
lastCompletionShownRef.current = completionId;
|
||||
}
|
||||
|
||||
resultShownAtRef.current = Date.now();
|
||||
|
||||
if (resultTimeoutRef.current) {
|
||||
clearTimeout(resultTimeoutRef.current);
|
||||
}
|
||||
|
||||
resultTimeoutRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setResultState(null);
|
||||
hasShownActivityRef.current = false;
|
||||
resultTimeoutRef.current = null;
|
||||
}, DONE_DISPLAY_TIME);
|
||||
} else {
|
||||
|
||||
setIsFadingOut(true);
|
||||
setIsVisible(false);
|
||||
setResultState(null);
|
||||
|
||||
if (fadeTimeoutRef.current) {
|
||||
clearTimeout(fadeTimeoutRef.current);
|
||||
}
|
||||
|
||||
fadeTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayedStatus(null);
|
||||
setDisplayedPermission(false);
|
||||
setIsFadingOut(false);
|
||||
hasShownActivityRef.current = false;
|
||||
lastActiveStatusRef.current = null;
|
||||
fadeTimeoutRef.current = null;
|
||||
}, 180);
|
||||
}
|
||||
|
||||
wasAbortedRef.current = false;
|
||||
};
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - displayStartTimeRef.current;
|
||||
|
||||
const isDone = removalPendingRef.current && isCompleteRef.current;
|
||||
|
||||
const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0;
|
||||
|
||||
if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (removalPendingRef.current && wasAbortedRef.current) {
|
||||
removalPendingRef.current = false;
|
||||
statusQueueRef.current = [];
|
||||
startFadeOut('aborted');
|
||||
} else if (!isDone && statusQueueRef.current.length > 0) {
|
||||
const latest = statusQueueRef.current[statusQueueRef.current.length - 1];
|
||||
activateStatus(latest.status, latest.permission);
|
||||
displayStartTimeRef.current = now;
|
||||
statusQueueRef.current = [];
|
||||
} else if (removalPendingRef.current) {
|
||||
|
||||
removalPendingRef.current = false;
|
||||
|
||||
if (statusQueueRef.current.length > 0) {
|
||||
hasShownActivityRef.current = true;
|
||||
}
|
||||
statusQueueRef.current = [];
|
||||
|
||||
let result: ResultState = null;
|
||||
if (wasAbortedRef.current) {
|
||||
result = 'aborted';
|
||||
} else if (isCompleteRef.current) {
|
||||
result = 'success';
|
||||
|
||||
hasShownActivityRef.current = true;
|
||||
}
|
||||
|
||||
if (result === 'success' && completionId && lastCompletionShownRef.current === completionId) {
|
||||
setDisplayedStatus(null);
|
||||
setDisplayedPermission(false);
|
||||
setIsFadingOut(false);
|
||||
setIsVisible(false);
|
||||
setResultState(null);
|
||||
statusQueueRef.current = [];
|
||||
hasShownActivityRef.current = false;
|
||||
lastActiveStatusRef.current = null;
|
||||
removalPendingRef.current = false;
|
||||
wasAbortedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
startFadeOut(result);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
|
||||
}, [isFadingOut]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fadeTimeoutRef.current) {
|
||||
clearTimeout(fadeTimeoutRef.current);
|
||||
}
|
||||
if (resultTimeoutRef.current) {
|
||||
clearTimeout(resultTimeoutRef.current);
|
||||
}
|
||||
if (transitionTimeoutRef.current) {
|
||||
clearTimeout(transitionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
windowFocusRef.current = typeof document !== 'undefined' && typeof document.hasFocus === 'function'
|
||||
? document.hasFocus()
|
||||
: true;
|
||||
|
||||
const handleFocus = () => {
|
||||
windowFocusRef.current = true;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
windowFocusRef.current = false;
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityRestore = () => {
|
||||
if (typeof document === 'undefined' || typeof Date === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
const shownAt = resultShownAtRef.current;
|
||||
const isCompletionVisible = resultState !== null || displayedStatus !== null;
|
||||
|
||||
if (isCompletionVisible && shownAt && Date.now() - shownAt > 500) {
|
||||
setDisplayedStatus(null);
|
||||
setDisplayedPermission(false);
|
||||
setIsFadingOut(false);
|
||||
setIsVisible(false);
|
||||
setResultState(null);
|
||||
statusQueueRef.current = [];
|
||||
hasShownActivityRef.current = false;
|
||||
lastActiveStatusRef.current = null;
|
||||
removalPendingRef.current = false;
|
||||
wasAbortedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityRestore);
|
||||
window.addEventListener('focus', handleVisibilityRestore);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityRestore);
|
||||
window.removeEventListener('focus', handleVisibilityRestore);
|
||||
};
|
||||
}, [displayedStatus, resultState]);
|
||||
|
||||
if (!displayedStatus && resultState === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let label: string;
|
||||
if (resultState === 'success') {
|
||||
label = 'Done';
|
||||
} else if (resultState === 'aborted') {
|
||||
label = 'Aborted';
|
||||
} else if (displayedStatus) {
|
||||
label = displayedStatus.charAt(0).toUpperCase() + displayedStatus.slice(1);
|
||||
} else {
|
||||
label = 'Working';
|
||||
}
|
||||
|
||||
const ariaLive = displayedPermission ? 'assertive' : 'polite';
|
||||
|
||||
const displayText = resultState === null ? `${label}...` : label;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full items-center text-muted-foreground pl-[2ch] transition-opacity duration-200 ${isVisible && !isFadingOut ? 'opacity-100' : 'opacity-0'}`}
|
||||
role="status"
|
||||
aria-live={ariaLive}
|
||||
aria-label={label}
|
||||
data-waiting={displayedPermission ? 'true' : undefined}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{resultState === null && (
|
||||
<Text
|
||||
variant="shine"
|
||||
className="typography-ui-header transition-opacity duration-150"
|
||||
style={{ opacity: isTransitioning ? 0.6 : 1 }}
|
||||
>
|
||||
{displayText}
|
||||
</Text>
|
||||
)}
|
||||
{resultState === 'success' && (
|
||||
<Text
|
||||
variant="hover-enter"
|
||||
className="typography-ui-header transition-opacity duration-150"
|
||||
style={{ opacity: isTransitioning ? 0.6 : 1 }}
|
||||
>
|
||||
Done
|
||||
</Text>
|
||||
)}
|
||||
{resultState === 'aborted' && (
|
||||
<Text
|
||||
variant="hover-enter"
|
||||
className="typography-ui-header transition-opacity duration-150 text-status-error"
|
||||
style={{ opacity: isTransitioning ? 0.6 : 1 }}
|
||||
>
|
||||
Aborted
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user