Initial public release
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
interface AgentInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
mode?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentMentionAutocompleteHandle {
|
||||
handleKeyDown: (key: string) => void;
|
||||
}
|
||||
|
||||
interface AgentMentionAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onAgentSelect: (agentName: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const isMentionable = (mode?: string | null): boolean => {
|
||||
if (!mode) {
|
||||
return false;
|
||||
}
|
||||
return mode !== 'primary';
|
||||
};
|
||||
|
||||
export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocompleteHandle, AgentMentionAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onAgentSelect,
|
||||
onClose,
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||
const { agents: allAgents } = useConfigStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
const filtered = allAgents
|
||||
.filter((agent) => isMentionable(agent.mode))
|
||||
.map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
mode: agent.mode ?? undefined,
|
||||
}));
|
||||
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
const matches = normalizedQuery.length
|
||||
? filtered.filter((agent) => agent.name.toLowerCase().includes(normalizedQuery))
|
||||
: filtered;
|
||||
|
||||
matches.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
setAgents(matches);
|
||||
setSelectedIndex(0);
|
||||
}, [allAgents, searchQuery]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!containerRef.current.contains(target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
handleKeyDown: (key: string) => {
|
||||
if (key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agents.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % agents.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
const agent = agents[(selectedIndex + agents.length) % agents.length];
|
||||
if (agent) {
|
||||
onAgentSelect(agent.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
}), [agents, onAgentSelect, onClose, selectedIndex]);
|
||||
|
||||
const renderAgent = (agent: AgentInfo, index: number) => (
|
||||
<div
|
||||
key={agent.name}
|
||||
className={cn(
|
||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||
index === selectedIndex && 'bg-accent'
|
||||
)}
|
||||
onClick={() => onAgentSelect(agent.name)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">#{agent.name}</span>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<div className="typography-meta text-muted-foreground truncate">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-[240px] max-w-[360px] max-h-60 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
{agents.length ? (
|
||||
<div>
|
||||
{agents.map((agent, index) => renderAgent(agent, index))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
No agents found
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete';
|
||||
@@ -0,0 +1,255 @@
|
||||
import React from 'react';
|
||||
import { RiArrowDownLine } from '@remixicon/react';
|
||||
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import MessageList from './MessageList';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
|
||||
export const ChatContainer: React.FC = () => {
|
||||
const {
|
||||
currentSessionId,
|
||||
messages,
|
||||
permissions,
|
||||
streamingMessageIds,
|
||||
isLoading,
|
||||
loadMessages,
|
||||
loadMoreMessages,
|
||||
updateViewportAnchor,
|
||||
sessionMemoryState,
|
||||
isSyncing,
|
||||
messageStreamStates,
|
||||
trimToViewportWindow,
|
||||
sessionActivityPhase,
|
||||
} = useSessionStore();
|
||||
|
||||
const streamingMessageId = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
return streamingMessageIds.get(currentSessionId) ?? null;
|
||||
}, [currentSessionId, streamingMessageIds]);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const sessionMessages = React.useMemo(() => {
|
||||
|
||||
return currentSessionId ? messages.get(currentSessionId) || [] : [];
|
||||
}, [currentSessionId, messages]);
|
||||
|
||||
const sessionPermissions = React.useMemo(() => {
|
||||
return currentSessionId ? permissions.get(currentSessionId) || [] : [];
|
||||
}, [currentSessionId, permissions]);
|
||||
|
||||
const {
|
||||
scrollRef,
|
||||
handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
spacerHeight,
|
||||
pendingAnchorId,
|
||||
hasActiveAnchor,
|
||||
} = useChatScrollManager({
|
||||
currentSessionId,
|
||||
sessionMessages,
|
||||
streamingMessageId,
|
||||
sessionMemoryState,
|
||||
updateViewportAnchor,
|
||||
isSyncing,
|
||||
isMobile,
|
||||
messageStreamStates,
|
||||
sessionPermissions,
|
||||
trimToViewportWindow,
|
||||
sessionActivityPhase,
|
||||
});
|
||||
|
||||
const memoryState = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
return sessionMemoryState.get(currentSessionId) ?? null;
|
||||
}, [currentSessionId, sessionMemoryState]);
|
||||
const hasMoreAbove = Boolean(memoryState?.hasMoreAbove);
|
||||
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
setIsLoadingOlder(false);
|
||||
}, [currentSessionId]);
|
||||
|
||||
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
||||
React.useLayoutEffect(() => {
|
||||
if (!currentSessionId || currentSessionId === lastScrolledSessionRef.current) {
|
||||
return;
|
||||
}
|
||||
lastScrolledSessionRef.current = currentSessionId;
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight - container.clientHeight;
|
||||
}
|
||||
}, [currentSessionId, scrollRef]);
|
||||
|
||||
const handleLoadOlder = React.useCallback(async () => {
|
||||
if (!currentSessionId || isLoadingOlder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollRef.current;
|
||||
const prevHeight = container?.scrollHeight ?? null;
|
||||
const prevTop = container?.scrollTop ?? null;
|
||||
|
||||
setIsLoadingOlder(true);
|
||||
try {
|
||||
await loadMoreMessages(currentSessionId, 'up');
|
||||
if (container && prevHeight !== null && prevTop !== null) {
|
||||
const heightDiff = container.scrollHeight - prevHeight;
|
||||
container.scrollTop = prevTop + heightDiff;
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingOlder(false);
|
||||
}
|
||||
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSessionMessages = messages.has(currentSessionId);
|
||||
const existingMessages = hasSessionMessages ? messages.get(currentSessionId) ?? [] : [];
|
||||
|
||||
if (existingMessages.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
await loadMessages(currentSessionId);
|
||||
} finally {
|
||||
if (typeof window === 'undefined') {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
}, [currentSessionId, loadMessages, messages, scrollToBottom]);
|
||||
|
||||
if (!currentSessionId) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
|
||||
const hasMessagesEntry = messages.has(currentSessionId);
|
||||
if (!hasMessagesEntry) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background gap-0">
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
||||
<div className="chat-column space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex gap-3 p-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionMessages.length === 0 && !streamingMessageId) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
</div>
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
contain: 'strict',
|
||||
|
||||
['--scroll-shadow-size' as string]: '48px',
|
||||
}}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
hideBottomShadow={!!pendingAnchorId}
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
<MessageList
|
||||
messages={sessionMessages}
|
||||
permissions={sessionPermissions}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
hasMoreAbove={hasMoreAbove}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
onLoadOlder={handleLoadOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
pendingAnchorId={pendingAnchorId}
|
||||
/>
|
||||
{}
|
||||
{spacerHeight > 0 && hasActiveAnchor && (
|
||||
<div
|
||||
data-role="active-turn-spacer"
|
||||
style={{ height: spacerHeight }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
{showScrollButton && sessionMessages.length > 0 && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => scrollToBottom()}
|
||||
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-accent"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
|
||||
<RiArrowDownLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
|
||||
const ChatEmptyState: React.FC = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-full w-full">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ChatEmptyState);
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
|
||||
interface ChatErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: React.ErrorInfo;
|
||||
}
|
||||
|
||||
interface ChatErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
|
||||
constructor(props: ChatErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ChatErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
this.setState({ error, errorInfo });
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Chat error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="flex items-center justify-center gap-2 text-destructive">
|
||||
<RiChat3Line className="h-5 w-5" />
|
||||
Chat Error
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
|
||||
</p>
|
||||
|
||||
{this.props.sessionId && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Session: {this.props.sessionId}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.error && (
|
||||
<details className="text-xs font-mono bg-muted p-3 rounded">
|
||||
<summary className="cursor-pointer hover:bg-muted/80">Error details</summary>
|
||||
<pre className="mt-2 overflow-x-auto">
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={this.handleReset} variant="outline" className="flex-1">
|
||||
<RiRestartLine className="h-4 w-4 mr-2" />
|
||||
Reset Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
If the problem persists, try refreshing the page.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import React from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RiAiAgentLine, RiCloseCircleLine, RiFileUploadLine, RiSendPlane2Line } from '@remixicon/react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
||||
import { FileAttachmentButton, AttachedFilesList } from './FileAttachment';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ServerFilePicker } from './ServerFilePicker';
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { WorkingPlaceholder } from './message/parts/WorkingPlaceholder';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { toast } from 'sonner';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||
}
|
||||
|
||||
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||
const [message, setMessage] = React.useState('');
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [showFileMention, setShowFileMention] = React.useState(false);
|
||||
const [mentionQuery, setMentionQuery] = React.useState('');
|
||||
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
|
||||
const [commandQuery, setCommandQuery] = React.useState('');
|
||||
const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false);
|
||||
const [agentQuery, setAgentQuery] = React.useState('');
|
||||
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
|
||||
|
||||
const sendMessage = useSessionStore((state) => state.sendMessage);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
|
||||
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
|
||||
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
||||
const abortPromptExpiresAt = useSessionStore((state) => state.abortPromptExpiresAt);
|
||||
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
||||
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
||||
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
||||
const addServerFile = useSessionStore((state) => state.addServerFile);
|
||||
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
|
||||
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
||||
|
||||
const { currentProviderId, currentModelId, currentAgentName, agents, setAgent } = useConfigStore();
|
||||
const { isMobile } = useUIStore();
|
||||
const { working } = useAssistantStatus();
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
const currentAgent = React.useMemo(() => {
|
||||
if (!currentAgentName) {
|
||||
return undefined;
|
||||
}
|
||||
return agents.find((agent) => agent.name === currentAgentName);
|
||||
}, [agents, currentAgentName]);
|
||||
|
||||
const agentDefaultEditMode = React.useMemo<EditPermissionMode>(() => {
|
||||
const agentPermissionRaw = currentAgent?.permission?.edit;
|
||||
let defaultMode: EditPermissionMode = 'ask';
|
||||
|
||||
if (agentPermissionRaw === 'allow' || agentPermissionRaw === 'ask' || agentPermissionRaw === 'deny' || agentPermissionRaw === 'full') {
|
||||
defaultMode = agentPermissionRaw;
|
||||
}
|
||||
|
||||
const editToolConfigured = currentAgent ? (currentAgent.tools?.['edit'] !== false) : false;
|
||||
if (!currentAgent || !editToolConfigured) {
|
||||
defaultMode = 'deny';
|
||||
}
|
||||
|
||||
return defaultMode;
|
||||
}, [currentAgent]);
|
||||
|
||||
const sessionAgentEditOverride = useSessionStore(
|
||||
React.useCallback((state) => {
|
||||
if (!currentSessionId || !currentAgentName) {
|
||||
return undefined;
|
||||
}
|
||||
const sessionMap = state.sessionAgentEditModes.get(currentSessionId);
|
||||
return sessionMap?.get(currentAgentName);
|
||||
}, [currentSessionId, currentAgentName])
|
||||
);
|
||||
|
||||
const agentWebfetchPermission = currentAgent?.permission?.webfetch;
|
||||
const agentBashPermission = currentAgent?.permission?.bash as BashPermissionSetting | undefined;
|
||||
|
||||
const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({
|
||||
agentDefaultEditMode,
|
||||
webfetchPermission: agentWebfetchPermission,
|
||||
bashPermission: agentBashPermission,
|
||||
}), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]);
|
||||
|
||||
const selectionContextReady = Boolean(currentSessionId && currentAgentName);
|
||||
|
||||
const effectiveEditPermission = React.useMemo<EditPermissionMode>(() => {
|
||||
if (selectionContextReady && sessionAgentEditOverride && permissionUiState.modeAvailability[sessionAgentEditOverride]) {
|
||||
return sessionAgentEditOverride;
|
||||
}
|
||||
return permissionUiState.cascadeDefaultMode;
|
||||
}, [permissionUiState, selectionContextReady, sessionAgentEditOverride]);
|
||||
|
||||
const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]);
|
||||
|
||||
const chatInputWrapperStyle = React.useMemo<React.CSSProperties | undefined>(() => {
|
||||
if (!chatInputAccent) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
borderColor: chatInputAccent.border ?? chatInputAccent.text,
|
||||
borderWidth: chatInputAccent.borderWidth ?? 1,
|
||||
};
|
||||
}, [chatInputAccent]);
|
||||
|
||||
const hasContent = message.trim() || attachedFiles.length > 0;
|
||||
|
||||
const canAbort = working.isWorking;
|
||||
|
||||
const isAbortPromptActive = React.useMemo(() => {
|
||||
if (!currentSessionId) return false;
|
||||
return abortPromptSessionId === currentSessionId && Boolean(abortPromptExpiresAt);
|
||||
}, [abortPromptSessionId, abortPromptExpiresAt, currentSessionId]);
|
||||
const canShowAbortButton = canAbort && (isMobile || isAbortPromptActive);
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (!hasContent || !currentSessionId) return;
|
||||
|
||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||
|
||||
scrollToBottom?.({ instant: true });
|
||||
|
||||
const normalizedCommand = messageToSend.trimStart();
|
||||
if (normalizedCommand.startsWith('/')) {
|
||||
const commandName = normalizedCommand
|
||||
.slice(1)
|
||||
.trim()
|
||||
.split(/\s+/)[0]
|
||||
?.toLowerCase();
|
||||
if (commandName === 'summarize') {
|
||||
scrollToBottom?.({ instant: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentProviderId || !currentModelId) {
|
||||
|
||||
console.warn('Cannot send message: provider or model not selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||
const agentMentionName = mention?.name;
|
||||
|
||||
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
||||
if (attachmentsToSend.length > 0) {
|
||||
clearAttachedFiles();
|
||||
}
|
||||
|
||||
setMessage('');
|
||||
|
||||
await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName)
|
||||
.catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: String(error ?? '');
|
||||
const normalized = rawMessage.toLowerCase();
|
||||
|
||||
console.error('Message send failed:', rawMessage || error);
|
||||
|
||||
const isSoftNetworkError =
|
||||
normalized.includes('timeout') ||
|
||||
normalized.includes('timed out') ||
|
||||
normalized.includes('may still be processing') ||
|
||||
normalized.includes('being processed') ||
|
||||
normalized.includes('failed to fetch') ||
|
||||
normalized.includes('networkerror') ||
|
||||
normalized.includes('network error') ||
|
||||
normalized.includes('gateway timeout') ||
|
||||
normalized === 'failed to send message';
|
||||
|
||||
if (isSoftNetworkError) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentsToSend.length > 0) {
|
||||
useFileStore.setState({ attachedFiles: attachmentsToSend });
|
||||
}
|
||||
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
||||
});
|
||||
|
||||
textareaRef.current?.focus();
|
||||
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
||||
if (showCommandAutocomplete && commandRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
commandRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (showAgentAutocomplete && agentRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
agentRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (showFileMention && mentionRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
mentionRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) {
|
||||
e.preventDefault();
|
||||
cycleAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const startAbortIndicator = React.useCallback(() => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setShowAbortStatus(true);
|
||||
|
||||
abortTimeoutRef.current = setTimeout(() => {
|
||||
setShowAbortStatus(false);
|
||||
abortTimeoutRef.current = null;
|
||||
}, 1800);
|
||||
}, []);
|
||||
|
||||
const handleAbort = React.useCallback(() => {
|
||||
clearAbortPrompt();
|
||||
startAbortIndicator();
|
||||
|
||||
void abortCurrentOperation();
|
||||
}, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]);
|
||||
|
||||
const cycleAgent = () => {
|
||||
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
|
||||
|
||||
if (primaryAgents.length <= 1) return;
|
||||
|
||||
const currentIndex = primaryAgents.findIndex(agent => agent.name === currentAgentName);
|
||||
const nextIndex = (currentIndex + 1) % primaryAgents.length;
|
||||
const nextAgent = primaryAgents[nextIndex];
|
||||
|
||||
setAgent(nextAgent.name);
|
||||
|
||||
if (currentSessionId) {
|
||||
|
||||
saveSessionAgentSelection(currentSessionId, nextAgent.name);
|
||||
}
|
||||
};
|
||||
|
||||
const adjustTextareaHeight = React.useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const view = textarea.ownerDocument?.defaultView;
|
||||
const computedStyle = view ? view.getComputedStyle(textarea) : null;
|
||||
const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN;
|
||||
const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN;
|
||||
const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN;
|
||||
const fallbackLineHeight = 22;
|
||||
const fallbackPadding = 16;
|
||||
const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom)
|
||||
? fallbackPadding
|
||||
: paddingTop + paddingBottom;
|
||||
const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight;
|
||||
const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal;
|
||||
const scrollHeight = textarea.scrollHeight || textarea.offsetHeight;
|
||||
const nextHeight = Math.min(scrollHeight, maxHeight);
|
||||
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.maxHeight = `${maxHeight}px`;
|
||||
|
||||
setTextareaSize((prev) => {
|
||||
if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
|
||||
return prev;
|
||||
}
|
||||
return { height: nextHeight, maxHeight };
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [adjustTextareaHeight, message, isMobile]);
|
||||
|
||||
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
||||
if (value.startsWith('/')) {
|
||||
const firstSpace = value.indexOf(' ');
|
||||
const firstNewline = value.indexOf('\n');
|
||||
const commandEnd = Math.min(
|
||||
firstSpace === -1 ? value.length : firstSpace,
|
||||
firstNewline === -1 ? value.length : firstNewline
|
||||
);
|
||||
|
||||
if (cursorPosition <= commandEnd && firstSpace === -1) {
|
||||
const commandText = value.substring(1, commandEnd);
|
||||
setCommandQuery(commandText);
|
||||
setShowCommandAutocomplete(true);
|
||||
setShowFileMention(false);
|
||||
setShowAgentAutocomplete(false);
|
||||
} else {
|
||||
setShowCommandAutocomplete(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCommandAutocomplete(false);
|
||||
|
||||
const textBeforeCursor = value.substring(0, cursorPosition);
|
||||
|
||||
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
||||
if (lastHashSymbol !== -1) {
|
||||
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
|
||||
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
|
||||
const hasSeparator = textAfterHash.includes(' ') || textAfterHash.includes('\n');
|
||||
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
||||
|
||||
if (isWordBoundary && !hasSeparator) {
|
||||
setAgentQuery(textAfterHash);
|
||||
setShowAgentAutocomplete(true);
|
||||
setShowFileMention(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowAgentAutocomplete(false);
|
||||
setAgentQuery('');
|
||||
|
||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||
if (lastAtSymbol !== -1) {
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
||||
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
||||
setMentionQuery(textAfterAt);
|
||||
setShowFileMention(true);
|
||||
} else {
|
||||
setShowFileMention(false);
|
||||
}
|
||||
} else {
|
||||
setShowFileMention(false);
|
||||
}
|
||||
}, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention]);
|
||||
|
||||
const insertTextAtSelection = React.useCallback((text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
const nextValue = message + text;
|
||||
setMessage(nextValue);
|
||||
updateAutocompleteState(nextValue, nextValue.length);
|
||||
requestAnimationFrame(() => adjustTextareaHeight());
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart ?? message.length;
|
||||
const end = textarea.selectionEnd ?? message.length;
|
||||
const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`;
|
||||
setMessage(nextValue);
|
||||
const cursorPosition = start + text.length;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const currentTextarea = textareaRef.current;
|
||||
if (currentTextarea) {
|
||||
currentTextarea.selectionStart = cursorPosition;
|
||||
currentTextarea.selectionEnd = cursorPosition;
|
||||
}
|
||||
adjustTextareaHeight();
|
||||
});
|
||||
|
||||
updateAutocompleteState(nextValue, cursorPosition);
|
||||
}, [adjustTextareaHeight, message, updateAutocompleteState]);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursorPosition = e.target.selectionStart ?? value.length;
|
||||
setMessage(value);
|
||||
adjustTextareaHeight();
|
||||
updateAutocompleteState(value, cursorPosition);
|
||||
};
|
||||
|
||||
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const fileMap = new Map<string, File>();
|
||||
|
||||
Array.from(e.clipboardData.files || []).forEach(file => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
fileMap.set(`${file.name}-${file.size}`, file);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(e.clipboardData.items || []).forEach(item => {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
fileMap.set(`${file.name}-${file.size}`, file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const imageFiles = Array.from(fileMap.values());
|
||||
if (imageFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
if (pastedText) {
|
||||
insertTextAtSelection(pastedText);
|
||||
}
|
||||
|
||||
let attachedCount = 0;
|
||||
|
||||
for (const file of imageFiles) {
|
||||
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||
try {
|
||||
await addAttachedFile(file);
|
||||
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||
if (sizeAfter > sizeBefore) {
|
||||
attachedCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Clipboard image attach failed', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachedCount > 0) {
|
||||
toast.success(`Attached ${attachedCount} image${attachedCount > 1 ? 's' : ''} from clipboard`);
|
||||
}
|
||||
}, [addAttachedFile, currentSessionId, insertTextAtSelection]);
|
||||
|
||||
const handleFileSelect = (file: { name: string; path: string }) => {
|
||||
|
||||
const cursorPosition = textareaRef.current?.selectionStart || 0;
|
||||
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtSymbol !== -1) {
|
||||
const newMessage =
|
||||
message.substring(0, lastAtSymbol) +
|
||||
file.name +
|
||||
message.substring(cursorPosition);
|
||||
setMessage(newMessage);
|
||||
}
|
||||
|
||||
setShowFileMention(false);
|
||||
setMentionQuery('');
|
||||
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleAgentSelect = (agentName: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
const cursorPosition = textarea?.selectionStart ?? message.length;
|
||||
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
||||
|
||||
if (lastHashSymbol !== -1) {
|
||||
const newMessage =
|
||||
message.substring(0, lastHashSymbol) +
|
||||
`#${agentName} ` +
|
||||
message.substring(cursorPosition);
|
||||
setMessage(newMessage);
|
||||
|
||||
const nextCursor = lastHashSymbol + agentName.length + 2;
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = nextCursor;
|
||||
textareaRef.current.selectionEnd = nextCursor;
|
||||
}
|
||||
adjustTextareaHeight();
|
||||
updateAutocompleteState(newMessage, nextCursor);
|
||||
});
|
||||
}
|
||||
|
||||
setShowAgentAutocomplete(false);
|
||||
setAgentQuery('');
|
||||
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => {
|
||||
|
||||
setMessage(`/${command.name} `);
|
||||
|
||||
const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command };
|
||||
if (textareaElement) {
|
||||
textareaElement._commandMetadata = command;
|
||||
}
|
||||
|
||||
setShowCommandAutocomplete(false);
|
||||
setCommandQuery('');
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
if (currentSessionId && textareaRef.current && !isMobile) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [currentSessionId, isMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) {
|
||||
clearAbortPrompt();
|
||||
}
|
||||
}, [abortPromptSessionId, currentSessionId, clearAbortPrompt]);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (currentSessionId && !isDragging) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (!currentSessionId) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
let attachedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||
try {
|
||||
await addAttachedFile(file);
|
||||
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||
if (sizeAfter > sizeBefore) {
|
||||
attachedCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File attach failed', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachedCount > 0) {
|
||||
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => {
|
||||
let attachedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||
try {
|
||||
await addServerFile(file.path, file.name);
|
||||
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||
if (sizeAfter > sizeBefore) {
|
||||
attachedCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server file attach failed', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachedCount > 0) {
|
||||
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
}, [addServerFile]);
|
||||
|
||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : 'px-2.5 py-1.5';
|
||||
const footerHeightClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
||||
|
||||
const iconButtonBaseClass = cn(
|
||||
footerHeightClass,
|
||||
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
||||
);
|
||||
|
||||
const actionButton = (
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!hasContent || !currentSessionId}
|
||||
className={cn(
|
||||
iconButtonBaseClass,
|
||||
hasContent && currentSessionId
|
||||
? 'text-primary hover:text-primary'
|
||||
: 'opacity-30'
|
||||
)}
|
||||
aria-label='Send message'
|
||||
>
|
||||
<RiSendPlane2Line className={cn(iconSizeClass)} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const projectFileButton = (
|
||||
<ServerFilePicker onFilesSelected={handleServerFilesSelected} multiSelect>
|
||||
<button
|
||||
type='button'
|
||||
className={iconButtonBaseClass}
|
||||
title='Attach files from project'
|
||||
aria-label='Attach files from project'
|
||||
>
|
||||
<RiFileUploadLine className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
</ServerFilePicker>
|
||||
);
|
||||
|
||||
const settingsButton = onOpenSettings ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onOpenSettings}
|
||||
className={iconButtonBaseClass}
|
||||
title='Model and agent settings'
|
||||
aria-label='Model and agent settings'
|
||||
>
|
||||
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const attachmentsControls = (
|
||||
<>
|
||||
<FileAttachmentButton />
|
||||
{projectFileButton}
|
||||
{settingsButton}
|
||||
</>
|
||||
);
|
||||
|
||||
const workingStatusText = working.statusText;
|
||||
|
||||
React.useEffect(() => {
|
||||
const pendingAbortBanner = Boolean(working.wasAborted);
|
||||
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
||||
startAbortIndicator();
|
||||
if (currentSessionId) {
|
||||
acknowledgeSessionAbort(currentSessionId);
|
||||
}
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbortBanner;
|
||||
}, [
|
||||
acknowledgeSessionAbort,
|
||||
currentSessionId,
|
||||
showAbortStatus,
|
||||
startAbortIndicator,
|
||||
working.wasAborted,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (abortTimeoutRef.current) {
|
||||
clearTimeout(abortTimeoutRef.current);
|
||||
abortTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldRenderPlaceholder = !showAbortStatus && (working.wasAborted || !working.abortActive);
|
||||
|
||||
return (
|
||||
|
||||
<form onSubmit={handleSubmit} className="pt-0 pb-4 bottom-safe-area">
|
||||
<div className="chat-column mb-1.5 h-[1.2rem] flex items-center justify-between gap-2 overflow-visible">
|
||||
<div className="flex-1 flex items-center overflow-hidden">
|
||||
{showAbortStatus ? (
|
||||
<div className="flex h-full items-center text-[var(--status-error)] pl-[2ch]">
|
||||
<span className="flex items-center gap-1.5 typography-ui-header">
|
||||
<RiCloseCircleLine size={18} aria-hidden="true" />
|
||||
Aborted
|
||||
</span>
|
||||
</div>
|
||||
) : shouldRenderPlaceholder ? (
|
||||
<WorkingPlaceholder
|
||||
key={currentSessionId ?? 'no-session'}
|
||||
statusText={workingStatusText}
|
||||
isWaitingForPermission={working.isWaitingForPermission}
|
||||
wasAborted={working.wasAborted}
|
||||
completionId={working.lastCompletionId}
|
||||
isComplete={working.isComplete}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canShowAbortButton ? (
|
||||
<div className="flex-shrink-0 pr-[2ch]">
|
||||
{isMobile ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleAbort}
|
||||
className='flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none'
|
||||
aria-label='Stop generating'
|
||||
>
|
||||
<RiCloseCircleLine size={18} aria-hidden='true' />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleAbort}
|
||||
className='inline-flex h-[1.2rem] items-center gap-0.5 rounded-md bg-[var(--status-error)]/70 px-1 text-[0.65rem] font-medium text-white hover:bg-[var(--status-error)]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--status-error)]/40'
|
||||
aria-label='Stop generating'
|
||||
>
|
||||
<RiCloseCircleLine size={11} className='text-white' aria-hidden='true' />
|
||||
Abort
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={cn(
|
||||
"chat-column relative overflow-visible",
|
||||
isDragging && "ring-2 ring-primary ring-offset-2 rounded-xl"
|
||||
)}
|
||||
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
||||
<div className="text-center">
|
||||
<FileAttachmentButton />
|
||||
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AttachedFilesList />
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border/20 bg-input/10 dark:bg-input/30",
|
||||
"flex flex-col relative overflow-visible"
|
||||
)}
|
||||
style={chatInputWrapperStyle}
|
||||
>
|
||||
{}
|
||||
{showCommandAutocomplete && (
|
||||
<CommandAutocomplete
|
||||
ref={commandRef}
|
||||
searchQuery={commandQuery}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
onClose={() => setShowCommandAutocomplete(false)}
|
||||
/>
|
||||
)}
|
||||
{}
|
||||
{showAgentAutocomplete && (
|
||||
<AgentMentionAutocomplete
|
||||
ref={agentRef}
|
||||
searchQuery={agentQuery}
|
||||
onAgentSelect={handleAgentSelect}
|
||||
onClose={() => setShowAgentAutocomplete(false)}
|
||||
/>
|
||||
)}
|
||||
{}
|
||||
{showFileMention && (
|
||||
<FileMentionAutocomplete
|
||||
ref={mentionRef}
|
||||
searchQuery={mentionQuery}
|
||||
onFileSelect={handleFileSelect}
|
||||
onClose={() => setShowFileMention(false)}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
data-chat-input="true"
|
||||
value={message}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={currentSessionId ? "# for agents; @ for files; / for commands" : "Select or create a session to start chatting"}
|
||||
disabled={!currentSessionId}
|
||||
|
||||
className={cn(
|
||||
'min-h-[52px] resize-none border-0 px-3 shadow-none rounded-t-xl rounded-b-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent',
|
||||
isMobile ? "py-2.5" : "pt-4 pb-2",
|
||||
"focus-visible:outline-none focus-visible:ring-0"
|
||||
)}
|
||||
style={{
|
||||
flex: 'none',
|
||||
height: textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-b-xl bg-transparent',
|
||||
footerPaddingClass,
|
||||
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
|
||||
)}
|
||||
data-chat-input-footer="true"
|
||||
>
|
||||
{isMobile ? (
|
||||
<div className="flex w-full items-center gap-x-1.5">
|
||||
<div className="flex items-center flex-shrink-0 gap-x-1">
|
||||
{attachmentsControls}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-x-1 min-w-0">
|
||||
<ModelControls className={cn('flex items-center justify-end min-w-0')} />
|
||||
{actionButton}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
||||
{attachmentsControls}
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||
{actionButton}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,750 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useMessageStore } from '@/stores/messageStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import MessageHeader from './message/MessageHeader';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||
import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts } from './message/partUtils';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import type { TurnGroupingContext } from './hooks/useTurnGrouping';
|
||||
|
||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
|
||||
const ref = React.useRef<{ hasValue: boolean; value: T | null | undefined }>({ hasValue: false, value: undefined as T | null | undefined });
|
||||
|
||||
if (!ref.current.hasValue && value !== undefined && value !== null) {
|
||||
ref.current = { hasValue: true, value };
|
||||
}
|
||||
|
||||
return ref.current.hasValue ? ref.current.value : value;
|
||||
}
|
||||
|
||||
const getMessageInfoProp = (info: unknown, key: string): unknown => {
|
||||
if (typeof info === 'object' && info !== null) {
|
||||
return (info as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
previousMessage?: {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
nextMessage?: {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||
animationHandlers?: AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||
isPendingAnchor?: boolean;
|
||||
turnGroupingContext?: TurnGroupingContext;
|
||||
}
|
||||
|
||||
const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
message,
|
||||
previousMessage,
|
||||
nextMessage,
|
||||
onContentChange,
|
||||
animationHandlers,
|
||||
isPendingAnchor = false,
|
||||
turnGroupingContext,
|
||||
}) => {
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const sessionState = useSessionStore(
|
||||
useShallow((state) => ({
|
||||
lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null,
|
||||
isStreamingMessage: (() => {
|
||||
const sessionId =
|
||||
(message.info as { sessionID?: string }).sessionID ??
|
||||
state.currentSessionId ??
|
||||
null;
|
||||
if (!sessionId) return false;
|
||||
return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id;
|
||||
})(),
|
||||
currentSessionId: state.currentSessionId,
|
||||
getCurrentAgent: state.getCurrentAgent,
|
||||
getSessionAgentSelection: state.getSessionAgentSelection,
|
||||
getAgentModelForSession: state.getAgentModelForSession,
|
||||
getSessionModelSelection: state.getSessionModelSelection,
|
||||
}))
|
||||
);
|
||||
|
||||
const {
|
||||
lifecyclePhase,
|
||||
isStreamingMessage,
|
||||
currentSessionId,
|
||||
getCurrentAgent,
|
||||
getSessionAgentSelection,
|
||||
getAgentModelForSession,
|
||||
getSessionModelSelection,
|
||||
} = sessionState;
|
||||
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const showReasoningTraces = useUIStore((state) => state.showReasoningTraces);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
|
||||
const [copiedMessage, setCopiedMessage] = React.useState(false);
|
||||
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(new Set());
|
||||
const [popupContent, setPopupContent] = React.useState<ToolPopupContent>({
|
||||
open: false,
|
||||
title: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
|
||||
const isUser = messageRole.isUser;
|
||||
|
||||
const normalizedParts = React.useMemo(() => {
|
||||
if (!isUser) {
|
||||
return message.parts;
|
||||
}
|
||||
|
||||
return message.parts.map((part) => {
|
||||
const rawPart = part as Record<string, unknown>;
|
||||
if (rawPart.type === 'compaction') {
|
||||
return { type: 'text', text: '/summarize' } as Part;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}, [isUser, message.parts]);
|
||||
|
||||
const previousUserMetadata = React.useMemo(() => {
|
||||
if (isUser || !previousMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientRole = getMessageInfoProp(previousMessage.info, 'clientRole');
|
||||
const role = getMessageInfoProp(previousMessage.info, 'role');
|
||||
const previousRole = typeof clientRole === 'string' ? clientRole : (typeof role === 'string' ? role : undefined);
|
||||
if (previousRole !== 'user') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = getMessageInfoProp(previousMessage.info, 'mode');
|
||||
const providerID = getMessageInfoProp(previousMessage.info, 'providerID');
|
||||
const modelID = getMessageInfoProp(previousMessage.info, 'modelID');
|
||||
const resolvedAgent = typeof mode === 'string' && mode.trim().length > 0 ? mode : undefined;
|
||||
const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
|
||||
const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
|
||||
|
||||
if (!resolvedAgent && !resolvedProvider && !resolvedModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
agentName: resolvedAgent,
|
||||
providerId: resolvedProvider,
|
||||
modelId: resolvedModel,
|
||||
};
|
||||
}, [isUser, previousMessage]);
|
||||
|
||||
const agentName = React.useMemo(() => {
|
||||
if (isUser) return undefined;
|
||||
|
||||
const messageMode = getMessageInfoProp(message.info, 'mode');
|
||||
if (typeof messageMode === 'string' && messageMode.trim().length > 0) {
|
||||
return messageMode;
|
||||
}
|
||||
|
||||
if (previousUserMetadata?.agentName) {
|
||||
return previousUserMetadata.agentName;
|
||||
}
|
||||
|
||||
const sessionId = message.info.sessionID;
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentContextAgent = getCurrentAgent(sessionId);
|
||||
if (currentContextAgent) {
|
||||
return currentContextAgent;
|
||||
}
|
||||
|
||||
const savedSelection = getSessionAgentSelection(sessionId);
|
||||
return savedSelection ?? undefined;
|
||||
}, [isUser, message.info, previousUserMetadata, getCurrentAgent, getSessionAgentSelection]);
|
||||
|
||||
const sessionId = message.info.sessionID;
|
||||
const messageProviderID = !isUser ? getMessageInfoProp(message.info, 'providerID') : null;
|
||||
const messageModelID = !isUser ? getMessageInfoProp(message.info, 'modelID') : null;
|
||||
|
||||
const contextModelSelection = React.useMemo(() => {
|
||||
if (isUser || !sessionId) return null;
|
||||
|
||||
if (previousUserMetadata?.providerId && previousUserMetadata?.modelId) {
|
||||
return {
|
||||
providerId: previousUserMetadata.providerId,
|
||||
modelId: previousUserMetadata.modelId,
|
||||
};
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const agentSelection = getAgentModelForSession(sessionId, agentName);
|
||||
if (agentSelection?.providerId && agentSelection?.modelId) {
|
||||
return agentSelection;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionSelection = getSessionModelSelection(sessionId);
|
||||
if (sessionSelection?.providerId && sessionSelection?.modelId) {
|
||||
return sessionSelection;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [isUser, sessionId, agentName, previousUserMetadata, getAgentModelForSession, getSessionModelSelection]);
|
||||
|
||||
const providerID = React.useMemo(() => {
|
||||
if (isUser) return null;
|
||||
if (typeof messageProviderID === 'string' && messageProviderID.trim().length > 0) {
|
||||
return messageProviderID;
|
||||
}
|
||||
return contextModelSelection?.providerId ?? null;
|
||||
}, [isUser, messageProviderID, contextModelSelection]);
|
||||
|
||||
const modelID = React.useMemo(() => {
|
||||
if (isUser) return null;
|
||||
if (typeof messageModelID === 'string' && messageModelID.trim().length > 0) {
|
||||
return messageModelID;
|
||||
}
|
||||
return contextModelSelection?.modelId ?? null;
|
||||
}, [isUser, messageModelID, contextModelSelection]);
|
||||
|
||||
const modelName = React.useMemo(() => {
|
||||
if (isUser) return undefined;
|
||||
|
||||
if (providerID && modelID && providers.length > 0) {
|
||||
const provider = providers.find((p) => p.id === providerID);
|
||||
if (provider?.models && Array.isArray(provider.models)) {
|
||||
const model = provider.models.find((m: Record<string, unknown>) => (m as Record<string, unknown>).id === modelID);
|
||||
const modelObj = model as Record<string, unknown> | undefined;
|
||||
const name = modelObj?.name;
|
||||
return typeof name === 'string' ? name : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [isUser, providerID, modelID, providers]);
|
||||
|
||||
const displayAgentName = useStickyDisplayValue<string>(agentName);
|
||||
const displayProviderIDValue = useStickyDisplayValue<string>(providerID ?? undefined);
|
||||
const displayModelName = useStickyDisplayValue<string>(modelName);
|
||||
|
||||
const headerAgentName = displayAgentName ?? undefined;
|
||||
const headerProviderID = displayProviderIDValue ?? null;
|
||||
const headerModelName = displayModelName ?? undefined;
|
||||
|
||||
const messageCompletedAt = React.useMemo(() => {
|
||||
const timeInfo = message.info.time as { completed?: number } | undefined;
|
||||
return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null;
|
||||
}, [message.info.time]);
|
||||
|
||||
const isMessageCompleted = React.useMemo(() => {
|
||||
if (isUser) return true;
|
||||
return Boolean(messageCompletedAt && messageCompletedAt > 0);
|
||||
}, [isUser, messageCompletedAt]);
|
||||
|
||||
const visibleParts = React.useMemo(
|
||||
() =>
|
||||
filterVisibleParts(normalizedParts, {
|
||||
includeReasoning: showReasoningTraces,
|
||||
}),
|
||||
[normalizedParts, showReasoningTraces]
|
||||
);
|
||||
|
||||
const displayParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return visibleParts;
|
||||
}
|
||||
|
||||
return isMessageCompleted ? visibleParts : [];
|
||||
}, [isUser, isMessageCompleted, visibleParts]);
|
||||
|
||||
const assistantTextParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
}
|
||||
return visibleParts.filter((part) => part.type === 'text');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const toolParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return [];
|
||||
}
|
||||
return visibleParts.filter((part) => part.type === 'tool');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const agentMention = React.useMemo(() => {
|
||||
if (!isUser) {
|
||||
return undefined;
|
||||
}
|
||||
const mentionPart = message.parts.find((part) => part.type === 'agent');
|
||||
if (!mentionPart) {
|
||||
return undefined;
|
||||
}
|
||||
const partWithName = mentionPart as { name?: string; source?: { value?: string } };
|
||||
const name = typeof partWithName.name === 'string' ? partWithName.name : undefined;
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0
|
||||
? partWithName.source.value
|
||||
: `#${name}`;
|
||||
return { name, token: rawValue } satisfies AgentMentionInfo;
|
||||
}, [isUser, message.parts]);
|
||||
|
||||
const stepState = React.useMemo(() => {
|
||||
|
||||
let stepStarts = 0;
|
||||
let stepFinishes = 0;
|
||||
visibleParts.forEach((part) => {
|
||||
if (part.type === 'step-start') {
|
||||
stepStarts += 1;
|
||||
} else if (part.type === 'step-finish') {
|
||||
stepFinishes += 1;
|
||||
}
|
||||
});
|
||||
return {
|
||||
hasOpenStep: stepStarts > stepFinishes,
|
||||
};
|
||||
}, [visibleParts]);
|
||||
|
||||
const hasOpenStep = stepState.hasOpenStep;
|
||||
|
||||
const shouldCoordinateRendering = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
if (assistantTextParts.length === 0 || toolParts.length === 0) {
|
||||
return hasOpenStep;
|
||||
}
|
||||
return true;
|
||||
}, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]);
|
||||
|
||||
const themeVariant = currentTheme?.metadata.variant;
|
||||
const isDarkTheme = React.useMemo(() => {
|
||||
if (themeVariant) {
|
||||
return themeVariant === 'dark';
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
}
|
||||
return false;
|
||||
}, [themeVariant]);
|
||||
|
||||
const syntaxTheme = React.useMemo(() => {
|
||||
if (currentTheme) {
|
||||
return generateSyntaxTheme(currentTheme);
|
||||
}
|
||||
return isDarkTheme ? defaultCodeDark : defaultCodeLight;
|
||||
}, [currentTheme, isDarkTheme]);
|
||||
|
||||
const shouldAnimateMessage = React.useMemo(() => {
|
||||
if (isUser) return false;
|
||||
const freshnessDetector = MessageFreshnessDetector.getInstance();
|
||||
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
|
||||
}, [message.info, currentSessionId, isUser]);
|
||||
|
||||
const previousRole = React.useMemo(() => {
|
||||
if (!previousMessage) return null;
|
||||
return deriveMessageRole(previousMessage.info);
|
||||
}, [previousMessage]);
|
||||
|
||||
const nextRole = React.useMemo(() => {
|
||||
if (!nextMessage) return null;
|
||||
return deriveMessageRole(nextMessage.info);
|
||||
}, [nextMessage]);
|
||||
|
||||
const shouldShowHeader = React.useMemo(() => {
|
||||
if (isUser) return true;
|
||||
if (!previousRole) return true;
|
||||
return previousRole.isUser;
|
||||
}, [isUser, previousRole]);
|
||||
|
||||
const isFollowedByAssistant = React.useMemo(() => {
|
||||
if (isUser) return false;
|
||||
if (!nextRole) return false;
|
||||
return !nextRole.isUser && nextRole.role === 'assistant';
|
||||
}, [isUser, nextRole]);
|
||||
|
||||
const streamPhase: StreamPhase = React.useMemo(() => {
|
||||
if (isMessageCompleted) {
|
||||
return 'completed';
|
||||
}
|
||||
if (lifecyclePhase) {
|
||||
return lifecyclePhase;
|
||||
}
|
||||
return isStreamingMessage ? 'streaming' : 'completed';
|
||||
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
|
||||
|
||||
const handleCopyCode = React.useCallback((code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
}, []);
|
||||
|
||||
const userMessageIdForTurn = turnGroupingContext?.turnId;
|
||||
const assistantSummaryFromStore = useMessageStore((state) => {
|
||||
if (!userMessageIdForTurn) return undefined;
|
||||
const sessionId = message.info.sessionID;
|
||||
if (!sessionId) return undefined;
|
||||
const sessionMessages = state.messages.get(sessionId);
|
||||
if (!sessionMessages) return undefined;
|
||||
const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn);
|
||||
if (!userMsg) return undefined;
|
||||
const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary;
|
||||
const body = summary?.body;
|
||||
return typeof body === 'string' && body.trim().length > 0 ? body : undefined;
|
||||
});
|
||||
|
||||
const assistantSummaryCandidate =
|
||||
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
|
||||
? turnGroupingContext.summaryBody
|
||||
: assistantSummaryFromStore;
|
||||
|
||||
const assistantSummaryRef = React.useRef<string | undefined>(undefined);
|
||||
if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) {
|
||||
assistantSummaryRef.current = assistantSummaryCandidate;
|
||||
}
|
||||
const prevUserMessageIdForCopy = React.useRef(userMessageIdForTurn);
|
||||
if (prevUserMessageIdForCopy.current !== userMessageIdForTurn) {
|
||||
prevUserMessageIdForCopy.current = userMessageIdForTurn;
|
||||
assistantSummaryRef.current = undefined;
|
||||
}
|
||||
const assistantSummaryForCopy = assistantSummaryRef.current;
|
||||
|
||||
const messageTextContent = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
const textParts = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const text = part.text || part.content || '';
|
||||
return text.trim();
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
}
|
||||
|
||||
if (assistantSummaryForCopy && assistantSummaryForCopy.trim().length > 0) {
|
||||
return assistantSummaryForCopy;
|
||||
}
|
||||
|
||||
const textParts = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const text = part.text || part.content || '';
|
||||
return text.trim();
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
}, [assistantSummaryForCopy, displayParts, isUser]);
|
||||
|
||||
const hasTextContent = messageTextContent.length > 0;
|
||||
|
||||
const handleCopyMessage = React.useCallback(() => {
|
||||
navigator.clipboard.writeText(messageTextContent);
|
||||
setCopiedMessage(true);
|
||||
setTimeout(() => setCopiedMessage(false), 2000);
|
||||
}, [messageTextContent]);
|
||||
|
||||
const handleToggleTool = React.useCallback((toolId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolId)) {
|
||||
next.delete(toolId);
|
||||
} else {
|
||||
next.add(toolId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resolvedAnimationHandlers = animationHandlers ?? null;
|
||||
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
|
||||
|
||||
const animationCompletedRef = React.useRef(false);
|
||||
const hasRequestedReservationRef = React.useRef(false);
|
||||
const animationStartNotifiedRef = React.useRef(false);
|
||||
const hasTriggeredReservationOnceRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
animationCompletedRef.current = false;
|
||||
hasRequestedReservationRef.current = false;
|
||||
animationStartNotifiedRef.current = false;
|
||||
hasTriggeredReservationOnceRef.current = false;
|
||||
hasAnnouncedAuxiliaryScrollRef.current = false;
|
||||
}, [message.info.id]);
|
||||
|
||||
const handleAuxiliaryContentComplete = React.useCallback(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
if (hasAnnouncedAuxiliaryScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
hasAnnouncedAuxiliaryScrollRef.current = true;
|
||||
onContentChange?.('structural');
|
||||
}, [isUser, onContentChange]);
|
||||
|
||||
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
||||
|
||||
if (content.image) {
|
||||
setPopupContent(content);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePopupChange = React.useCallback((open: boolean) => {
|
||||
setPopupContent((prev) => ({ ...prev, open }));
|
||||
}, []);
|
||||
|
||||
const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled'));
|
||||
const isStreamingPhase = streamPhase === 'streaming';
|
||||
|
||||
const hasReasoningParts = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
return false;
|
||||
}
|
||||
return visibleParts.some((part) => part.type === 'reasoning');
|
||||
}, [isUser, visibleParts]);
|
||||
|
||||
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase;
|
||||
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldReserveAnimationSpace) {
|
||||
if (hasRequestedReservationRef.current) {
|
||||
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
|
||||
resolvedAnimationHandlers.onReasoningBlock();
|
||||
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
|
||||
resolvedAnimationHandlers.onReservationCancelled();
|
||||
}
|
||||
hasRequestedReservationRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTriggeredReservationOnceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredReservationOnceRef.current = true;
|
||||
resolvedAnimationHandlers.onStreamingCandidate();
|
||||
hasRequestedReservationRef.current = true;
|
||||
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedAnimationHandlers?.onAnimationStart) {
|
||||
return;
|
||||
}
|
||||
if (!allowAnimation) {
|
||||
return;
|
||||
}
|
||||
if (animationStartNotifiedRef.current) {
|
||||
return;
|
||||
}
|
||||
resolvedAnimationHandlers.onAnimationStart();
|
||||
animationStartNotifiedRef.current = true;
|
||||
}, [resolvedAnimationHandlers, allowAnimation]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
|
||||
if (!shouldTrackHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = messageContainerRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
|
||||
handler(element.getBoundingClientRect().height);
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
const notifyHeight = (height: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
handler(height);
|
||||
return;
|
||||
}
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
handler(height);
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
notifyHeight(entry.contentRect.height);
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
notifyHeight(element.getBoundingClientRect().height);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group w-full',
|
||||
shouldShowHeader ? 'pt-2' : 'pt-0',
|
||||
isUser ? 'pb-2' : isFollowedByAssistant ? 'pb-0' : 'pb-2'
|
||||
)}
|
||||
data-message-id={message.info.id}
|
||||
ref={messageContainerRef}
|
||||
style={isPendingAnchor ? { visibility: 'hidden' } : undefined}
|
||||
>
|
||||
<div className="chat-column">
|
||||
{isUser ? (
|
||||
<FadeInOnReveal>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border bg-input/10 dark:bg-input/30 pt-[0.7rem] pb-[0.45rem] relative'
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'color-mix(in srgb, var(--primary-muted, var(--primary)) 40%, var(--interactive-border, transparent))'
|
||||
}}
|
||||
>
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={visibleParts}
|
||||
isUser={isUser}
|
||||
isMessageCompleted={isMessageCompleted}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
hasTouchInput={hasTouchInput}
|
||||
copiedCode={copiedCode}
|
||||
onCopyCode={handleCopyCode}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={handleToggleTool}
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</FadeInOnReveal>
|
||||
) : (
|
||||
<div>
|
||||
{shouldShowHeader && (
|
||||
<MessageHeader
|
||||
isUser={isUser}
|
||||
providerID={headerProviderID}
|
||||
agentName={headerAgentName}
|
||||
modelName={headerModelName}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={visibleParts}
|
||||
isUser={isUser}
|
||||
isMessageCompleted={isMessageCompleted}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
hasTouchInput={hasTouchInput}
|
||||
copiedCode={copiedCode}
|
||||
onCopyCode={handleCopyCode}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={handleToggleTool}
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={shouldShowHeader}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
agentMention={agentMention}
|
||||
turnGroupingContext={turnGroupingContext}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
<ToolOutputDialog
|
||||
popup={popupContent}
|
||||
onOpenChange={handlePopupChange}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ChatMessage);
|
||||
@@ -0,0 +1,261 @@
|
||||
import React from 'react';
|
||||
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
interface CommandInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
isBuiltIn?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandAutocompleteHandle {
|
||||
handleKeyDown: (key: string) => void;
|
||||
}
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onCommandSelect: (command: CommandInfo) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, CommandAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onCommandSelect,
|
||||
onClose
|
||||
}, ref) => {
|
||||
const { hasMessagesInCurrentSession } = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const sessionId = state.currentSessionId;
|
||||
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
|
||||
return {
|
||||
hasMessagesInCurrentSession: messageCount > 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (containerRef.current.contains(target)) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadCommands = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
|
||||
const apiCommands = await opencodeClient.listCommands();
|
||||
|
||||
const customCommands: CommandInfo[] = apiCommands.map(cmd => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
agent: cmd.agent,
|
||||
model: cmd.model,
|
||||
isBuiltIn: false
|
||||
}));
|
||||
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasMessagesInCurrentSession
|
||||
? []
|
||||
: [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]),
|
||||
{ name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true },
|
||||
];
|
||||
|
||||
const commandMap = new Map<string, CommandInfo>();
|
||||
|
||||
builtInCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
|
||||
|
||||
customCommands.forEach(cmd => commandMap.set(cmd.name, cmd));
|
||||
|
||||
const allCommands = Array.from(commandMap.values());
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
? allCommands.filter(cmd =>
|
||||
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
const aStartsWith = a.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||
const bStartsWith = b.name.toLowerCase().startsWith(searchQuery.toLowerCase());
|
||||
if (aStartsWith && !bStartsWith) return -1;
|
||||
if (!aStartsWith && bStartsWith) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
setCommands(filtered);
|
||||
} catch {
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const builtInCommands: CommandInfo[] = [
|
||||
...(hasMessagesInCurrentSession
|
||||
? []
|
||||
: [{ name: 'init', description: 'Create/update AGENTS.md file', isBuiltIn: true }]),
|
||||
{ name: 'summarize', description: 'Generate a summary of the current session', isBuiltIn: true },
|
||||
];
|
||||
|
||||
const filtered = (searchQuery
|
||||
? builtInCommands.filter(cmd =>
|
||||
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(cmd.description && cmd.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
: builtInCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
|
||||
setCommands(filtered);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCommands();
|
||||
}, [searchQuery, hasMessagesInCurrentSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [commands]);
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
handleKeyDown: (key: string) => {
|
||||
const total = commands.length;
|
||||
if (key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % total);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||
const command = commands[safeIndex];
|
||||
if (command) {
|
||||
onCommandSelect(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}), [commands, selectedIndex, onClose, onCommandSelect]);
|
||||
|
||||
const getCommandIcon = (command: CommandInfo) => {
|
||||
|
||||
switch (command.name) {
|
||||
case 'init':
|
||||
return <RiFileLine className="h-3.5 w-3.5 text-green-500" />;
|
||||
case 'summarize':
|
||||
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
|
||||
case 'test':
|
||||
case 'build':
|
||||
case 'run':
|
||||
return <RiTerminalBoxLine className="h-3.5 w-3.5 text-cyan-500" />;
|
||||
default:
|
||||
if (command.isBuiltIn) {
|
||||
return <RiFlashlightLine className="h-3.5 w-3.5 text-yellow-500" />;
|
||||
}
|
||||
return <RiCommandLine className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-[250px] max-w-[450px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{commands.map((command, index) => (
|
||||
<div
|
||||
key={command.name}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
className={cn(
|
||||
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
|
||||
index === selectedIndex && "bg-accent"
|
||||
)}
|
||||
onClick={() => onCommandSelect(command)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="mt-0.5">
|
||||
{getCommandIcon(command)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label font-medium">/{command.name}</span>
|
||||
{command.agent && (
|
||||
<span className="typography-meta text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{command.agent}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{commands.length === 0 && (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
No commands found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CommandAutocomplete.displayName = 'CommandAutocomplete';
|
||||
|
||||
export type { CommandInfo };
|
||||
@@ -0,0 +1,293 @@
|
||||
import React, { useRef, memo } from 'react';
|
||||
import { RiAttachment2, RiCloseLine, RiComputerLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiHardDrive3Line } from '@remixicon/react';
|
||||
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
|
||||
export const FileAttachmentButton = memo(() => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { addAttachedFile } = useSessionStore();
|
||||
const { isMobile } = useUIStore();
|
||||
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
let attachedCount = 0;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||
try {
|
||||
await addAttachedFile(files[i]);
|
||||
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||
if (sizeAfter > sizeBefore) {
|
||||
attachedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File attach failed', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachedCount > 0) {
|
||||
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept="*/*"
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
buttonSizeClass,
|
||||
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
||||
)}
|
||||
title='Attach files'
|
||||
>
|
||||
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface FileChipProps {
|
||||
file: AttachedFile;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
||||
const getFileIcon = () => {
|
||||
if (file.mimeType.startsWith('image/')) {
|
||||
return <RiFileImageLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (file.mimeType.includes('text') || file.mimeType.includes('code')) {
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (file.mimeType.includes('json') || file.mimeType.includes('xml')) {
|
||||
return <RiFilePdfLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const extractFilename = (path: string): string => {
|
||||
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
|
||||
const parts = normalized.split('/');
|
||||
const filename = parts[parts.length - 1];
|
||||
|
||||
return filename || path;
|
||||
};
|
||||
|
||||
const displayName = extractFilename(file.filename);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta">
|
||||
{}
|
||||
<div title={file.source === 'server' ? "Server file" : "Local file"}>
|
||||
{file.source === 'server' ? (
|
||||
<RiHardDrive3Line className="h-3 w-3 text-primary" />
|
||||
) : (
|
||||
<RiComputerLine className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{getFileIcon()}
|
||||
<span title={file.serverPath || displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="ml-1 hover:text-destructive p-0.5"
|
||||
title="Remove file"
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const AttachedFilesList = memo(() => {
|
||||
const { attachedFiles, removeAttachedFile } = useSessionStore();
|
||||
|
||||
if (attachedFiles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
|
||||
<span className="typography-meta text-muted-foreground font-medium">Attached:</span>
|
||||
{attachedFiles.map((file) => (
|
||||
<FileChip
|
||||
key={file.id}
|
||||
file={file}
|
||||
onRemove={() => removeAttachedFile(file.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface FilePart {
|
||||
type: string;
|
||||
mime?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface MessageFilesDisplayProps {
|
||||
files: FilePart[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}
|
||||
|
||||
export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDisplayProps) => {
|
||||
|
||||
const fileItems = files.filter(f => f.type === 'file' && (f.mime || f.url));
|
||||
|
||||
const extractFilename = (path?: string): string => {
|
||||
if (!path) return 'Unnamed file';
|
||||
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const parts = normalized.split('/');
|
||||
return parts[parts.length - 1] || path;
|
||||
};
|
||||
|
||||
const getFileIcon = (mimeType?: string) => {
|
||||
if (!mimeType) return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return <RiFileImageLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (mimeType.includes('text') || mimeType.includes('code')) {
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
if (mimeType.includes('json') || mimeType.includes('xml')) {
|
||||
return <RiFilePdfLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||
};
|
||||
|
||||
const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
|
||||
const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
|
||||
|
||||
const handleImageClick = React.useCallback((file: { filename?: string; mime?: string; size?: number; url?: string }) => {
|
||||
if (!onShowPopup || !file?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
|
||||
const popupPayload: ToolPopupContent = {
|
||||
open: true,
|
||||
title: filename,
|
||||
content: '',
|
||||
metadata: {
|
||||
tool: 'image-preview',
|
||||
filename,
|
||||
mime: file.mime,
|
||||
size: file.size,
|
||||
},
|
||||
image: {
|
||||
url: file.url,
|
||||
mimeType: file.mime,
|
||||
filename,
|
||||
},
|
||||
};
|
||||
|
||||
onShowPopup(popupPayload);
|
||||
}, [onShowPopup]);
|
||||
|
||||
if (fileItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-2">
|
||||
{}
|
||||
{otherFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{otherFiles.map((file, index) => (
|
||||
<div
|
||||
key={`file-${index}`}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta"
|
||||
>
|
||||
{getFileIcon(file.mime)}
|
||||
<span>
|
||||
{extractFilename(file.filename)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="overflow-x-auto -mx-1 px-1 py-1 scrollbar-thin">
|
||||
<div className="flex gap-3 snap-x snap-mandatory">
|
||||
{imageFiles.map((file, index) => {
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
|
||||
return (
|
||||
<Tooltip key={`img-${index}`} delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImageClick(file)}
|
||||
className="relative flex-none w-32 sm:w-36 md:w-40 aspect-square rounded-xl border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
|
||||
aria-label={filename}
|
||||
>
|
||||
{file.url ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={filename}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
|
||||
<RiFileImageLine className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only">{filename}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="typography-meta px-2 py-1">
|
||||
{filename}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import React from 'react';
|
||||
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, truncatePathMiddle } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
type FileInfo = ProjectFileSearchHit;
|
||||
|
||||
export interface FileMentionHandle {
|
||||
handleKeyDown: (key: string) => void;
|
||||
}
|
||||
|
||||
interface FileMentionAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onFileSelect: (file: FileInfo) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileMentionAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onFileSelect,
|
||||
onClose
|
||||
}, ref) => {
|
||||
const { currentDirectory } = useDirectoryStore();
|
||||
const { addServerFile } = useSessionStore();
|
||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
||||
const [files, setFiles] = React.useState<FileInfo[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [hoveredTooltipIndex, setHoveredTooltipIndex] = React.useState<number | null>(null);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (containerRef.current.contains(target)) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, debouncedQuery ?? '', 40)
|
||||
.then((hits) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFiles(hits.slice(0, 15));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFiles([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, debouncedQuery, searchFiles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
setHoveredTooltipIndex(null);
|
||||
}, [files]);
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleFileSelect = React.useCallback(async (file: FileInfo) => {
|
||||
|
||||
await addServerFile(file.path, file.name);
|
||||
onFileSelect(file);
|
||||
}, [addServerFile, onFileSelect]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
handleKeyDown: (key: string) => {
|
||||
if (key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const total = files.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % total);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||
const selectedFile = files[safeIndex];
|
||||
if (selectedFile) {
|
||||
handleFileSelect(selectedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}), [files, selectedIndex, onClose, handleFileSelect]);
|
||||
|
||||
const getFileIcon = (file: FileInfo) => {
|
||||
const ext = file.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return <RiCodeLine className="h-3.5 w-3.5 text-blue-500" />;
|
||||
case 'json':
|
||||
return <RiCodeLine className="h-3.5 w-3.5 text-yellow-500" />;
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
return <RiFileLine className="h-3.5 w-3.5 text-gray-500" />;
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
case 'svg':
|
||||
return <RiFileImageLine className="h-3.5 w-3.5 text-green-500" />;
|
||||
default:
|
||||
return <RiFilePdfLine className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-[240px] max-w-[520px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="pb-2">
|
||||
{files.map((file, index) => {
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 });
|
||||
const isSelected = selectedIndex === index;
|
||||
const isHovered = hoveredTooltipIndex === index;
|
||||
const tooltipOpen = isSelected || isHovered;
|
||||
|
||||
const item = (
|
||||
<div
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleFileSelect(file)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{getFileIcon(file)}
|
||||
<span className="flex-1 truncate max-w-[360px]" aria-label={relativePath}>
|
||||
{displayPath}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={file.path}
|
||||
open={tooltipOpen}
|
||||
delayDuration={tooltipOpen ? 0 : 120}
|
||||
onOpenChange={(open) => {
|
||||
setHoveredTooltipIndex((previous) => {
|
||||
if (!open && previous === index) {
|
||||
return null;
|
||||
}
|
||||
if (open) {
|
||||
return index;
|
||||
}
|
||||
return previous;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>{item}</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" className="max-w-xs">
|
||||
<span className="typography-meta text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{relativePath}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{}
|
||||
{files.length > 0 && <div className="h-2" />}
|
||||
{files.length === 0 && (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
||||
↑↓ navigate • Enter select • Esc close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import React from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import type { Part } from '@opencode-ai/sdk';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||
|
||||
const SHIKI_THEMES = ['vitesse-light', 'vitesse-dark'] as const;
|
||||
|
||||
// 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 CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const getCodeContent = (): string => {
|
||||
if (!codeRef.current) return '';
|
||||
const codeEl = codeRef.current.querySelector('code');
|
||||
|
||||
return codeEl?.innerText || '';
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const code = getCodeContent();
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const streamdownComponents = {
|
||||
pre: CodeBlockWrapper,
|
||||
table: TableWrapper,
|
||||
};
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
part?: Part;
|
||||
messageId: string;
|
||||
isAnimated?: boolean;
|
||||
className?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
part,
|
||||
messageId,
|
||||
isAnimated = true,
|
||||
className,
|
||||
isStreaming = false,
|
||||
}) => {
|
||||
const componentKey = React.useMemo(() => {
|
||||
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
|
||||
return `markdown-${signature}`;
|
||||
}, [messageId, part?.id]);
|
||||
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words', className)}>
|
||||
<Streamdown
|
||||
mode={isStreaming ? 'streaming' : 'static'}
|
||||
shikiTheme={SHIKI_THEMES}
|
||||
className="streamdown-content"
|
||||
controls={{ code: false, table: false }}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isAnimated) {
|
||||
return (
|
||||
<FadeInOnReveal key={componentKey}>
|
||||
{markdownContent}
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
}
|
||||
|
||||
return markdownContent;
|
||||
};
|
||||
|
||||
export const SimpleMarkdownRenderer: React.FC<{
|
||||
content: string;
|
||||
className?: string;
|
||||
}> = ({ content, className }) => {
|
||||
return (
|
||||
<div className={cn('break-words', className)}>
|
||||
<Streamdown
|
||||
mode="static"
|
||||
shikiTheme={SHIKI_THEMES}
|
||||
className="streamdown-content"
|
||||
controls={{ code: false, table: false }}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import type { Permission } from '@/types/permission';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||
import { useTurnGrouping } from './hooks/useTurnGrouping';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: { info: Message; parts: Part[] }[];
|
||||
permissions: Permission[];
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
hasMoreAbove: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadOlder: () => void;
|
||||
scrollToBottom?: (options?: { instant?: boolean }) => void;
|
||||
pendingAnchorId?: string | null;
|
||||
}
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({
|
||||
messages,
|
||||
permissions,
|
||||
onMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
hasMoreAbove,
|
||||
isLoadingOlder,
|
||||
onLoadOlder,
|
||||
scrollToBottom,
|
||||
pendingAnchorId,
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (permissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
onMessageContentChange('permission');
|
||||
}, [permissions, onMessageContentChange]);
|
||||
|
||||
const displayMessages = React.useMemo(() => {
|
||||
return messages.filter((message) => !isFullySyntheticMessage(message.parts));
|
||||
}, [messages]);
|
||||
|
||||
const { getContextForMessage } = useTurnGrouping(displayMessages);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hasMoreAbove && (
|
||||
<div className="flex justify-center py-3">
|
||||
{isLoadingOlder ? (
|
||||
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadOlder}
|
||||
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
|
||||
>
|
||||
Load older messages
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
{displayMessages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message.info.id}
|
||||
message={message}
|
||||
previousMessage={index > 0 ? displayMessages[index - 1] : undefined}
|
||||
nextMessage={index < displayMessages.length - 1 ? displayMessages[index + 1] : undefined}
|
||||
onContentChange={onMessageContentChange}
|
||||
animationHandlers={getAnimationHandlers(message.info.id)}
|
||||
scrollToBottom={scrollToBottom}
|
||||
isPendingAnchor={pendingAnchorId === message.info.id}
|
||||
turnGroupingContext={getContextForMessage(message.info.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
{permissions.length > 0 && (
|
||||
<div>
|
||||
{permissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageList);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
import React from 'react';
|
||||
import { RiCheckLine, RiCloseLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Permission, PermissionResponse } from '@/types/permission';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
interface PermissionCardProps {
|
||||
permission: Permission;
|
||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||
}
|
||||
|
||||
const getToolIcon = (toolName: string) => {
|
||||
const iconClass = "h-3 w-3";
|
||||
const tool = toolName.toLowerCase();
|
||||
|
||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||
return <RiPencilAiLine className={iconClass} />;
|
||||
}
|
||||
|
||||
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
||||
return <RiTerminalBoxLine className={iconClass} />;
|
||||
}
|
||||
|
||||
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
||||
return <RiGlobalLine className={iconClass} />;
|
||||
}
|
||||
|
||||
return <RiToolsLine className={iconClass} />;
|
||||
};
|
||||
|
||||
const getToolDisplayName = (toolName: string): string => {
|
||||
const tool = toolName.toLowerCase();
|
||||
|
||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||
return 'edit';
|
||||
}
|
||||
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal' || tool === 'shell_command') {
|
||||
return 'bash';
|
||||
}
|
||||
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
||||
return 'webfetch';
|
||||
}
|
||||
|
||||
return toolName;
|
||||
};
|
||||
|
||||
export const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
permission,
|
||||
onResponse
|
||||
}) => {
|
||||
const [isResponding, setIsResponding] = React.useState(false);
|
||||
const [hasResponded, setHasResponded] = React.useState(false);
|
||||
const { respondToPermission } = useSessionStore();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||
|
||||
const handleResponse = async (response: PermissionResponse) => {
|
||||
setIsResponding(true);
|
||||
|
||||
try {
|
||||
await respondToPermission(permission.sessionID, permission.id, response);
|
||||
setHasResponded(true);
|
||||
onResponse?.(response);
|
||||
} catch { /* ignored */ } finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasResponded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName = permission.type || 'Unknown Tool';
|
||||
const tool = toolName.toLowerCase();
|
||||
|
||||
const getMeta = (key: string, fallback: string = ''): string => {
|
||||
const val = permission.metadata[key];
|
||||
return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : fallback);
|
||||
};
|
||||
const getMetaNum = (key: string): number | undefined => {
|
||||
const val = permission.metadata[key];
|
||||
return typeof val === 'number' ? val : undefined;
|
||||
};
|
||||
const getMetaBool = (key: string): boolean => {
|
||||
const val = permission.metadata[key];
|
||||
return Boolean(val);
|
||||
};
|
||||
const displayToolName = getToolDisplayName(toolName);
|
||||
|
||||
const renderToolContent = () => {
|
||||
|
||||
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
|
||||
const command = getMeta('command') || getMeta('cmd') || getMeta('script');
|
||||
const description = getMeta('description');
|
||||
const workingDir = getMeta('cwd') || getMeta('working_directory') || getMeta('directory') || getMeta('path');
|
||||
const timeout = getMetaNum('timeout');
|
||||
|
||||
const commandInTitle = permission.title === command;
|
||||
|
||||
return (
|
||||
<>
|
||||
{description && (
|
||||
<div className="typography-meta text-muted-foreground mb-2">{description}</div>
|
||||
)}
|
||||
{workingDir && (
|
||||
<div className="typography-meta text-muted-foreground mb-2">
|
||||
<span className="font-semibold">Working Directory:</span> <code className="px-1 py-0.5 bg-muted/30 rounded">{workingDir}</code>
|
||||
</div>
|
||||
)}
|
||||
{timeout && (
|
||||
<div className="typography-meta text-muted-foreground mb-2">
|
||||
<span className="font-semibold">Timeout:</span> {timeout}ms
|
||||
</div>
|
||||
)}
|
||||
{}
|
||||
{command && !commandInTitle && (
|
||||
<div>
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}
|
||||
}}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{command}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||
const filePath = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||
const oldContent = getMeta('old_str') || getMeta('oldString') || getMeta('old_content') || getMeta('before');
|
||||
const newContent = getMeta('new_str') || getMeta('newString') || getMeta('new_content') || getMeta('after');
|
||||
const changes = getMeta('changes') || getMeta('diff');
|
||||
const replaceAll = getMetaBool('replace_all') || getMetaBool('replaceAll');
|
||||
|
||||
return (
|
||||
<>
|
||||
{filePath && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">File Path:</div>
|
||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded block break-all">
|
||||
{filePath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{replaceAll && (
|
||||
<div className="typography-meta text-muted-foreground mb-2">
|
||||
<span className="font-semibold">⚠️ Replace All Occurrences</span>
|
||||
</div>
|
||||
)}
|
||||
{changes ? (
|
||||
<div>
|
||||
<div className="typography-meta text-muted-foreground mb-1">Changes:</div>
|
||||
<ScrollableOverlay outerClassName="max-h-64" className="overflow-x-auto p-0">
|
||||
<SyntaxHighlighter
|
||||
language="diff"
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem'
|
||||
}}
|
||||
wrapLongLines={false}
|
||||
>
|
||||
{changes}
|
||||
</SyntaxHighlighter>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{oldContent && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">Remove:</div>
|
||||
<ScrollableOverlay outerClassName="max-h-32 border border-red-500/20 rounded bg-red-500/5 p-2" className="p-0">
|
||||
<pre className="typography-meta font-mono text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
|
||||
{oldContent.length > 500 ? oldContent.substring(0, 500) + '...' : oldContent}
|
||||
</pre>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
{newContent && (
|
||||
<div>
|
||||
<div className="typography-meta text-muted-foreground mb-1">Replace with:</div>
|
||||
<ScrollableOverlay outerClassName="max-h-32 border border-green-500/20 rounded bg-green-500/5 p-2" className="p-0">
|
||||
<pre className="typography-meta font-mono text-green-600 dark:text-green-400 whitespace-pre-wrap break-all">
|
||||
{newContent.length > 500 ? newContent.substring(0, 500) + '...' : newContent}
|
||||
</pre>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'webfetch' || tool === 'fetch' || tool === 'curl' || tool === 'wget') {
|
||||
const url = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
||||
const method = getMeta('method') || 'GET';
|
||||
const headers = permission.metadata.headers && typeof permission.metadata.headers === 'object' ? (permission.metadata.headers as Record<string, unknown>) : undefined;
|
||||
const body = getMeta('body') || getMeta('data') || getMeta('payload');
|
||||
const timeout = getMetaNum('timeout');
|
||||
const format = getMeta('format') || getMeta('responseType');
|
||||
|
||||
return (
|
||||
<>
|
||||
{url && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">Request:</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-meta font-semibold px-1.5 py-0.5 bg-primary/20 text-primary rounded">
|
||||
{method}
|
||||
</span>
|
||||
<code className="typography-meta px-2 py-1 bg-muted/30 rounded flex-1 break-all">
|
||||
{url}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{headers && Object.keys(headers).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">Headers:</div>
|
||||
<ScrollableOverlay outerClassName="max-h-24" className="p-0">
|
||||
<SyntaxHighlighter
|
||||
language="json"
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem'
|
||||
}}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{JSON.stringify(headers, null, 2)}
|
||||
</SyntaxHighlighter>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
{body && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">Body:</div>
|
||||
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||
<SyntaxHighlighter
|
||||
language={typeof body === 'object' ? 'json' : 'text'}
|
||||
style={syntaxTheme}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem'
|
||||
}}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body)}
|
||||
</SyntaxHighlighter>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
{(timeout || format) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{timeout && <span>Timeout: {timeout}ms</span>}
|
||||
{timeout && format && <span> • </span>}
|
||||
{format && <span>Response format: {format}</span>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const genericContent = getMeta('command') || getMeta('content') || getMeta('action') || getMeta('operation');
|
||||
const description = getMeta('description');
|
||||
|
||||
return (
|
||||
<>
|
||||
{description && (
|
||||
<div className="typography-meta text-muted-foreground mb-2">{description}</div>
|
||||
)}
|
||||
{genericContent && (
|
||||
<div className="mb-2">
|
||||
<div className="typography-meta text-muted-foreground mb-1">Action:</div>
|
||||
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
|
||||
{String(genericContent)}
|
||||
</pre>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
{}
|
||||
{Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
|
||||
<div>
|
||||
<div className="typography-meta text-muted-foreground mb-1">Details:</div>
|
||||
<ScrollableOverlay outerClassName="max-h-32" className="p-0">
|
||||
<pre className="typography-meta font-mono px-2 py-1 bg-muted/30 rounded whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(permission.metadata, null, 2)}
|
||||
</pre>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group w-full pt-0 pb-2">
|
||||
<div className="chat-column">
|
||||
<div className="-mt-1 border border-border/30 rounded-xl bg-muted/10">
|
||||
{}
|
||||
<div className="px-2 py-1.5 border-b border-border/20 bg-muted/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiQuestionLine className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span className="typography-meta font-medium text-muted-foreground">
|
||||
Permission Required
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getToolIcon(toolName)}
|
||||
<span className="typography-meta text-muted-foreground font-medium">{displayToolName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="px-2 py-2">
|
||||
{}
|
||||
{(() => {
|
||||
|
||||
let primaryContent = '';
|
||||
let primaryLanguage = 'text';
|
||||
let shouldHighlight = false;
|
||||
|
||||
if (tool === 'bash' || tool === 'shell' || tool === 'shell_command') {
|
||||
primaryContent = getMeta('command') || getMeta('cmd') || getMeta('script');
|
||||
primaryLanguage = 'bash';
|
||||
shouldHighlight = true;
|
||||
}
|
||||
|
||||
else if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||
primaryContent = getMeta('path') || getMeta('file_path') || getMeta('filename') || getMeta('filePath');
|
||||
shouldHighlight = false;
|
||||
}
|
||||
|
||||
else if (tool === 'webfetch' || tool === 'fetch') {
|
||||
primaryContent = getMeta('url') || getMeta('uri') || getMeta('endpoint');
|
||||
shouldHighlight = false;
|
||||
}
|
||||
|
||||
const titleMatchesContent = permission.title === primaryContent;
|
||||
|
||||
if (titleMatchesContent && primaryContent && shouldHighlight) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<SyntaxHighlighter
|
||||
language={primaryLanguage}
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
fontSize: 'var(--text-meta)',
|
||||
lineHeight: '1.25rem',
|
||||
background: 'rgb(var(--muted) / 0.3)',
|
||||
borderRadius: '0.25rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}
|
||||
}}
|
||||
wrapLongLines={true}
|
||||
>
|
||||
{primaryContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (titleMatchesContent && primaryContent && !shouldHighlight) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<code className="typography-ui-label px-2 py-1 bg-muted/30 rounded block break-all">
|
||||
{primaryContent}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (permission.title) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"typography-ui-label text-foreground mb-3",
|
||||
|
||||
(shouldHighlight || primaryContent) && "font-mono"
|
||||
)}>
|
||||
{permission.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{}
|
||||
{renderToolContent()}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="px-2 pb-1.5 pt-1 flex items-center gap-1.5 border-t border-border/20">
|
||||
<button
|
||||
onClick={() => handleResponse('once')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--status-success) / 0.1)',
|
||||
color: 'var(--status-success)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-success) / 0.1)';
|
||||
}}
|
||||
>
|
||||
<RiCheckLine className="h-3 w-3" />
|
||||
Allow Once
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('always')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--muted) / 0.5)',
|
||||
color: 'var(--muted-foreground)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.7)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--muted) / 0.5)';
|
||||
}}
|
||||
>
|
||||
<RiTimeLine className="h-3 w-3" />
|
||||
Always Allow
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('reject')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded transition-all",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'rgb(var(--status-error) / 0.1)',
|
||||
color: 'var(--status-error)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgb(var(--status-error) / 0.1)';
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
Deny
|
||||
</button>
|
||||
|
||||
{isResponding && (
|
||||
<div className="ml-auto typography-meta text-muted-foreground">
|
||||
<div className="animate-spin h-3 w-3 border border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Permission, PermissionResponse } from '@/types/permission';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
interface PermissionRequestProps {
|
||||
permission: Permission;
|
||||
onResponse?: (response: 'once' | 'always' | 'reject') => void;
|
||||
}
|
||||
|
||||
export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
permission,
|
||||
onResponse
|
||||
}) => {
|
||||
const [isResponding, setIsResponding] = React.useState(false);
|
||||
const [hasResponded, setHasResponded] = React.useState(false);
|
||||
const { respondToPermission } = useSessionStore();
|
||||
|
||||
const handleResponse = async (response: PermissionResponse) => {
|
||||
setIsResponding(true);
|
||||
|
||||
try {
|
||||
await respondToPermission(permission.sessionID, permission.id, response);
|
||||
setHasResponded(true);
|
||||
onResponse?.(response);
|
||||
} catch { /* ignored */ } finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasResponded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = typeof permission.metadata.command === 'string'
|
||||
? permission.metadata.command
|
||||
: permission.title;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<span className="typography-ui-label font-medium text-muted-foreground">
|
||||
Permission required:
|
||||
</span>
|
||||
<code className="ml-2 typography-meta bg-amber-100/50 dark:bg-amber-800/30 px-1.5 py-0.5 rounded font-mono text-amber-800 dark:text-amber-200">
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => handleResponse('once')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-success)',
|
||||
color: 'var(--status-success)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-success-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<RiCheckLine className="h-3 w-3" />
|
||||
Once
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('always')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-info)',
|
||||
color: 'var(--status-info)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-info-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<RiTimeLine className="h-3 w-3" />
|
||||
Always
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse('reject')}
|
||||
disabled={isResponding}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 typography-meta font-medium rounded border h-6",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
borderColor: 'var(--status-error)',
|
||||
color: 'var(--status-error)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--status-error-background)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
Reject
|
||||
</button>
|
||||
|
||||
{isResponding && (
|
||||
<div className="ml-2 flex items-center">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-t-transparent rounded-full" style={{ borderColor: 'var(--loading-spinner)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,559 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { RiCloseLine, RiCodeLine, RiFileImageLine, RiFileTextLine, RiFolder6Line, RiSearchLine } from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, truncatePathMiddle } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
extension?: string;
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
interface ServerFilePickerProps {
|
||||
onFilesSelected: (files: FileInfo[]) => void;
|
||||
multiSelect?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||
onFilesSelected,
|
||||
multiSelect = false,
|
||||
children
|
||||
}) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { currentDirectory } = useDirectoryStore();
|
||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
|
||||
const [selectedFiles, setSelectedFiles] = React.useState<Set<string>>(new Set());
|
||||
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
|
||||
const [fileTree, setFileTree] = React.useState<FileInfo[]>([]);
|
||||
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [attaching, setAttaching] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const loadDirectory = React.useCallback(async (dirPath: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tempClient = opencodeClient.getApiClient();
|
||||
const response = await tempClient.file.list({
|
||||
query: {
|
||||
path: '.',
|
||||
directory: dirPath
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
setFileTree([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = response.data
|
||||
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
|
||||
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
|
||||
const extension = item.type === 'file'
|
||||
? item.name.split('.').pop()?.toLowerCase()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
path: item.absolute || `${dirPath}/${item.name}`,
|
||||
type: item.type as 'file' | 'directory',
|
||||
size: item.size || 0,
|
||||
extension
|
||||
};
|
||||
})
|
||||
.sort((a: FileInfo, b: FileInfo) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
setFileTree(items);
|
||||
} catch {
|
||||
setError('Failed to load directory contents');
|
||||
setFileTree([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if ((open || mobileOpen) && currentDirectory) {
|
||||
void loadDirectory(currentDirectory);
|
||||
}
|
||||
}, [open, mobileOpen, currentDirectory, loadDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!(open || mobileOpen) || !currentDirectory) {
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedQuery = debouncedSearchQuery.trim();
|
||||
if (!trimmedQuery) {
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
|
||||
searchFiles(currentDirectory, trimmedQuery, 150)
|
||||
.then((hits) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const mappedHits: FileInfo[] = hits.map((hit) => ({
|
||||
name: hit.name,
|
||||
path: hit.path,
|
||||
type: 'file',
|
||||
extension: hit.extension,
|
||||
relativePath: hit.relativePath,
|
||||
size: 0,
|
||||
}));
|
||||
setSearchResults(mappedHits);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSearchResults([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setSearching(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, mobileOpen, currentDirectory, debouncedSearchQuery, searchFiles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open && !mobileOpen) {
|
||||
setSelectedFiles(new Set());
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
}
|
||||
}, [open, mobileOpen]);
|
||||
|
||||
const getFileIcon = (file: FileInfo) => {
|
||||
if (file.type === 'directory') {
|
||||
return expandedDirs.has(file.path) ? (
|
||||
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
|
||||
) : (
|
||||
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
|
||||
);
|
||||
}
|
||||
|
||||
const ext = file.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'html':
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return <RiCodeLine className="h-3.5 w-3.5 text-blue-500" />;
|
||||
case 'json':
|
||||
return <RiCodeLine className="h-3.5 w-3.5 text-yellow-500" />;
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
return <RiFileTextLine className="h-3.5 w-3.5 text-gray-500" />;
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
case 'svg':
|
||||
return <RiFileImageLine className="h-3.5 w-3.5 text-green-500" />;
|
||||
default:
|
||||
return <RiFileTextLine className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDirectory = async (dirPath: string) => {
|
||||
const isExpanded = expandedDirs.has(dirPath);
|
||||
|
||||
if (isExpanded) {
|
||||
setExpandedDirs(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(dirPath);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setExpandedDirs(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(dirPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const tempClient = opencodeClient.getApiClient();
|
||||
const response = await tempClient.file.list({
|
||||
query: {
|
||||
path: '.',
|
||||
directory: dirPath
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const subItems = response.data
|
||||
.filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.'))
|
||||
.map((item: { name: string; type: string; size?: number; absolute?: string }) => {
|
||||
const extension = item.type === 'file'
|
||||
? item.name.split('.').pop()?.toLowerCase()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
path: item.absolute || `${dirPath}/${item.name}`,
|
||||
type: item.type as 'file' | 'directory',
|
||||
size: 0,
|
||||
extension
|
||||
};
|
||||
});
|
||||
|
||||
setFileTree(prev => {
|
||||
const filtered = prev.filter(item => !item.path.startsWith(dirPath + '/'));
|
||||
return [...filtered, ...subItems].sort((a, b) => {
|
||||
const aDepth = a.path.split('/').length;
|
||||
const bDepth = b.path.split('/').length;
|
||||
if (aDepth !== bDepth) return aDepth - bDepth;
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFileSelection = (filePath: string) => {
|
||||
if (multiSelect) {
|
||||
setSelectedFiles(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedFiles(new Set([filePath]));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const treeFileMap = new Map(
|
||||
fileTree
|
||||
.filter((file) => file.type === 'file')
|
||||
.map((file) => [file.path, file])
|
||||
);
|
||||
const searchFileMap = new Map(searchResults.map((file) => [file.path, file]));
|
||||
|
||||
const selected = Array.from(selectedFiles)
|
||||
.map((filePath) => treeFileMap.get(filePath) ?? searchFileMap.get(filePath))
|
||||
.filter((file): file is FileInfo => Boolean(file));
|
||||
|
||||
setAttaching(true);
|
||||
try {
|
||||
await onFilesSelected(selected);
|
||||
setSelectedFiles(new Set());
|
||||
setOpen(false);
|
||||
setMobileOpen(false);
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rootItems = React.useMemo(() => {
|
||||
if (!currentDirectory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fileTree.filter((item) => {
|
||||
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
|
||||
return itemDir === currentDirectory;
|
||||
});
|
||||
}, [fileTree, currentDirectory]);
|
||||
|
||||
const isSearchActive = searchQuery.trim().length > 0;
|
||||
|
||||
const getChildItems = (parentPath: string) => {
|
||||
return fileTree.filter(item => {
|
||||
const itemDir = item.path.substring(0, item.path.lastIndexOf('/'));
|
||||
return itemDir === parentPath;
|
||||
});
|
||||
};
|
||||
|
||||
const getRelativePath = (fullPath: string) => {
|
||||
if (currentDirectory && fullPath.startsWith(currentDirectory)) {
|
||||
const relativePath = fullPath.substring(currentDirectory.length);
|
||||
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||
}
|
||||
return fullPath.split('/').pop() || fullPath;
|
||||
};
|
||||
|
||||
const renderFileItem = (file: FileInfo, level: number) => {
|
||||
const rawLabel = isSearchActive
|
||||
? file.relativePath || getRelativePath(file.path)
|
||||
: file.name;
|
||||
const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45;
|
||||
const displayLabel = shouldCompact
|
||||
? truncatePathMiddle(rawLabel, { maxLength: isMobile ? 42 : 48 })
|
||||
: rawLabel;
|
||||
|
||||
const row = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded hover:bg-accent cursor-pointer typography-ui-label text-foreground text-left",
|
||||
file.type === 'file' && selectedFiles.has(file.path) && "bg-primary/10"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (file.type === 'file') {
|
||||
toggleFileSelection(file.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-start gap-1">
|
||||
<span className="text-muted-foreground">{getFileIcon(file)}</span>
|
||||
<span className="flex-1 truncate text-foreground text-left max-w-[360px]" aria-label={rawLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</div>
|
||||
{file.type === 'file' && selectedFiles.has(file.path) && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!shouldCompact) {
|
||||
return React.cloneElement(row, { key: file.path });
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={file.path} delayDuration={120}>
|
||||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<span className="typography-meta text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{rawLabel}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFileTree = (file: FileInfo, level: number): React.ReactNode => {
|
||||
const isDirectory = file.type === 'directory';
|
||||
const children = isDirectory ? getChildItems(file.path) : [];
|
||||
const isExpanded = expandedDirs.has(file.path);
|
||||
|
||||
return (
|
||||
<div key={file.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded cursor-pointer typography-ui-label text-foreground text-left',
|
||||
!isDirectory && selectedFiles.has(file.path) && 'bg-primary/10'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDirectory) {
|
||||
toggleDirectory(file.path);
|
||||
} else {
|
||||
toggleFileSelection(file.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground">{getFileIcon(file)}</span>
|
||||
<span className="flex-1 truncate text-foreground text-left">
|
||||
{file.name}
|
||||
</span>
|
||||
{!isDirectory && selectedFiles.has(file.path) && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDirectory && isExpanded && children.length > 0 && (
|
||||
<div>
|
||||
{children.map((child) => renderFileTree(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const summaryLabel = selectedFiles.size > 0
|
||||
? `${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} selected`
|
||||
: 'No files selected';
|
||||
|
||||
const summarySection = (
|
||||
<div className="flex items-center justify-between px-3 py-2 shrink-0">
|
||||
<div className="typography-meta text-muted-foreground">{summaryLabel}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedFiles.size === 0 || attaching}
|
||||
className="h-6 typography-meta"
|
||||
>
|
||||
{attaching ? 'Attaching...' : 'Attach Files'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const scrollAreaClass = isMobile ? 'flex-1 min-h-[240px]' : 'h-[300px]';
|
||||
|
||||
const pickerBody = (
|
||||
<>
|
||||
<div className="px-3 py-2 border-b shrink-0">
|
||||
<div className="font-medium typography-ui-label text-foreground">Select Project Files</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b shrink-0">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="pl-7 h-6 typography-ui-label"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded"
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3"/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className={scrollAreaClass}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="typography-ui-label text-muted-foreground">Loading files...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="typography-ui-label text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="py-1 px-2">
|
||||
{isSearchActive ? (
|
||||
searching ? (
|
||||
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||
Searching files…
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((file) => renderFileItem(file, 0))
|
||||
)
|
||||
) : (
|
||||
rootItems.map((file) => renderFileTree(file, 0))
|
||||
)}
|
||||
|
||||
{!isSearchActive && rootItems.length === 0 && (
|
||||
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||
No files in this directory
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSearchActive && !searching && searchResults.length === 0 && (
|
||||
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
|
||||
const mobileTrigger = (
|
||||
<span
|
||||
className="inline-flex cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMobileOpen(true);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{mobileTrigger}
|
||||
<MobileOverlayPanel
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
title="Select Project Files"
|
||||
footer={summarySection}
|
||||
>
|
||||
<div className="flex flex-col gap-0">{pickerBody}</div>
|
||||
</MobileOverlayPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[520px] p-0 overflow-hidden flex flex-col ml-16"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{pickerBody}
|
||||
<DropdownMenuSeparator />
|
||||
{summarySection}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,390 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
|
||||
export interface ChatMessageEntry {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
turnId: string;
|
||||
userMessage: ChatMessageEntry;
|
||||
assistantMessages: ChatMessageEntry[];
|
||||
}
|
||||
|
||||
export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
|
||||
|
||||
export interface TurnActivityPart {
|
||||
id: string;
|
||||
turnId: string;
|
||||
messageId: string;
|
||||
kind: TurnActivityKind;
|
||||
part: Part;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
interface TurnDiffStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
files: number;
|
||||
}
|
||||
|
||||
export interface TurnGroupingContext {
|
||||
turnId: string;
|
||||
isFirstAssistantInTurn: boolean;
|
||||
|
||||
summaryBody?: string;
|
||||
|
||||
activityParts: TurnActivityPart[];
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
diffStats?: TurnDiffStats;
|
||||
|
||||
isWorking: boolean;
|
||||
isGroupExpanded: boolean;
|
||||
|
||||
previewedPartIds: Set<string>;
|
||||
toggleGroup: () => void;
|
||||
markPartsPreviewed: (partIds: string[]) => void;
|
||||
}
|
||||
|
||||
interface TurnUiState {
|
||||
isExpanded: boolean;
|
||||
previewedPartIds: Set<string>;
|
||||
}
|
||||
|
||||
interface TurnActivityInfo {
|
||||
activityParts: TurnActivityPart[];
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
summaryBody?: string;
|
||||
diffStats?: TurnDiffStats;
|
||||
}
|
||||
|
||||
const ENABLE_TEXT_JUSTIFICATION_ACTIVITY = false;
|
||||
|
||||
export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
|
||||
const result: Turn[] = [];
|
||||
let currentTurn: Turn | null = null;
|
||||
|
||||
messages.forEach((msg) => {
|
||||
const role = (msg.info as { clientRole?: string | null | undefined }).clientRole ?? msg.info.role;
|
||||
|
||||
if (role === 'user') {
|
||||
currentTurn = {
|
||||
turnId: msg.info.id,
|
||||
userMessage: msg,
|
||||
assistantMessages: [],
|
||||
};
|
||||
result.push(currentTurn);
|
||||
} else if (role === 'assistant' && currentTurn) {
|
||||
currentTurn.assistantMessages.push(msg);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const extractFinalAssistantText = (turn: Turn): string | undefined => {
|
||||
|
||||
for (const assistantMsg of turn.assistantMessages) {
|
||||
|
||||
for (const part of assistantMsg.parts) {
|
||||
if (part.type === 'step-finish') {
|
||||
const finishReason = (part as { reason?: string | null | undefined }).reason;
|
||||
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
|
||||
|
||||
if (finishReason === 'stop' || infoFinish === 'stop') {
|
||||
|
||||
const textPart = assistantMsg.parts.find(p => p.type === 'text');
|
||||
if (textPart) {
|
||||
const textContent = (textPart as { text?: string | null | undefined }).text ??
|
||||
(textPart as { content?: string | null | undefined }).content;
|
||||
if (typeof textContent === 'string' && textContent.trim().length > 0) {
|
||||
return textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getTurnActivityInfo = (turn: Turn): TurnActivityInfo => {
|
||||
interface SummaryDiff {
|
||||
additions?: number | null | undefined;
|
||||
deletions?: number | null | undefined;
|
||||
file?: string | null | undefined;
|
||||
}
|
||||
interface UserSummaryPayload {
|
||||
body?: string | null | undefined;
|
||||
diffs?: SummaryDiff[] | null | undefined;
|
||||
}
|
||||
|
||||
const summaryBody = extractFinalAssistantText(turn);
|
||||
|
||||
let diffStats: TurnDiffStats | undefined;
|
||||
|
||||
const summary = (turn.userMessage.info as { summary?: UserSummaryPayload | null | undefined }).summary;
|
||||
const diffs = summary?.diffs;
|
||||
if (Array.isArray(diffs) && diffs.length > 0) {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let files = 0;
|
||||
|
||||
diffs.forEach((diff) => {
|
||||
if (!diff) {
|
||||
return;
|
||||
}
|
||||
const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
|
||||
const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
|
||||
|
||||
if (diffAdditions !== 0 || diffDeletions !== 0) {
|
||||
files += 1;
|
||||
}
|
||||
additions += diffAdditions;
|
||||
deletions += diffDeletions;
|
||||
});
|
||||
|
||||
if (files > 0) {
|
||||
diffStats = {
|
||||
additions,
|
||||
deletions,
|
||||
files,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let hasTools = false;
|
||||
let hasReasoning = false;
|
||||
|
||||
turn.assistantMessages.forEach((msg) => {
|
||||
msg.parts.forEach((part) => {
|
||||
if (part.type === 'tool') {
|
||||
hasTools = true;
|
||||
} else if (part.type === 'reasoning') {
|
||||
hasReasoning = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const activityParts: TurnActivityPart[] = [];
|
||||
let syntheticIdCounter = 0;
|
||||
|
||||
turn.assistantMessages.forEach((msg) => {
|
||||
const messageId = msg.info.id;
|
||||
const hasStopFinishInMessage = ENABLE_TEXT_JUSTIFICATION_ACTIVITY
|
||||
? msg.parts.some((part) => {
|
||||
if (part.type !== 'step-finish') return false;
|
||||
const reason = (part as { reason?: string | null | undefined }).reason;
|
||||
return reason === 'stop';
|
||||
})
|
||||
: false;
|
||||
|
||||
msg.parts.forEach((part) => {
|
||||
const baseId =
|
||||
(typeof part.id === 'string' && part.id.trim().length > 0)
|
||||
? part.id
|
||||
: `${messageId}-activity-${syntheticIdCounter++}`;
|
||||
|
||||
if (part.type === 'tool') {
|
||||
const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
|
||||
const time = state?.time;
|
||||
const end = typeof time?.end === 'number' ? time.end : undefined;
|
||||
|
||||
activityParts.push({
|
||||
id: baseId,
|
||||
turnId: turn.turnId,
|
||||
messageId,
|
||||
kind: 'tool',
|
||||
part,
|
||||
endedAt: end,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text
|
||||
?? (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||
const end = typeof time?.end === 'number' ? time.end : undefined;
|
||||
|
||||
activityParts.push({
|
||||
id: baseId,
|
||||
turnId: turn.turnId,
|
||||
messageId,
|
||||
kind: 'reasoning',
|
||||
part,
|
||||
endedAt: end,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ENABLE_TEXT_JUSTIFICATION_ACTIVITY &&
|
||||
part.type === 'text' &&
|
||||
(hasTools || hasReasoning) &&
|
||||
!hasStopFinishInMessage
|
||||
) {
|
||||
const text =
|
||||
(part as { text?: string | null | undefined; content?: string | null | undefined }).text ??
|
||||
(part as { text?: string | null | undefined; content?: string | null | undefined }).content;
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
|
||||
const end = typeof time?.end === 'number' ? time.end : undefined;
|
||||
|
||||
activityParts.push({
|
||||
id: baseId,
|
||||
turnId: turn.turnId,
|
||||
messageId,
|
||||
kind: 'justification',
|
||||
part,
|
||||
endedAt: end,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
activityParts,
|
||||
hasTools,
|
||||
hasReasoning,
|
||||
summaryBody,
|
||||
diffStats,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseTurnGroupingResult {
|
||||
turns: Turn[];
|
||||
getTurnForMessage: (messageId: string) => Turn | undefined;
|
||||
getContextForMessage: (messageId: string) => TurnGroupingContext | undefined;
|
||||
}
|
||||
|
||||
export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingResult => {
|
||||
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
||||
|
||||
const turns = React.useMemo(() => detectTurns(messages), [messages]);
|
||||
|
||||
const lastTurnId = React.useMemo(() => {
|
||||
if (turns.length === 0) return null;
|
||||
return turns[turns.length - 1]!.turnId;
|
||||
}, [turns]);
|
||||
|
||||
const messageToTurn = React.useMemo(() => {
|
||||
const map = new Map<string, Turn>();
|
||||
turns.forEach((turn) => {
|
||||
map.set(turn.userMessage.info.id, turn);
|
||||
turn.assistantMessages.forEach((msg) => {
|
||||
map.set(msg.info.id, turn);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [turns]);
|
||||
|
||||
const turnActivityInfo = React.useMemo(() => {
|
||||
const map = new Map<string, TurnActivityInfo>();
|
||||
turns.forEach((turn) => {
|
||||
map.set(turn.turnId, getTurnActivityInfo(turn));
|
||||
});
|
||||
return map;
|
||||
}, [turns]);
|
||||
|
||||
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(
|
||||
() => new Map()
|
||||
);
|
||||
|
||||
const getOrCreateTurnState = React.useCallback(
|
||||
(turnId: string): TurnUiState => {
|
||||
const existing = turnUiStates.get(turnId);
|
||||
if (existing) return existing;
|
||||
return { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||
},
|
||||
[turnUiStates]
|
||||
);
|
||||
|
||||
const toggleGroup = React.useCallback((turnId: string) => {
|
||||
setTurnUiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||
next.set(turnId, { ...current, isExpanded: !current.isExpanded });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => {
|
||||
if (partIds.length === 0) return;
|
||||
|
||||
setTurnUiStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const state = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set<string>() };
|
||||
const newPreviewed = new Set(state.previewedPartIds);
|
||||
partIds.forEach((id) => {
|
||||
if (id && id.trim().length > 0) {
|
||||
newPreviewed.add(id);
|
||||
}
|
||||
});
|
||||
next.set(turnId, { ...state, previewedPartIds: newPreviewed });
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getTurnForMessage = React.useCallback(
|
||||
(messageId: string): Turn | undefined => {
|
||||
return messageToTurn.get(messageId);
|
||||
},
|
||||
[messageToTurn]
|
||||
);
|
||||
|
||||
const getContextForMessage = React.useCallback(
|
||||
(messageId: string): TurnGroupingContext | undefined => {
|
||||
const turn = messageToTurn.get(messageId);
|
||||
if (!turn) return undefined;
|
||||
|
||||
const isAssistantMessage = turn.assistantMessages.some(
|
||||
(msg) => msg.info.id === messageId
|
||||
);
|
||||
if (!isAssistantMessage) return undefined;
|
||||
|
||||
const activityInfo = turnActivityInfo.get(turn.turnId);
|
||||
const activityParts = activityInfo?.activityParts ?? [];
|
||||
const hasTools = Boolean(activityInfo?.hasTools);
|
||||
const hasReasoning = Boolean(activityInfo?.hasReasoning);
|
||||
const summaryBody = activityInfo?.summaryBody;
|
||||
const diffStats = activityInfo?.diffStats;
|
||||
|
||||
const firstAssistantId = turn.assistantMessages[0]?.info.id;
|
||||
const isFirstAssistantInTurn = messageId === firstAssistantId;
|
||||
|
||||
const uiState = getOrCreateTurnState(turn.turnId);
|
||||
const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId;
|
||||
|
||||
return {
|
||||
turnId: turn.turnId,
|
||||
isFirstAssistantInTurn,
|
||||
summaryBody,
|
||||
activityParts,
|
||||
hasTools,
|
||||
hasReasoning,
|
||||
diffStats,
|
||||
isWorking: isTurnWorking,
|
||||
isGroupExpanded: uiState.isExpanded,
|
||||
previewedPartIds: uiState.previewedPartIds,
|
||||
toggleGroup: () => toggleGroup(turn.turnId),
|
||||
markPartsPreviewed: (partIds: string[]) => markPartsPreviewedInternal(turn.turnId, partIds),
|
||||
};
|
||||
}, [getOrCreateTurnState, lastTurnId, sessionIsWorking, markPartsPreviewedInternal, messageToTurn, toggleGroup, turnActivityInfo]
|
||||
);
|
||||
|
||||
return {
|
||||
turns,
|
||||
getTurnForMessage,
|
||||
getContextForMessage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { RiAlignJustify, RiLayoutColumnLine } from '@remixicon/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type DiffViewMode = 'side-by-side' | 'unified';
|
||||
|
||||
interface DiffViewToggleProps {
|
||||
mode: DiffViewMode;
|
||||
onModeChange: (mode: DiffViewMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DiffViewToggle: React.FC<DiffViewToggleProps> = ({ mode, onModeChange, className }) => {
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onModeChange(mode === 'side-by-side' ? 'unified' : 'side-by-side');
|
||||
},
|
||||
[mode, onModeChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn('h-5 w-5 p-0 opacity-60 hover:opacity-100', className)}
|
||||
onClick={handleClick}
|
||||
title={mode === 'side-by-side' ? 'Switch to unified view' : 'Switch to side-by-side view'}
|
||||
>
|
||||
{mode === 'side-by-side' ? (
|
||||
<RiAlignJustify className="h-3 w-3" />
|
||||
) : (
|
||||
<RiLayoutColumnLine className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FadeInOnRevealProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FADE_ANIMATION_ENABLED = true;
|
||||
|
||||
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className }) => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!FADE_ANIMATION_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame: number | null = null;
|
||||
|
||||
const enable = () => setVisible(true);
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
frame = window.requestAnimationFrame(enable);
|
||||
} else {
|
||||
enable();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (
|
||||
frame !== null &&
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.cancelAnimationFrame === 'function'
|
||||
) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!FADE_ANIMATION_ENABLED) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full transition-all duration-300 ease-out',
|
||||
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { RiBrainAi3Line, RiUser3Line } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getAgentColor } from '@/lib/agentColors';
|
||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||
import { useProviderLogo } from '@/hooks/useProviderLogo';
|
||||
|
||||
interface MessageHeaderProps {
|
||||
isUser: boolean;
|
||||
providerID: string | null;
|
||||
agentName: string | undefined;
|
||||
modelName: string | undefined;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agentName, modelName, isDarkTheme }) => {
|
||||
const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
|
||||
|
||||
return (
|
||||
<FadeInOnReveal>
|
||||
<div className={cn('pl-3', '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">
|
||||
<RiUser3Line 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}
|
||||
/>
|
||||
) : (
|
||||
<RiBrainAi3Line
|
||||
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',
|
||||
'agent-badge typography-meta',
|
||||
getAgentColor(agentName).class
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{agentName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeInOnReveal>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MessageHeader);
|
||||
@@ -0,0 +1,563 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { RiBrainAi3Line, RiFileImageLine, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toolDisplayStyles } from '@/lib/typography';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import {
|
||||
renderTodoOutput,
|
||||
renderListOutput,
|
||||
renderGrepOutput,
|
||||
renderGlobOutput,
|
||||
renderWebSearchOutput,
|
||||
formatInputForDisplay,
|
||||
parseDiffToUnified,
|
||||
type UnifiedDiffHunk,
|
||||
type SideBySideDiffHunk,
|
||||
type SideBySideDiffLine,
|
||||
} from './toolRenderers';
|
||||
import type { ToolPopupContent, DiffViewMode } from './types';
|
||||
import { DiffViewToggle } from './DiffViewToggle';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const getToolIcon = (toolName: string) => {
|
||||
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||
const tool = toolName.toLowerCase();
|
||||
|
||||
if (tool === 'reasoning') {
|
||||
return <RiBrainAi3Line className={iconClass} />;
|
||||
}
|
||||
if (tool === 'image-preview') {
|
||||
return <RiFileImageLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||
return <RiPencilAiLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
|
||||
return <RiFilePdfLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
|
||||
return <RiFilePdfLine 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 <RiSearchLine 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 === 'google' || tool === 'bing' || tool === 'duckduckgo') {
|
||||
return <RiSearchLine className={iconClass} />;
|
||||
}
|
||||
if (tool === 'todowrite' || tool === 'todoread') {
|
||||
return <RiListCheck3 className={iconClass} />;
|
||||
}
|
||||
if (tool.startsWith('git')) {
|
||||
return <RiGitBranchLine className={iconClass} />;
|
||||
}
|
||||
return <RiToolsLine className={iconClass} />;
|
||||
};
|
||||
|
||||
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
||||
|
||||
return (
|
||||
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'overflow-hidden flex flex-col min-h-0 pt-3 pb-4 px-4 gap-1',
|
||||
'[&>button]:top-1.5',
|
||||
isMobile ? 'w-[95vw] max-w-[95vw]' : 'max-w-5xl',
|
||||
isMobile ? '[&>button]:right-1' : '[&>button]:top-2.5 [&>button]:right-4'
|
||||
)}
|
||||
style={{ maxHeight: '90vh' }}
|
||||
>
|
||||
<div className="flex-shrink-0 pb-1">
|
||||
<div className="flex items-start gap-2 text-foreground typography-ui-header font-semibold">
|
||||
{popup.metadata?.tool ? getToolIcon(popup.metadata.tool as string) : (
|
||||
<RiToolsLine className="h-3.5 w-3.5 text-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="break-words flex-1 leading-tight">{popup.title}</span>
|
||||
{popup.isDiff && (
|
||||
<DiffViewToggle
|
||||
mode={diffViewMode}
|
||||
onModeChange={setDiffViewMode}
|
||||
className="mr-8 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-border/30 bg-muted/10 overflow-hidden">
|
||||
<div className="h-full max-h-[75vh] overflow-y-auto px-3 pr-4">
|
||||
{popup.metadata?.input && typeof popup.metadata.input === 'object' &&
|
||||
Object.keys(popup.metadata.input).length > 0 &&
|
||||
popup.metadata?.tool !== 'todowrite' &&
|
||||
popup.metadata?.tool !== 'todoread' ? (() => {
|
||||
const meta = popup.metadata!;
|
||||
const input = meta.input as Record<string, unknown>;
|
||||
|
||||
const getInputValue = (key: string): string | null => {
|
||||
const val = input[key];
|
||||
return typeof val === 'string' ? val : (typeof val === 'number' ? String(val) : null);
|
||||
};
|
||||
return (
|
||||
<div className="border-b border-border/20 p-4 -mx-3">
|
||||
<div className="typography-markdown font-medium text-muted-foreground mb-2 px-3">
|
||||
{meta.tool === 'bash'
|
||||
? 'Command:'
|
||||
: meta.tool === 'task'
|
||||
? 'Task Details:'
|
||||
: 'Input:'}
|
||||
</div>
|
||||
{meta.tool === 'bash' && getInputValue('command') ? (
|
||||
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language="bash"
|
||||
PreTag="div"
|
||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||
wrapLongLines
|
||||
>
|
||||
{getInputValue('command')!}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : meta.tool === 'task' && getInputValue('prompt') ? (
|
||||
<pre
|
||||
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||
style={toolDisplayStyles.getPopupStyles()}
|
||||
>
|
||||
{getInputValue('description') ? `Task: ${getInputValue('description')}\n` : ''}
|
||||
{getInputValue('subagent_type') ? `Agent Type: ${getInputValue('subagent_type')}\n` : ''}
|
||||
{`Instructions:\n${getInputValue('prompt')}`}
|
||||
</pre>
|
||||
) : meta.tool === 'write' && getInputValue('content') ? (
|
||||
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={getLanguageFromExtension(getInputValue('filePath') || getInputValue('file_path') || '') || 'text'}
|
||||
PreTag="div"
|
||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||
wrapLongLines
|
||||
>
|
||||
{getInputValue('content')!}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
|
||||
style={toolDisplayStyles.getPopupStyles()}
|
||||
>
|
||||
{formatInputForDisplay(input, meta.tool as string)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
{popup.isDiff ? (
|
||||
diffViewMode === 'unified' ? (
|
||||
<div className="typography-markdown">
|
||||
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||
<div
|
||||
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
|
||||
>
|
||||
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
<div>
|
||||
{hunk.lines.map((line, lineIdx) => (
|
||||
<div
|
||||
key={lineIdx}
|
||||
className={cn(
|
||||
'typography-markdown font-mono px-3 py-0.5 flex',
|
||||
line.type === 'context' && 'bg-transparent',
|
||||
line.type === 'removed' && 'bg-transparent',
|
||||
line.type === 'added' && 'bg-transparent'
|
||||
)}
|
||||
style={{
|
||||
lineHeight: '1.1',
|
||||
...(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-10 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||
{line.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{(() => {
|
||||
const inputFile = (typeof popup.metadata?.input === 'object' && popup.metadata.input !== null)
|
||||
? ((popup.metadata.input as Record<string, unknown>).file_path || (popup.metadata.input as Record<string, unknown>).filePath)
|
||||
: null;
|
||||
const fileStr = typeof inputFile === 'string' ? inputFile : '';
|
||||
const hunkFile = (typeof hunk === 'object' && hunk !== null && 'file' in hunk) ? (hunk as UnifiedDiffHunk).file : null;
|
||||
const hunkFileStr = typeof hunkFile === 'string' ? hunkFile : '';
|
||||
const finalFile = fileStr || hunkFileStr || '';
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={getLanguageFromExtension(finalFile) || '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>
|
||||
) : popup.diffHunks ? (
|
||||
<div className="typography-markdown">
|
||||
{popup.diffHunks.map((hunk, hunkIdx) => (
|
||||
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
|
||||
<div
|
||||
className={cn('bg-muted/20 px-3 py-2 font-medium text-muted-foreground border-b border-border/10 sticky top-0 z-10 break-words -mx-3', isMobile ? 'typography-micro' : 'typography-markdown')}
|
||||
>
|
||||
{`${hunk.file} (line ${hunk.oldStart})`}
|
||||
</div>
|
||||
<div>
|
||||
{(hunk as unknown as SideBySideDiffHunk).lines.map((line: SideBySideDiffLine, lineIdx: number) => (
|
||||
<div key={lineIdx} className="grid grid-cols-2 divide-x divide-border/20">
|
||||
<div
|
||||
className={cn(
|
||||
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
|
||||
line.leftLine.type === 'context' && 'bg-transparent',
|
||||
line.leftLine.type === 'empty' && 'bg-transparent'
|
||||
)}
|
||||
style={{
|
||||
lineHeight: '1.1',
|
||||
...(line.leftLine.type === 'removed' ? { backgroundColor: 'var(--tools-edit-removed-bg)' } : {}),
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||
{line.leftLine.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{line.leftLine.content && (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={(() => {
|
||||
const input = popup.metadata?.input;
|
||||
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||
const filePath = inputObj.file_path || inputObj.filePath;
|
||||
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
|
||||
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || '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.leftLine.content}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
|
||||
line.rightLine.type === 'context' && 'bg-transparent',
|
||||
line.rightLine.type === 'empty' && 'bg-transparent'
|
||||
)}
|
||||
style={{
|
||||
lineHeight: '1.1',
|
||||
...(line.rightLine.type === 'added' ? { backgroundColor: 'var(--tools-edit-added-bg)' } : {}),
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-3 self-start select-none">
|
||||
{line.rightLine.lineNumber || ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{line.rightLine.content && (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={(() => {
|
||||
const input = popup.metadata?.input;
|
||||
const inputObj = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||
const filePath = inputObj.file_path || inputObj.filePath;
|
||||
const hunkFile = (hunk as unknown as UnifiedDiffHunk).file;
|
||||
return getLanguageFromExtension(typeof filePath === 'string' ? filePath : hunkFile) || '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.rightLine.content}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
) : popup.image ? (
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="max-h-[70vh] overflow-hidden rounded-2xl border border-border/40 bg-muted/10">
|
||||
<img
|
||||
src={popup.image.url}
|
||||
alt={popup.image.filename || popup.title || 'Image preview'}
|
||||
className="block h-full max-h-[70vh] w-auto max-w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{popup.image.filename && (
|
||||
<span className="typography-meta text-muted-foreground text-center">
|
||||
{popup.image.filename}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : popup.content ? (
|
||||
<div className="p-4">
|
||||
{(() => {
|
||||
const tool = popup.metadata?.tool;
|
||||
|
||||
if (tool === 'todowrite' || tool === 'todoread') {
|
||||
return (
|
||||
renderTodoOutput(popup.content) || (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language="json"
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'list') {
|
||||
return (
|
||||
renderListOutput(popup.content) || (
|
||||
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||
{popup.content}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'grep') {
|
||||
return (
|
||||
renderGrepOutput(popup.content, isMobile) || (
|
||||
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||
{popup.content}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'glob') {
|
||||
return (
|
||||
renderGlobOutput(popup.content, isMobile) || (
|
||||
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
|
||||
{popup.content}
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'task' || tool === 'reasoning') {
|
||||
return (
|
||||
<div
|
||||
className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}
|
||||
style={{ fontSize: 'var(--text-meta)' }}
|
||||
>
|
||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
||||
{popup.content}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
|
||||
return (
|
||||
renderWebSearchOutput(popup.content, syntaxTheme) || (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language="text"
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'read') {
|
||||
const lines = popup.content.split('\n');
|
||||
|
||||
const inputMeta = popup.metadata?.input;
|
||||
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
|
||||
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
|
||||
const limit = typeof inputObj.limit === 'number' ? inputObj.limit : undefined;
|
||||
|
||||
const isInfoMessage = (line: string) => line.trim().startsWith('(');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{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={`typography-markdown font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-4 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={popup.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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={popup.language || 'text'}
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={{ style: { background: 'transparent !important' } }}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-muted-foreground typography-ui-header">
|
||||
<div className="mb-2">Command completed successfully</div>
|
||||
<div className="typography-markdown">No output was produced</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolOutputDialog;
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Message } from '@opencode-ai/sdk';
|
||||
|
||||
export interface MessageRoleInfo {
|
||||
role: string;
|
||||
isUser: boolean;
|
||||
}
|
||||
|
||||
export const deriveMessageRole = (
|
||||
messageInfo: Message | (Message & { clientRole?: string; userMessageMarker?: boolean })
|
||||
): MessageRoleInfo => {
|
||||
const info = messageInfo as Message & { clientRole?: string; userMessageMarker?: boolean; origin?: string; source?: string };
|
||||
const clientRole = info?.clientRole;
|
||||
const serverRole = info?.role;
|
||||
const userMarker = info?.userMessageMarker === true;
|
||||
|
||||
const isUser =
|
||||
userMarker ||
|
||||
clientRole === 'user' ||
|
||||
serverRole === 'user' ||
|
||||
info?.origin === 'user' ||
|
||||
info?.source === 'user';
|
||||
|
||||
if (isUser) {
|
||||
return {
|
||||
role: 'user',
|
||||
isUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: clientRole || serverRole || 'assistant',
|
||||
isUser: false,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Part } from '@opencode-ai/sdk';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
export const extractTextContent = (part: Part): string => {
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
if (typeof rawText === 'string') {
|
||||
return rawText;
|
||||
}
|
||||
return partWithText.content || partWithText.value || '';
|
||||
};
|
||||
|
||||
export const isEmptyTextPart = (part: Part): boolean => {
|
||||
if (part.type !== 'text') {
|
||||
return false;
|
||||
}
|
||||
const text = extractTextContent(part);
|
||||
return !text || text.trim().length === 0;
|
||||
};
|
||||
|
||||
type PartWithSynthetic = Part & { synthetic?: boolean };
|
||||
|
||||
interface VisibleFilterOptions {
|
||||
includeReasoning?: boolean;
|
||||
}
|
||||
|
||||
export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions = {}): Part[] => {
|
||||
const { includeReasoning = true } = options;
|
||||
|
||||
return parts.filter((part) => {
|
||||
const partWithSynthetic = part as PartWithSynthetic;
|
||||
const isSynthetic = Boolean(partWithSynthetic.synthetic);
|
||||
if (isSynthetic) {
|
||||
return false;
|
||||
}
|
||||
if (!includeReasoning && part.type === 'reasoning') {
|
||||
return false;
|
||||
}
|
||||
const isPatchPart = part.type === 'patch';
|
||||
|
||||
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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { RiCheckLine } from '@remixicon/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { typography } from '@/lib/typography';
|
||||
import { formatToolInput, detectToolOutputLanguage } from '@/lib/toolHelpers';
|
||||
|
||||
const cleanOutput = (output: string) => {
|
||||
let cleaned = output.replace(/^<file>\s*\n?/, '').replace(/\n?<\/file>\s*$/, '');
|
||||
cleaned = cleaned.replace(/^\s*\d{5}\|\s?/gm, '');
|
||||
return cleaned.trim();
|
||||
};
|
||||
|
||||
export const hasLspDiagnostics = (output: string): boolean => {
|
||||
if (!output) return false;
|
||||
return output.includes('<file_diagnostics>') || output.includes('This file has errors') || output.includes('please fix');
|
||||
};
|
||||
|
||||
const stripLspDiagnostics = (output: string): string => {
|
||||
if (!output) return '';
|
||||
return output.replace(/This file has errors.*?<\/file_diagnostics>/s, '').trim();
|
||||
};
|
||||
|
||||
const formatInputForDisplay = (input: Record<string, unknown>, toolName?: string) => {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return String(input);
|
||||
}
|
||||
return formatToolInput(input, toolName || '');
|
||||
};
|
||||
|
||||
export const formatEditOutput = (output: string, toolName: string, metadata?: Record<string, unknown>): string => {
|
||||
let cleaned = cleanOutput(output);
|
||||
|
||||
if ((toolName === 'edit' || toolName === 'multiedit') && hasLspDiagnostics(cleaned)) {
|
||||
cleaned = stripLspDiagnostics(cleaned);
|
||||
}
|
||||
|
||||
if ((toolName === 'edit' || toolName === 'multiedit') && cleaned.trim().length === 0 && metadata?.diff) {
|
||||
|
||||
const diff = metadata.diff;
|
||||
return typeof diff === 'string' ? diff : String(diff);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const items: Array<{ name: string; depth: number; isFile: boolean }> = [];
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^(\s*)(.+)$/);
|
||||
if (match) {
|
||||
const [, spaces, name] = match;
|
||||
const depth = Math.floor(spaces.length / 2);
|
||||
const isFile = !name.endsWith('/');
|
||||
items.push({
|
||||
name: name.replace(/\/$/, ''),
|
||||
depth,
|
||||
isFile,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full min-w-0 font-mono space-y-0.5',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
style={typography.micro}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="min-w-0" style={{ paddingLeft: `${item.depth * 20}px` }}>
|
||||
{item.isFile ? (
|
||||
<span className="text-foreground/90 block truncate">{item.name}</span>
|
||||
) : (
|
||||
<span className="font-semibold text-foreground block truncate">{item.name}/</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const fileGroups: Record<string, Array<{ lineNum: string; content: string }>> = {};
|
||||
|
||||
lines.forEach((line) => {
|
||||
const match = line.match(/^(.+?):(\d+):(.*)$/) || line.match(/^(.+?):(.*)$/);
|
||||
if (match) {
|
||||
const [, filepath, lineNumOrContent, content] = match;
|
||||
const lineNum = content !== undefined ? lineNumOrContent : '';
|
||||
const actualContent = content !== undefined ? content : lineNumOrContent;
|
||||
|
||||
if (!fileGroups[filepath]) {
|
||||
fileGroups[filepath] = [];
|
||||
}
|
||||
fileGroups[filepath].push({ lineNum, content: actualContent });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-2 w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
>
|
||||
<div className="typography-meta text-muted-foreground mb-2">
|
||||
Found {lines.length} match{lines.length !== 1 ? 'es' : ''}
|
||||
</div>
|
||||
{Object.entries(fileGroups).map(([filepath, matches]) => (
|
||||
<div key={filepath} className="space-y-1">
|
||||
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
{filepath}
|
||||
</div>
|
||||
<div className="pl-4 space-y-1">
|
||||
{matches.map((match, idx) => {
|
||||
if (!match.lineNum && !match.content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
{match.lineNum && (
|
||||
<span className="text-muted-foreground font-mono whitespace-nowrap">
|
||||
Line {match.lineNum}:
|
||||
</span>
|
||||
)}
|
||||
<span className="text-foreground font-mono break-words flex-1">
|
||||
{match.content || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const paths = output.trim().split('\n').filter(Boolean);
|
||||
if (paths.length === 0) return null;
|
||||
|
||||
const groups: Record<string, string[]> = {};
|
||||
paths.forEach((path) => {
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
const dir = lastSlash > 0 ? path.substring(0, lastSlash) : '/';
|
||||
const filename = lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
|
||||
|
||||
if (!groups[dir]) {
|
||||
groups[dir] = [];
|
||||
}
|
||||
groups[dir].push(filename);
|
||||
});
|
||||
|
||||
const sortedDirs = Object.keys(groups).sort();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-2 w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
>
|
||||
<div className="typography-meta text-muted-foreground mb-2">
|
||||
Found {paths.length} file{paths.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{sortedDirs.map((dir) => (
|
||||
<div key={dir} className="space-y-1">
|
||||
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
{dir}/
|
||||
</div>
|
||||
<div className={cn('pl-4 grid gap-1', isMobile ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||
{groups[dir].sort().map((filename) => (
|
||||
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
|
||||
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
|
||||
<span className="text-foreground font-mono truncate">{filename}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type Todo = {
|
||||
id?: string;
|
||||
content: string;
|
||||
status: 'in_progress' | 'pending' | 'completed' | 'cancelled';
|
||||
priority?: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
export const renderTodoOutput = (output: string, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
const todos = JSON.parse(output) as Todo[];
|
||||
if (!Array.isArray(todos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const todosByStatus = {
|
||||
in_progress: todos.filter((t) => t.status === 'in_progress'),
|
||||
pending: todos.filter((t) => t.status === 'pending'),
|
||||
completed: todos.filter((t) => t.status === 'completed'),
|
||||
cancelled: todos.filter((t) => t.status === 'cancelled'),
|
||||
};
|
||||
|
||||
const getPriorityDot = (priority?: string) => {
|
||||
const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1';
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return <div className={baseClasses} style={{ backgroundColor: 'var(--status-error)' }} />;
|
||||
case 'medium':
|
||||
return <div className={baseClasses} style={{ backgroundColor: 'var(--primary)' }} />;
|
||||
case 'low':
|
||||
return <div className={baseClasses} style={{ backgroundColor: 'var(--status-info)' }} />;
|
||||
default:
|
||||
return <div className={baseClasses} style={{ backgroundColor: 'var(--muted-foreground)', opacity: 0.5 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-3 w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-4 typography-meta pb-2 border-b border-border/20">
|
||||
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>Total: {todos.length}</span>
|
||||
{todosByStatus.in_progress.length > 0 && (
|
||||
<span className="font-medium" style={{ color: 'var(--foreground)' }}>In Progress: {todosByStatus.in_progress.length}</span>
|
||||
)}
|
||||
{todosByStatus.pending.length > 0 && (
|
||||
<span style={{ color: 'var(--muted-foreground)' }}>Pending: {todosByStatus.pending.length}</span>
|
||||
)}
|
||||
{todosByStatus.completed.length > 0 && (
|
||||
<span style={{ color: 'var(--status-success)' }}>Completed: {todosByStatus.completed.length}</span>
|
||||
)}
|
||||
{todosByStatus.cancelled.length > 0 && (
|
||||
<span style={{ color: 'var(--muted-foreground)', opacity: 0.5 }}>Cancelled: {todosByStatus.cancelled.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{todosByStatus.in_progress.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full animate-pulse" style={{ backgroundColor: 'var(--foreground)' }} />
|
||||
<span className="typography-meta font-semibold text-foreground uppercase tracking-wide">In Progress</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-4">
|
||||
{todosByStatus.in_progress.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
{getPriorityDot(todo.priority)}
|
||||
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{todosByStatus.pending.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-muted-foreground/50" />
|
||||
<span className="typography-meta font-semibold text-muted-foreground uppercase tracking-wide">Pending</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-4">
|
||||
{todosByStatus.pending.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
{getPriorityDot(todo.priority)}
|
||||
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{todosByStatus.completed.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiCheckLine className="w-3 h-3" style={{ color: 'var(--status-success)' }} />
|
||||
<span className="typography-meta font-semibold uppercase tracking-wide" style={{ color: 'var(--status-success)' }}>Completed</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-4">
|
||||
{todosByStatus.completed.map((todo, idx) => (
|
||||
<div key={todo.id || idx} className="flex items-start gap-2">
|
||||
<RiCheckLine className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--status-success)', opacity: 0.7 }} />
|
||||
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{todosByStatus.cancelled.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 text-muted-foreground/50">×</span>
|
||||
<span className="typography-meta font-semibold text-muted-foreground/50 uppercase tracking-wide">Cancelled</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-4">
|
||||
{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-meta text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: string]: React.CSSProperties }, options?: { unstyled?: boolean }) => {
|
||||
try {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'typography-meta max-w-none w-full min-w-0',
|
||||
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/20'
|
||||
)}
|
||||
>
|
||||
<Streamdown mode="static" className="streamdown-content streamdown-tool">
|
||||
{output}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export type DiffLineType = 'context' | 'added' | 'removed';
|
||||
|
||||
export interface UnifiedDiffLine {
|
||||
type: DiffLineType;
|
||||
lineNumber: number | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface UnifiedDiffHunk {
|
||||
file: string;
|
||||
oldStart: number;
|
||||
newStart: number;
|
||||
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 = '';
|
||||
const hunks: UnifiedDiffHunk[] = [];
|
||||
|
||||
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 unifiedLines: UnifiedDiffLine[] = [];
|
||||
let lineNum = newStart;
|
||||
let j = i + 1;
|
||||
|
||||
while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) {
|
||||
const contentLine = lines[j];
|
||||
if (contentLine.startsWith('+')) {
|
||||
unifiedLines.push({ type: 'added', lineNumber: lineNum, content: contentLine.substring(1) });
|
||||
lineNum++;
|
||||
} else if (contentLine.startsWith('-')) {
|
||||
unifiedLines.push({ type: 'removed', lineNumber: null, content: contentLine.substring(1) });
|
||||
} else if (contentLine.startsWith(' ')) {
|
||||
unifiedLines.push({ type: 'context', lineNumber: lineNum, content: contentLine.substring(1) });
|
||||
lineNum++;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
hunks.push({
|
||||
file: currentFile,
|
||||
oldStart,
|
||||
newStart,
|
||||
lines: unifiedLines,
|
||||
});
|
||||
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export { formatInputForDisplay };
|
||||
@@ -0,0 +1,24 @@
|
||||
export type StreamPhase = 'streaming' | 'cooldown' | 'completed';
|
||||
|
||||
export type DiffViewMode = 'side-by-side' | 'unified';
|
||||
|
||||
export interface AgentMentionInfo {
|
||||
name: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ToolPopupContent {
|
||||
open: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
language?: string;
|
||||
isDiff?: boolean;
|
||||
diffHunks?: Array<Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
image?: {
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user