@@ -48,6 +48,7 @@ import { linkToSession } from '@/lib/links';
|
||||
import { getExecutorIcon } from '@/lib/executor-icons';
|
||||
|
||||
import { useDockviewStore } from '@/lib/state/dockview-store';
|
||||
import { useContextFilesStore } from '@/lib/state/context-files-store';
|
||||
|
||||
interface TaskCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -959,6 +960,8 @@ export function TaskCreateDialog({
|
||||
referencePanel: 'chat',
|
||||
});
|
||||
setPlanMode(newSessionId, true);
|
||||
// Add plan as context so it's included in messages
|
||||
useContextFilesStore.getState().addFile(newSessionId, { path: 'plan:context', name: 'Plan' });
|
||||
router.push(linkToSession(newSessionId));
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useState, useEffect, memo, forwardRef, useImperativeHandle, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useRef, useCallback, useState, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconAlertTriangle,
|
||||
IconFileTextSpark,
|
||||
IconPlus,
|
||||
IconPlayerPauseFilled,
|
||||
IconMessageDots,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { GridSpinner } from '@/components/grid-spinner';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SHORTCUTS } from '@/lib/keyboard/constants';
|
||||
import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip';
|
||||
import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display';
|
||||
import { SessionsDropdown } from '@/components/task/sessions-dropdown';
|
||||
import { TaskCreateDialog } from '@/components/task-create-dialog';
|
||||
import { ModelSelector } from '@/components/task/model-selector';
|
||||
import { RichTextInput, type RichTextInputHandle } from './rich-text-input';
|
||||
import { MentionMenu } from './mention-menu';
|
||||
import { SlashCommandMenu } from './slash-command-menu';
|
||||
import { TipTapInput, type TipTapInputHandle } from './tiptap-input';
|
||||
import { ClarificationInputOverlay } from './clarification-input-overlay';
|
||||
import { CommentBlocksContainer } from './comment-block';
|
||||
import { ChatInputFocusHint } from './chat-input-focus-hint';
|
||||
import { ResizeHandle } from './resize-handle';
|
||||
import { TodoSummary } from './todo-summary';
|
||||
import { DocumentReferenceIndicator } from './document-reference-indicator';
|
||||
import { QueuedMessageIndicator, type QueuedMessageIndicatorHandle } from './queued-message-indicator';
|
||||
import { ChatInputToolbar } from './chat-input-toolbar';
|
||||
import { ContextZone } from './context-items/context-zone';
|
||||
import type { ContextItem, ImageContextItem } from '@/lib/types/context';
|
||||
import type { ContextFile } from '@/lib/state/context-files-store';
|
||||
import { useResizableInput } from '@/hooks/use-resizable-input';
|
||||
import {
|
||||
ImageAttachmentPreview,
|
||||
processImageFile,
|
||||
formatBytes,
|
||||
MAX_IMAGES,
|
||||
MAX_TOTAL_SIZE,
|
||||
type ImageAttachment,
|
||||
} from './image-attachment-preview';
|
||||
import { useInlineMention } from '@/hooks/use-inline-mention';
|
||||
import { useInlineSlash } from '@/hooks/use-inline-slash';
|
||||
import {
|
||||
getChatDraftText,
|
||||
setChatDraftText,
|
||||
getChatDraftAttachments,
|
||||
setChatDraftAttachments,
|
||||
setChatDraftContent,
|
||||
restoreAttachmentPreview,
|
||||
} from '@/lib/local-storage';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import type { ActiveDocument } from '@/lib/state/slices/ui/types';
|
||||
|
||||
// Re-export ImageAttachment type for consumers
|
||||
export type { ImageAttachment } from './image-attachment-preview';
|
||||
@@ -54,119 +48,9 @@ export type MessageAttachment = {
|
||||
mime_type: string; // MIME type
|
||||
};
|
||||
|
||||
// Memoized toolbar to prevent re-renders on input value changes
|
||||
type ChatInputToolbarProps = {
|
||||
planModeEnabled: boolean;
|
||||
onPlanModeChange: (enabled: boolean) => void;
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription: string;
|
||||
isAgentBusy: boolean;
|
||||
isDisabled: boolean;
|
||||
isSending: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
submitKey?: 'enter' | 'cmd_enter';
|
||||
};
|
||||
|
||||
const ChatInputToolbar = memo(function ChatInputToolbar({
|
||||
planModeEnabled,
|
||||
onPlanModeChange,
|
||||
sessionId,
|
||||
taskId,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
isAgentBusy,
|
||||
isDisabled,
|
||||
isSending,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitKey = 'cmd_enter',
|
||||
}: ChatInputToolbarProps) {
|
||||
const submitShortcut = submitKey === 'enter' ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border">
|
||||
{/* Left: Plan, Thinking, Context */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Plan mode toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40',
|
||||
planModeEnabled && 'bg-primary/15 text-primary'
|
||||
)}
|
||||
onClick={() => onPlanModeChange(!planModeEnabled)}
|
||||
>
|
||||
<IconFileTextSpark className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: Sessions, Model, Submit */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<SessionsDropdown
|
||||
taskId={taskId}
|
||||
activeSessionId={sessionId}
|
||||
taskTitle={taskTitle}
|
||||
taskDescription={taskDescription}
|
||||
/>
|
||||
|
||||
{/* Token usage display */}
|
||||
<TokenUsageDisplay sessionId={sessionId} />
|
||||
|
||||
<ModelSelector sessionId={sessionId} />
|
||||
|
||||
{/* Submit/Stop button */}
|
||||
<div className="ml-1">
|
||||
<KeyboardShortcutTooltip shortcut={submitShortcut} enabled={!isAgentBusy && !isDisabled}>
|
||||
{isAgentBusy ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full cursor-pointer"
|
||||
disabled={isDisabled}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSending ? (
|
||||
<GridSpinner className="text-primary-foreground" />
|
||||
) : (
|
||||
<IconArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</KeyboardShortcutTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export type ChatInputContainerHandle = {
|
||||
focusInput: () => void;
|
||||
getTextareaElement: () => HTMLTextAreaElement | null;
|
||||
getTextareaElement: () => HTMLElement | null;
|
||||
getValue: () => string;
|
||||
getSelectionStart: () => number;
|
||||
insertText: (text: string, from: number, to: number) => void;
|
||||
@@ -178,7 +62,7 @@ type TodoItem = {
|
||||
};
|
||||
|
||||
type ChatInputContainerProps = {
|
||||
onSubmit: (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[]) => void;
|
||||
onSubmit: (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[], inlineMentions?: ContextFile[]) => void;
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
@@ -194,14 +78,8 @@ type ChatInputContainerProps = {
|
||||
onClarificationResolved?: () => void;
|
||||
showRequestChangesTooltip?: boolean;
|
||||
onRequestChangesTooltipDismiss?: () => void;
|
||||
/** Pending review comments grouped by file */
|
||||
/** Pending review comments grouped by file — kept for submit collection */
|
||||
pendingCommentsByFile?: Record<string, DiffComment[]>;
|
||||
/** Callback to remove comments for a file */
|
||||
onRemoveCommentFile?: (filePath: string) => void;
|
||||
/** Callback to remove a specific comment */
|
||||
onRemoveComment?: (sessionId: string, filePath: string, commentId: string) => void;
|
||||
/** Callback when a comment is clicked (for jump-to-line) */
|
||||
onCommentClick?: (comment: DiffComment) => void;
|
||||
/** Chat submit key preference */
|
||||
submitKey?: 'enter' | 'cmd_enter';
|
||||
/** Whether the current agent has commands (affects placeholder) */
|
||||
@@ -210,18 +88,18 @@ type ChatInputContainerProps = {
|
||||
isFailed?: boolean;
|
||||
/** Whether a message is queued */
|
||||
isQueued?: boolean;
|
||||
/** Callback to start editing the queued message (from keyboard navigation) */
|
||||
onStartQueueEdit?: () => void;
|
||||
/** User message history for up/down arrow navigation */
|
||||
userMessageHistory?: string[];
|
||||
/** Plan/document comments that will be included with the next message */
|
||||
documentCommentCount?: number;
|
||||
/** Callback to clear plan/document comments */
|
||||
onClearDocumentComments?: () => void;
|
||||
/** Context items to display as chips above input */
|
||||
contextItems?: ContextItem[];
|
||||
/** Whether plan is selected as context in the popover */
|
||||
planContextEnabled?: boolean;
|
||||
/** Context files for popover */
|
||||
contextFiles?: ContextFile[];
|
||||
/** Toggle a context file in/out */
|
||||
onToggleContextFile?: (file: ContextFile) => void;
|
||||
/** Add a context file (for @mention) */
|
||||
onAddContextFile?: (file: ContextFile) => void;
|
||||
/** Todo items from processed messages */
|
||||
todoItems?: TodoItem[];
|
||||
/** Active document reference */
|
||||
activeDocument?: ActiveDocument | null;
|
||||
/** Queued message content */
|
||||
queuedMessage?: string | null;
|
||||
/** Cancel queued message */
|
||||
@@ -256,19 +134,16 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
showRequestChangesTooltip = false,
|
||||
onRequestChangesTooltipDismiss,
|
||||
pendingCommentsByFile,
|
||||
onRemoveCommentFile,
|
||||
onRemoveComment,
|
||||
onCommentClick,
|
||||
submitKey = 'cmd_enter',
|
||||
hasAgentCommands = false,
|
||||
isFailed = false,
|
||||
isQueued = false,
|
||||
onStartQueueEdit,
|
||||
userMessageHistory = [],
|
||||
documentCommentCount = 0,
|
||||
onClearDocumentComments,
|
||||
contextItems = [],
|
||||
planContextEnabled = false,
|
||||
contextFiles = [],
|
||||
onToggleContextFile,
|
||||
onAddContextFile,
|
||||
todoItems = [],
|
||||
activeDocument,
|
||||
queuedMessage,
|
||||
onCancelQueue,
|
||||
updateQueueContent,
|
||||
@@ -279,18 +154,38 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
ref
|
||||
) {
|
||||
// Keep input state local to avoid re-rendering parent on every keystroke
|
||||
const [value, setValue] = useState('');
|
||||
const [value, setValue] = useState(() =>
|
||||
sessionId ? getChatDraftText(sessionId) : ''
|
||||
);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [attachments, setAttachments] = useState<ImageAttachment[]>([]);
|
||||
const [attachments, setAttachments] = useState<ImageAttachment[]>(() =>
|
||||
sessionId ? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview) : []
|
||||
);
|
||||
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
|
||||
const inputRef = useRef<RichTextInputHandle>(null);
|
||||
const [contextPopoverOpen, setContextPopoverOpen] = useState(false);
|
||||
const inputRef = useRef<TipTapInputHandle>(null);
|
||||
|
||||
// Message history navigation state
|
||||
const [historyIndex, setHistoryIndex] = useState(-1); // -1 means not navigating history
|
||||
const [historyBuffer, setHistoryBuffer] = useState(''); // Store current input when navigating history
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
// Hydrate draft when switching sessions (component stays mounted)
|
||||
const prevSessionIdRef = useRef(sessionId);
|
||||
useEffect(() => {
|
||||
if (sessionId === prevSessionIdRef.current) return;
|
||||
prevSessionIdRef.current = sessionId;
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- syncing from localStorage on session switch */
|
||||
if (sessionId) {
|
||||
setValue(getChatDraftText(sessionId));
|
||||
setAttachments(getChatDraftAttachments(sessionId).map(restoreAttachmentPreview));
|
||||
} else {
|
||||
setValue('');
|
||||
setAttachments([]);
|
||||
}
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [sessionId]);
|
||||
|
||||
// Resizable input
|
||||
const { height, containerRef, resizeHandleProps } = useResizableInput();
|
||||
const { height, resetHeight, containerRef, resizeHandleProps } = useResizableInput(sessionId ?? undefined);
|
||||
|
||||
// Expose imperative handle for parent
|
||||
useImperativeHandle(
|
||||
@@ -314,7 +209,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
}
|
||||
}, [showRequestChangesTooltip]);
|
||||
|
||||
// Use ref for value to keep handleSubmit stable (doesn't change on every keystroke)
|
||||
// Use ref for value to keep handleSubmit stable
|
||||
const valueRef = useRef(value);
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
@@ -324,43 +219,24 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
const attachmentsRef = useRef(attachments);
|
||||
useEffect(() => {
|
||||
attachmentsRef.current = attachments;
|
||||
}, [attachments]);
|
||||
|
||||
// Handle image paste
|
||||
const handlePaste = useCallback(async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
// Check for image items
|
||||
const imageItems: DataTransferItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
imageItems.push(item);
|
||||
}
|
||||
// Persist attachments when they change (skip during session switch — handled by hydrate effect)
|
||||
if (sessionId && prevSessionIdRef.current === sessionId) {
|
||||
setChatDraftAttachments(sessionId, attachments);
|
||||
}
|
||||
}, [attachments, sessionId]);
|
||||
|
||||
if (imageItems.length === 0) return;
|
||||
|
||||
// Prevent default paste behavior for images
|
||||
e.preventDefault();
|
||||
|
||||
// Check if we've hit the max images limit
|
||||
// Handle image paste (called by TipTapInput's handlePaste editorProp)
|
||||
const handleImagePaste = useCallback(async (files: File[]) => {
|
||||
if (attachments.length >= MAX_IMAGES) {
|
||||
console.warn(`Maximum ${MAX_IMAGES} images allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate current total size
|
||||
const currentTotalSize = attachments.reduce((sum, att) => sum + att.size, 0);
|
||||
|
||||
// Process each image
|
||||
for (const item of imageItems) {
|
||||
if (attachments.length + imageItems.indexOf(item) >= MAX_IMAGES) break;
|
||||
for (const file of files) {
|
||||
if (attachments.length >= MAX_IMAGES) break;
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
// Check total size limit
|
||||
if (currentTotalSize + file.size > MAX_TOTAL_SIZE) {
|
||||
console.warn('Total attachment size limit exceeded');
|
||||
break;
|
||||
@@ -373,26 +249,12 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
}
|
||||
}, [attachments]);
|
||||
|
||||
// Attach paste listener to the container
|
||||
useEffect(() => {
|
||||
const textarea = inputRef.current?.getTextareaElement();
|
||||
if (!textarea) return;
|
||||
|
||||
textarea.addEventListener('paste', handlePaste);
|
||||
return () => {
|
||||
textarea.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [handlePaste]);
|
||||
|
||||
// Remove attachment handler
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments(prev => prev.filter(att => att.id !== id));
|
||||
}, []);
|
||||
|
||||
// Mention menu hook
|
||||
const mention = useInlineMention(inputRef, value, setValue, sessionId);
|
||||
|
||||
// Handler for agent slash commands - sends /{commandName} as a message
|
||||
// Handler for agent slash commands
|
||||
const handleAgentCommand = useCallback(
|
||||
(commandName: string) => {
|
||||
onSubmit(`/${commandName}`);
|
||||
@@ -400,121 +262,37 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
[onSubmit]
|
||||
);
|
||||
|
||||
// Slash command menu hook
|
||||
const slash = useInlineSlash(inputRef, value, setValue, {
|
||||
sessionId,
|
||||
onAgentCommand: handleAgentCommand,
|
||||
});
|
||||
|
||||
// Use refs for menu state to keep handleSubmit stable
|
||||
const menuStateRef = useRef({ mentionOpen: mention.isOpen, slashOpen: slash.isOpen });
|
||||
useEffect(() => {
|
||||
menuStateRef.current = { mentionOpen: mention.isOpen, slashOpen: slash.isOpen };
|
||||
}, [mention.isOpen, slash.isOpen]);
|
||||
|
||||
// Ref for pending comments
|
||||
const pendingCommentsRef = useRef(pendingCommentsByFile);
|
||||
useEffect(() => {
|
||||
pendingCommentsRef.current = pendingCommentsByFile;
|
||||
}, [pendingCommentsByFile]);
|
||||
|
||||
// Combined change handler
|
||||
// Change handler
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
mention.handleChange(newValue);
|
||||
slash.handleChange(newValue);
|
||||
setValue(newValue);
|
||||
if (sessionId) setChatDraftText(sessionId, newValue);
|
||||
|
||||
// Reset history navigation when user modifies content from history (but not when editing queued message)
|
||||
if (historyIndex >= 0) {
|
||||
// User is modifying a historical message - keep the content but exit history mode
|
||||
setHistoryIndex(-1);
|
||||
setHistoryBuffer('');
|
||||
}
|
||||
|
||||
// Dismiss the request changes tooltip when user starts typing
|
||||
if (showRequestChangesTooltip && onRequestChangesTooltipDismiss) {
|
||||
onRequestChangesTooltipDismiss();
|
||||
}
|
||||
},
|
||||
[mention, slash, showRequestChangesTooltip, onRequestChangesTooltipDismiss, historyIndex]
|
||||
[showRequestChangesTooltip, onRequestChangesTooltipDismiss, historyIndex, sessionId]
|
||||
);
|
||||
|
||||
// Combined keydown handler
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Let menus handle navigation first
|
||||
if (mention.isOpen) {
|
||||
mention.handleKeyDown(event);
|
||||
if (event.defaultPrevented) return;
|
||||
}
|
||||
if (slash.isOpen) {
|
||||
slash.handleKeyDown(event);
|
||||
if (event.defaultPrevented) return;
|
||||
}
|
||||
|
||||
// Handle up/down arrow for history navigation
|
||||
const textarea = event.currentTarget;
|
||||
const cursorPosition = textarea.selectionStart;
|
||||
const textLength = textarea.value.length;
|
||||
|
||||
// Up arrow: navigate to previous message or start editing queued message
|
||||
if (event.key === 'ArrowUp' && cursorPosition === 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// If there's a queued message and we're not navigating history, trigger edit mode on QueuedMessageIndicator
|
||||
if (isQueued && historyIndex === -1 && onStartQueueEdit) {
|
||||
onStartQueueEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to previous message in history
|
||||
if (userMessageHistory.length > 0) {
|
||||
const newIndex = historyIndex === -1
|
||||
? userMessageHistory.length - 1
|
||||
: Math.max(0, historyIndex - 1);
|
||||
|
||||
if (historyIndex === -1) {
|
||||
setHistoryBuffer(value);
|
||||
}
|
||||
|
||||
setValue(userMessageHistory[newIndex] || '');
|
||||
setHistoryIndex(newIndex);
|
||||
}
|
||||
}
|
||||
// Down arrow: navigate to next message in history
|
||||
else if (event.key === 'ArrowDown' && cursorPosition === textLength) {
|
||||
event.preventDefault();
|
||||
|
||||
if (historyIndex >= 0) {
|
||||
const newIndex = historyIndex + 1;
|
||||
|
||||
if (newIndex >= userMessageHistory.length) {
|
||||
// Restore the buffer (what user was typing before navigating)
|
||||
setValue(historyBuffer);
|
||||
setHistoryIndex(-1);
|
||||
} else {
|
||||
setValue(userMessageHistory[newIndex] || '');
|
||||
setHistoryIndex(newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[mention, slash, userMessageHistory, historyIndex, historyBuffer, value, isQueued, onStartQueueEdit]
|
||||
);
|
||||
|
||||
// Stable submit handler using refs - doesn't change on value/menu state changes
|
||||
// Stable submit handler using refs
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Don't submit if already sending
|
||||
if (isSending) return;
|
||||
|
||||
// Don't submit if a menu is open
|
||||
if (menuStateRef.current.mentionOpen || menuStateRef.current.slashOpen) return;
|
||||
|
||||
const trimmed = valueRef.current.trim();
|
||||
const comments = pendingCommentsRef.current;
|
||||
const currentAttachments = attachmentsRef.current;
|
||||
|
||||
// Collect all pending comments
|
||||
const allComments: DiffComment[] = [];
|
||||
if (comments) {
|
||||
for (const filePath of Object.keys(comments)) {
|
||||
@@ -522,35 +300,54 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
}
|
||||
}
|
||||
|
||||
// Allow submission if there's text OR comments OR attachments
|
||||
if (!trimmed && allComments.length === 0 && currentAttachments.length === 0) return;
|
||||
|
||||
// Convert attachments to MessageAttachment format for backend
|
||||
const messageAttachments: MessageAttachment[] = currentAttachments.map(att => ({
|
||||
type: 'image' as const,
|
||||
data: att.data,
|
||||
mime_type: att.mimeType,
|
||||
}));
|
||||
|
||||
// Submit message - handler will queue it if agent is busy
|
||||
// Extract inline @mentions from the editor before clearing
|
||||
const inlineMentions = inputRef.current?.getMentions() ?? [];
|
||||
|
||||
onSubmit(
|
||||
trimmed,
|
||||
allComments.length > 0 ? allComments : undefined,
|
||||
messageAttachments.length > 0 ? messageAttachments : undefined
|
||||
messageAttachments.length > 0 ? messageAttachments : undefined,
|
||||
inlineMentions.length > 0 ? inlineMentions : undefined
|
||||
);
|
||||
// Clear editor imperatively (suppresses stale onUpdate)
|
||||
inputRef.current?.clear();
|
||||
setValue('');
|
||||
setAttachments([]); // Clear attachments after submit
|
||||
setAttachments([]);
|
||||
setHistoryIndex(-1);
|
||||
setHistoryBuffer('');
|
||||
}, [onSubmit, isSending]);
|
||||
resetHeight();
|
||||
// Clear persisted draft after sending
|
||||
if (sessionId) {
|
||||
setChatDraftText(sessionId, '');
|
||||
setChatDraftContent(sessionId, null);
|
||||
setChatDraftAttachments(sessionId, []);
|
||||
}
|
||||
}, [onSubmit, isSending, sessionId, resetHeight]);
|
||||
|
||||
// Disable input when starting, sending, or session ended (failed/cancelled)
|
||||
// Note: We allow typing when agent is busy so users can queue messages
|
||||
// Merge images into context items
|
||||
const allItems = useMemo((): ContextItem[] => {
|
||||
const imageItems: ImageContextItem[] = attachments.map(att => ({
|
||||
kind: 'image' as const,
|
||||
id: `image:${att.id}`,
|
||||
label: `Image (${formatBytes(att.size)})`,
|
||||
attachment: att,
|
||||
onRemove: () => handleRemoveAttachment(att.id),
|
||||
}));
|
||||
return [...contextItems, ...imageItems];
|
||||
}, [contextItems, attachments, handleRemoveAttachment]);
|
||||
|
||||
// Derived state
|
||||
const isDisabled = isStarting || isSending || isFailed;
|
||||
const hasPendingClarification = pendingClarification && onClarificationResolved;
|
||||
const hasPendingComments = pendingCommentsByFile && Object.keys(pendingCommentsByFile).length > 0;
|
||||
|
||||
// Dynamic placeholder
|
||||
const inputPlaceholder =
|
||||
placeholder ||
|
||||
(isAgentBusy
|
||||
@@ -559,19 +356,17 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
? 'Ask to make changes, @mention files, run /commands'
|
||||
: 'Ask to make changes, @mention files');
|
||||
|
||||
// Show focus hint when input not focused, no clarification, and no comments
|
||||
const showFocusHint = !isInputFocused && !hasPendingClarification && !hasPendingComments;
|
||||
|
||||
// Determine if we have any context items to show
|
||||
// Queue slot
|
||||
const hasQueuedMessage = isQueued && queuedMessage && onCancelQueue && updateQueueContent;
|
||||
const hasTodos = todoItems.length > 0;
|
||||
const hasContextItems =
|
||||
|
||||
// Determine if context zone should show
|
||||
const hasContextZone =
|
||||
hasQueuedMessage ||
|
||||
hasTodos ||
|
||||
!!activeDocument ||
|
||||
hasPendingComments ||
|
||||
documentCommentCount > 0 ||
|
||||
attachments.length > 0 ||
|
||||
allItems.length > 0 ||
|
||||
hasPendingClarification;
|
||||
|
||||
if (isFailed) {
|
||||
@@ -631,34 +426,13 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
{/* Focus hint */}
|
||||
<ChatInputFocusHint visible={showFocusHint} />
|
||||
|
||||
{/* Popup menus */}
|
||||
<MentionMenu
|
||||
isOpen={mention.isOpen}
|
||||
isLoading={mention.isLoading}
|
||||
position={mention.position}
|
||||
items={mention.items}
|
||||
query={mention.query}
|
||||
selectedIndex={mention.selectedIndex}
|
||||
onSelect={mention.handleSelect}
|
||||
onClose={mention.closeMenu}
|
||||
setSelectedIndex={mention.setSelectedIndex}
|
||||
/>
|
||||
<SlashCommandMenu
|
||||
isOpen={slash.isOpen}
|
||||
position={slash.position}
|
||||
commands={slash.commands}
|
||||
selectedIndex={slash.selectedIndex}
|
||||
onSelect={slash.handleSelect}
|
||||
onClose={slash.closeMenu}
|
||||
setSelectedIndex={slash.setSelectedIndex}
|
||||
/>
|
||||
|
||||
{/* CONTEXT ZONE: scrollable, above editor */}
|
||||
{hasContextItems && (
|
||||
<div className="overflow-y-auto max-h-[40%] border-b border-border/50 shrink-0">
|
||||
<div className="px-2 pt-2 pb-1 space-y-1.5">
|
||||
{/* Queued message */}
|
||||
{hasQueuedMessage && (
|
||||
{hasContextZone && (
|
||||
<ContextZone
|
||||
items={allItems}
|
||||
sessionId={sessionId}
|
||||
queueSlot={
|
||||
hasQueuedMessage ? (
|
||||
<QueuedMessageIndicator
|
||||
ref={queuedMessageRef}
|
||||
content={queuedMessage}
|
||||
@@ -667,65 +441,20 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
isVisible={true}
|
||||
onEditComplete={onQueueEditComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Todo summary */}
|
||||
{hasTodos && <TodoSummary todos={todoItems} />}
|
||||
|
||||
{/* Document reference */}
|
||||
{activeDocument && (
|
||||
<DocumentReferenceIndicator activeDocument={activeDocument} />
|
||||
)}
|
||||
|
||||
{/* Pending comment blocks */}
|
||||
{hasPendingComments && sessionId && onRemoveCommentFile && onRemoveComment && (
|
||||
<CommentBlocksContainer
|
||||
commentsByFile={pendingCommentsByFile}
|
||||
sessionId={sessionId}
|
||||
onRemoveFile={onRemoveCommentFile}
|
||||
onRemoveComment={onRemoveComment}
|
||||
onCommentClick={onCommentClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Plan comments indicator */}
|
||||
{documentCommentCount > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-2 py-1.5">
|
||||
<IconMessageDots className="h-3.5 w-3.5 text-purple-500 shrink-0" />
|
||||
<span className="text-xs text-muted-foreground flex-1">
|
||||
{documentCommentCount} plan comment{documentCommentCount !== 1 ? 's' : ''} will be included
|
||||
</span>
|
||||
{onClearDocumentComments && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearDocumentComments}
|
||||
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image attachments preview */}
|
||||
<ImageAttachmentPreview
|
||||
attachments={attachments}
|
||||
onRemove={handleRemoveAttachment}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{/* Clarification (renders above editor, textarea stays visible but disabled) */}
|
||||
{hasPendingClarification && (
|
||||
) : undefined
|
||||
}
|
||||
todoSlot={hasTodos ? <TodoSummary todos={todoItems} /> : undefined}
|
||||
clarificationSlot={
|
||||
hasPendingClarification ? (
|
||||
<div className="overflow-auto">
|
||||
<ClarificationInputOverlay
|
||||
message={pendingClarification}
|
||||
onResolved={onClarificationResolved}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* EDITOR ZONE: flex-1, fills remaining space */}
|
||||
@@ -733,11 +462,10 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
<Tooltip open={showRequestChangesTooltip}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex-1 min-h-0">
|
||||
<RichTextInput
|
||||
<TipTapInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={isDisabled || !!hasPendingClarification}
|
||||
@@ -745,6 +473,13 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
submitKey={submitKey}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
sessionId={sessionId}
|
||||
taskId={taskId}
|
||||
onAddContextFile={onAddContextFile}
|
||||
onToggleContextFile={onToggleContextFile}
|
||||
planContextEnabled={planContextEnabled}
|
||||
onAgentCommand={handleAgentCommand}
|
||||
onImagePaste={handleImagePaste}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -753,7 +488,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Integrated toolbar - memoized to prevent re-renders on input changes */}
|
||||
{/* Integrated toolbar */}
|
||||
<ChatInputToolbar
|
||||
planModeEnabled={planModeEnabled}
|
||||
onPlanModeChange={onPlanModeChange}
|
||||
@@ -767,6 +502,12 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmit}
|
||||
submitKey={submitKey}
|
||||
contextCount={allItems.length}
|
||||
contextPopoverOpen={contextPopoverOpen}
|
||||
onContextPopoverOpenChange={setContextPopoverOpen}
|
||||
planContextEnabled={planContextEnabled}
|
||||
contextFiles={contextFiles}
|
||||
onToggleFile={onToggleContextFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconFileTextSpark,
|
||||
IconPlayerPauseFilled,
|
||||
IconAt,
|
||||
} from '@tabler/icons-react';
|
||||
import { GridSpinner } from '@/components/grid-spinner';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SHORTCUTS } from '@/lib/keyboard/constants';
|
||||
import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip';
|
||||
import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display';
|
||||
import { SessionsDropdown } from '@/components/task/sessions-dropdown';
|
||||
import { ModelSelector } from '@/components/task/model-selector';
|
||||
import { ContextPopover } from './context-popover';
|
||||
import type { ContextFile } from '@/lib/state/context-files-store';
|
||||
|
||||
export type ChatInputToolbarProps = {
|
||||
planModeEnabled: boolean;
|
||||
onPlanModeChange: (enabled: boolean) => void;
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription: string;
|
||||
isAgentBusy: boolean;
|
||||
isDisabled: boolean;
|
||||
isSending: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
submitKey?: 'enter' | 'cmd_enter';
|
||||
contextCount?: number;
|
||||
contextPopoverOpen?: boolean;
|
||||
onContextPopoverOpenChange?: (open: boolean) => void;
|
||||
/** Whether plan is selected as context in the popover (independent of plan panel) */
|
||||
planContextEnabled?: boolean;
|
||||
contextFiles?: ContextFile[];
|
||||
onToggleFile?: (file: ContextFile) => void;
|
||||
};
|
||||
|
||||
export const ChatInputToolbar = memo(function ChatInputToolbar({
|
||||
planModeEnabled,
|
||||
onPlanModeChange,
|
||||
sessionId,
|
||||
taskId,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
isAgentBusy,
|
||||
isDisabled,
|
||||
isSending,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitKey = 'cmd_enter',
|
||||
contextCount = 0,
|
||||
contextPopoverOpen = false,
|
||||
onContextPopoverOpenChange,
|
||||
planContextEnabled = false,
|
||||
contextFiles = [],
|
||||
onToggleFile,
|
||||
}: ChatInputToolbarProps) {
|
||||
const submitShortcut = submitKey === 'enter' ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border">
|
||||
{/* Left: Plan, Thinking, Context */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Plan mode toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40',
|
||||
planModeEnabled && 'bg-primary/15 text-primary'
|
||||
)}
|
||||
onClick={() => onPlanModeChange(!planModeEnabled)}
|
||||
>
|
||||
<IconFileTextSpark className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Context @ button */}
|
||||
<ContextPopover
|
||||
open={contextPopoverOpen}
|
||||
onOpenChange={onContextPopoverOpenChange ?? (() => {})}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative"
|
||||
>
|
||||
<IconAt className="h-4 w-4" />
|
||||
{contextCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-4 min-w-4 rounded-full bg-muted-foreground/80 text-[10px] text-background flex items-center justify-center px-0.5">
|
||||
{contextCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
sessionId={sessionId}
|
||||
planContextEnabled={planContextEnabled}
|
||||
contextFiles={contextFiles}
|
||||
onToggleFile={onToggleFile ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: Sessions, Model, Submit */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<SessionsDropdown
|
||||
taskId={taskId}
|
||||
activeSessionId={sessionId}
|
||||
taskTitle={taskTitle}
|
||||
taskDescription={taskDescription}
|
||||
/>
|
||||
|
||||
{/* Token usage display */}
|
||||
<TokenUsageDisplay sessionId={sessionId} />
|
||||
|
||||
<ModelSelector sessionId={sessionId} />
|
||||
|
||||
{/* Submit/Stop button */}
|
||||
<div className="ml-1">
|
||||
<KeyboardShortcutTooltip shortcut={submitShortcut} enabled={!isAgentBusy && !isDisabled}>
|
||||
{isAgentBusy ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full cursor-pointer"
|
||||
disabled={isDisabled}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSending ? (
|
||||
<GridSpinner className="text-primary-foreground" />
|
||||
) : (
|
||||
<IconArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</KeyboardShortcutTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@kandev/ui/collapsible';
|
||||
import { IconChevronDown, IconChevronRight, IconX, IconMessage } from '@tabler/icons-react';
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import { CommentDisplay } from '@/components/diff/comment-display';
|
||||
|
||||
interface CommentBlockProps {
|
||||
/** File path for the comments */
|
||||
filePath: string;
|
||||
/** Comments for this file */
|
||||
comments: DiffComment[];
|
||||
/** Callback to remove all comments for this file */
|
||||
onRemove: () => void;
|
||||
/** Callback to remove a specific comment */
|
||||
onRemoveComment: (commentId: string) => void;
|
||||
/** Callback when a comment is clicked (for jump-to-line) */
|
||||
onCommentClick?: (comment: DiffComment) => void;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich block component for embedding review comments in chat input.
|
||||
* Shows file name with comment count badge, expandable to see details.
|
||||
*/
|
||||
export function CommentBlock({
|
||||
filePath,
|
||||
comments,
|
||||
onRemove,
|
||||
onRemoveComment,
|
||||
onCommentClick,
|
||||
className,
|
||||
}: CommentBlockProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Get just the filename for display
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
|
||||
if (comments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-muted/30',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 cursor-pointer p-0"
|
||||
>
|
||||
{isOpen ? (
|
||||
<IconChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<IconChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<IconMessage className="h-3.5 w-3.5 text-blue-500" />
|
||||
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs font-medium"
|
||||
title={filePath}
|
||||
>
|
||||
{fileName}
|
||||
</span>
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
>
|
||||
{comments.length}
|
||||
</Badge>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
|
||||
>
|
||||
<IconX className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-1.5 border-t border-border/50 px-2 py-2">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
onClick={() => onCommentClick?.(comment)}
|
||||
className={cn(
|
||||
'rounded transition-colors',
|
||||
onCommentClick && 'cursor-pointer hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<CommentDisplay
|
||||
comment={comment}
|
||||
compact
|
||||
onDelete={() => onRemoveComment(comment.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentBlocksContainerProps {
|
||||
/** Comments grouped by file path */
|
||||
commentsByFile: Record<string, DiffComment[]>;
|
||||
/** Session ID for removing comments */
|
||||
sessionId: string;
|
||||
/** Callback to remove comments for a file */
|
||||
onRemoveFile: (filePath: string) => void;
|
||||
/** Callback to remove a specific comment */
|
||||
onRemoveComment: (sessionId: string, filePath: string, commentId: string) => void;
|
||||
/** Callback when a comment is clicked (for jump-to-line) */
|
||||
onCommentClick?: (comment: DiffComment) => void;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for multiple CommentBlock components
|
||||
*/
|
||||
export function CommentBlocksContainer({
|
||||
commentsByFile,
|
||||
sessionId,
|
||||
onRemoveFile,
|
||||
onRemoveComment,
|
||||
onCommentClick,
|
||||
className,
|
||||
}: CommentBlocksContainerProps) {
|
||||
const files = Object.keys(commentsByFile);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{files.map((filePath) => (
|
||||
<CommentBlock
|
||||
key={filePath}
|
||||
filePath={filePath}
|
||||
comments={commentsByFile[filePath]}
|
||||
onRemove={() => onRemoveFile(filePath)}
|
||||
onRemoveComment={(commentId) => onRemoveComment(sessionId, filePath, commentId)}
|
||||
onCommentClick={onCommentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import type { CommentContextItem } from '@/lib/types/context';
|
||||
import { getFileName } from '@/lib/utils/file-path';
|
||||
import { ContextChip } from './context-chip';
|
||||
import { CommentDisplay } from '@/components/diff/comment-display';
|
||||
|
||||
export const CommentItem = memo(function CommentItem({ item }: { item: CommentContextItem }) {
|
||||
const fileName = getFileName(item.filePath);
|
||||
|
||||
const preview = (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground truncate" title={item.filePath}>
|
||||
{fileName}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{item.comments.map((comment) => (
|
||||
<CommentDisplay
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
compact
|
||||
onDelete={() => item.onRemoveComment(comment.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextChip
|
||||
kind="comment"
|
||||
label={item.label}
|
||||
preview={preview}
|
||||
onClick={item.onOpen}
|
||||
onRemove={item.onRemove}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
IconListCheck,
|
||||
IconFile,
|
||||
IconMessageDots,
|
||||
IconPhoto,
|
||||
IconAt,
|
||||
IconX,
|
||||
IconPinFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import type { TablerIcon } from '@tabler/icons-react';
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@kandev/ui/hover-card';
|
||||
import type { ContextItemKind } from '@/lib/types/context';
|
||||
|
||||
const ICON_BY_KIND: Record<ContextItemKind, TablerIcon> = {
|
||||
plan: IconListCheck,
|
||||
file: IconFile,
|
||||
comment: IconMessageDots,
|
||||
'plan-comment': IconMessageDots,
|
||||
image: IconPhoto,
|
||||
prompt: IconAt,
|
||||
};
|
||||
|
||||
type ContextChipProps = {
|
||||
kind: ContextItemKind;
|
||||
label: string;
|
||||
pinned?: boolean;
|
||||
preview?: ReactNode;
|
||||
/** Data URL to render as a tiny thumbnail instead of the default icon */
|
||||
thumbnail?: string;
|
||||
onClick?: () => void;
|
||||
onUnpin?: () => void;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export const ContextChip = memo(function ContextChip({
|
||||
kind,
|
||||
label,
|
||||
pinned,
|
||||
preview,
|
||||
thumbnail,
|
||||
onClick,
|
||||
onUnpin,
|
||||
onRemove,
|
||||
}: ContextChipProps) {
|
||||
const Icon = ICON_BY_KIND[kind];
|
||||
|
||||
const chip = (
|
||||
<div
|
||||
className={`group flex items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground bg-muted/50 rounded border border-border/50 ${onClick ? 'cursor-pointer hover:bg-muted/80' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{thumbnail ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- base64 thumbnail
|
||||
<img src={thumbnail} alt="" className="h-3 w-3 shrink-0 rounded-sm object-cover" />
|
||||
) : (
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate max-w-[120px]">{label}</span>
|
||||
{pinned && onUnpin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnpin();
|
||||
}}
|
||||
className="ml-0.5 text-muted-foreground/70 hover:text-foreground cursor-pointer"
|
||||
title="Unpin (will be removed after send)"
|
||||
>
|
||||
<IconPinFilled className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 ml-0.5 hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<IconX className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!preview) return chip;
|
||||
|
||||
return (
|
||||
<ControlledHoverChip preview={preview}>
|
||||
{chip}
|
||||
</ControlledHoverChip>
|
||||
);
|
||||
});
|
||||
|
||||
function ControlledHoverChip({
|
||||
preview,
|
||||
children,
|
||||
}: {
|
||||
preview: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const suppressRef = useRef(false);
|
||||
|
||||
return (
|
||||
<HoverCard
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next && suppressRef.current) return;
|
||||
setOpen(next);
|
||||
}}
|
||||
openDelay={300}
|
||||
>
|
||||
<HoverCardTrigger
|
||||
asChild
|
||||
onClick={() => {
|
||||
suppressRef.current = true;
|
||||
setOpen(false);
|
||||
setTimeout(() => { suppressRef.current = false; }, 300);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="start" className="w-80 max-h-80 overflow-y-auto">
|
||||
{preview}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import type { ContextItem } from '@/lib/types/context';
|
||||
import { PlanItem } from './plan-item';
|
||||
import { FileItem } from './file-item';
|
||||
import { PromptItem } from './prompt-item';
|
||||
import { CommentItem } from './comment-item';
|
||||
import { PlanCommentItem } from './plan-comment-item';
|
||||
import { ImageItem } from './image-item';
|
||||
|
||||
export function ContextItemRenderer({
|
||||
item,
|
||||
sessionId,
|
||||
}: {
|
||||
item: ContextItem;
|
||||
sessionId?: string | null;
|
||||
}) {
|
||||
switch (item.kind) {
|
||||
case 'plan':
|
||||
return <PlanItem item={item} />;
|
||||
case 'file':
|
||||
return <FileItem item={item} sessionId={sessionId} />;
|
||||
case 'prompt':
|
||||
return <PromptItem item={item} />;
|
||||
case 'comment':
|
||||
return <CommentItem item={item} />;
|
||||
case 'plan-comment':
|
||||
return <PlanCommentItem item={item} />;
|
||||
case 'image':
|
||||
return <ImageItem item={item} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ContextItem } from '@/lib/types/context';
|
||||
import { ContextItemRenderer } from './context-item-renderer';
|
||||
|
||||
type ContextZoneProps = {
|
||||
items: ContextItem[];
|
||||
sessionId?: string | null;
|
||||
queueSlot?: ReactNode;
|
||||
todoSlot?: ReactNode;
|
||||
clarificationSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export function ContextZone({
|
||||
items,
|
||||
sessionId,
|
||||
queueSlot,
|
||||
todoSlot,
|
||||
clarificationSlot,
|
||||
}: ContextZoneProps) {
|
||||
const hasContent = !!queueSlot || !!todoSlot || items.length > 0 || !!clarificationSlot;
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto max-h-[40%] border-b border-border/50 shrink-0">
|
||||
<div className="px-2 pt-2 pb-1 space-y-1.5">
|
||||
{queueSlot}
|
||||
{todoSlot}
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap px-0 py-0.5">
|
||||
{items.map((item) => (
|
||||
<ContextItemRenderer key={item.id} item={item} sessionId={sessionId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{clarificationSlot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback } from 'react';
|
||||
import type { FileContextItem } from '@/lib/types/context';
|
||||
import { ContextChip } from './context-chip';
|
||||
import { LazyFilePreview } from './lazy-file-preview';
|
||||
|
||||
export const FileItem = memo(function FileItem({
|
||||
item,
|
||||
sessionId,
|
||||
}: {
|
||||
item: FileContextItem;
|
||||
sessionId?: string | null;
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
item.onOpen(item.path);
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<ContextChip
|
||||
kind="file"
|
||||
label={item.label}
|
||||
pinned={item.pinned}
|
||||
preview={<LazyFilePreview path={item.path} sessionId={sessionId ?? null} />}
|
||||
onClick={handleClick}
|
||||
onUnpin={item.onUnpin}
|
||||
onRemove={item.onRemove}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@kandev/ui/dialog';
|
||||
import type { ImageContextItem } from '@/lib/types/context';
|
||||
import { ContextChip } from './context-chip';
|
||||
|
||||
export const ImageItem = memo(function ImageItem({ item }: { item: ImageContextItem }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const preview = (
|
||||
<div className="space-y-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */}
|
||||
<img
|
||||
src={item.attachment.preview}
|
||||
alt="Preview"
|
||||
className="max-w-full max-h-48 rounded object-contain"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.attachment.width} × {item.attachment.height}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextChip
|
||||
kind="image"
|
||||
label={item.label}
|
||||
thumbnail={item.attachment.preview}
|
||||
preview={preview}
|
||||
onClick={handleClick}
|
||||
onRemove={item.onRemove}
|
||||
/>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-3xl p-2">
|
||||
<DialogTitle className="sr-only">Image preview</DialogTitle>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */}
|
||||
<img
|
||||
src={item.attachment.preview}
|
||||
alt="Full size preview"
|
||||
className="max-w-full max-h-[80vh] rounded object-contain mx-auto"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export { ContextChip } from './context-chip';
|
||||
export { ContextItemRenderer } from './context-item-renderer';
|
||||
export { ContextZone } from './context-zone';
|
||||
export { PlanItem } from './plan-item';
|
||||
export { FileItem } from './file-item';
|
||||
export { PromptItem } from './prompt-item';
|
||||
export { CommentItem } from './comment-item';
|
||||
export { PlanCommentItem } from './plan-comment-item';
|
||||
export { ImageItem } from './image-item';
|
||||
export { LazyFilePreview } from './lazy-file-preview';
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { requestFileContent } from '@/lib/ws/workspace-files';
|
||||
|
||||
type LazyFilePreviewProps = {
|
||||
path: string;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export function LazyFilePreview({ path, sessionId }: LazyFilePreviewProps) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function fetch() {
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
const response = await requestFileContent(client, sessionId!, path);
|
||||
if (!cancelled) {
|
||||
setContent(response.content ?? null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetch();
|
||||
return () => { cancelled = true; };
|
||||
}, [path, sessionId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground text-xs font-medium truncate">{path}</div>
|
||||
<div className="h-3 w-3/4 bg-muted animate-pulse rounded" />
|
||||
<div className="h-3 w-1/2 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || content === null) {
|
||||
return <div className="text-muted-foreground text-xs">{path}</div>;
|
||||
}
|
||||
|
||||
const preview = content.length > 2000 ? content.slice(0, 2000) + '...' : content;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground text-xs font-medium truncate">{path}</div>
|
||||
<pre className="text-[10px] leading-tight font-mono whitespace-pre-wrap break-all">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
import { getTaskPlan } from '@/lib/api/domains/plan-api';
|
||||
|
||||
type LazyPlanPreviewProps = {
|
||||
taskId: string | null;
|
||||
};
|
||||
|
||||
export function LazyPlanPreview({ taskId }: LazyPlanPreviewProps) {
|
||||
const plan = useAppStore((state) =>
|
||||
taskId ? state.taskPlans.byTaskId[taskId] ?? null : null
|
||||
);
|
||||
const loaded = useAppStore((state) =>
|
||||
taskId ? state.taskPlans.loadedByTaskId[taskId] ?? false : false
|
||||
);
|
||||
const loading = useAppStore((state) =>
|
||||
taskId ? state.taskPlans.loadingByTaskId[taskId] ?? false : false
|
||||
);
|
||||
|
||||
const storeApi = useAppStoreApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId || loaded || loading) return;
|
||||
|
||||
const { setTaskPlanLoading } = storeApi.getState();
|
||||
setTaskPlanLoading(taskId, true);
|
||||
|
||||
getTaskPlan(taskId).then((result) => {
|
||||
storeApi.getState().setTaskPlan(taskId, result);
|
||||
}).catch(() => {
|
||||
storeApi.getState().setTaskPlanLoading(taskId, false);
|
||||
});
|
||||
}, [taskId, loaded, loading, storeApi]);
|
||||
|
||||
if (!taskId) {
|
||||
return <div className="text-xs text-muted-foreground">No task selected</div>;
|
||||
}
|
||||
|
||||
if (loading || !loaded) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground text-xs font-medium">Plan</div>
|
||||
<div className="h-3 w-3/4 bg-muted animate-pulse rounded" />
|
||||
<div className="h-3 w-1/2 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plan?.content) {
|
||||
return <div className="text-xs text-muted-foreground">Plan is empty</div>;
|
||||
}
|
||||
|
||||
const preview = plan.content.length > 2000 ? plan.content.slice(0, 2000) + '...' : plan.content;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground text-xs font-medium">Plan</div>
|
||||
<pre className="text-[10px] leading-tight font-mono whitespace-pre-wrap break-all">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import type { PlanCommentContextItem } from '@/lib/types/context';
|
||||
import { ContextChip } from './context-chip';
|
||||
|
||||
export const PlanCommentItem = memo(function PlanCommentItem({ item }: { item: PlanCommentContextItem }) {
|
||||
const preview = (
|
||||
<div className="space-y-1.5">
|
||||
{item.comments.map((comment) => (
|
||||
<div key={comment.id} className="text-xs space-y-0.5">
|
||||
{comment.selectedText && (
|
||||
<div className="text-muted-foreground italic line-clamp-2">
|
||||
“{comment.selectedText}”
|
||||
</div>
|
||||
)}
|
||||
<div className="break-words">{comment.comment}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextChip
|
||||
kind="plan-comment"
|
||||
label={item.label}
|
||||
preview={preview}
|
||||
onClick={item.onOpen}
|
||||
onRemove={item.onRemove}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import type { PlanContextItem } from '@/lib/types/context';
|
||||
import { ContextChip } from './context-chip';
|
||||
import { LazyPlanPreview } from './lazy-plan-preview';
|
||||
|
||||
export const PlanItem = memo(function PlanItem({ item }: { item: PlanContextItem }) {
|
||||
return (
|
||||
<ContextChip
|
||||
kind="plan"
|
||||
label={item.label}
|
||||
pinned={item.pinned}
|
||||
preview={<LazyPlanPreview taskId={item.taskId ?? null} />}
|
||||
onClick={item.onOpen}
|
||||
onUnpin={item.onUnpin}
|
||||
onRemove={item.onRemove}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import type { PromptContextItem } from '@/lib/types/context';
|
||||
import { ContextChip } from './context-chip';
|
||||
import { PromptPreview } from './prompt-preview';
|
||||
|
||||
export const PromptItem = memo(function PromptItem({ item }: { item: PromptContextItem }) {
|
||||
return (
|
||||
<ContextChip
|
||||
kind="prompt"
|
||||
label={item.label}
|
||||
pinned={item.pinned}
|
||||
preview={<PromptPreview content={item.promptContent ?? null} />}
|
||||
onClick={item.onClick}
|
||||
onUnpin={item.onUnpin}
|
||||
onRemove={item.onRemove}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
|
||||
type PromptPreviewProps = {
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export function PromptPreview({ content }: PromptPreviewProps) {
|
||||
if (!content) {
|
||||
return <div className="text-xs text-muted-foreground">Custom prompt</div>;
|
||||
}
|
||||
|
||||
const truncated = content.length > 2000 ? content.slice(0, 2000) + '...' : content;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground text-xs font-medium">Prompt</div>
|
||||
<pre className="text-[10px] leading-tight font-mono whitespace-pre-wrap break-all">
|
||||
{truncated}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PromptPreviewFromStoreProps = {
|
||||
promptId: string;
|
||||
};
|
||||
|
||||
export function PromptPreviewFromStore({ promptId }: PromptPreviewFromStoreProps) {
|
||||
const content = useAppStore((state) => {
|
||||
const prompt = state.prompts.items.find((p) => p.id === promptId);
|
||||
return prompt?.content ?? null;
|
||||
});
|
||||
|
||||
return <PromptPreview content={content} />;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { IconListCheck, IconFile, IconFolder, IconSearch, IconAt } from '@tabler/icons-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { searchWorkspaceFiles } from '@/lib/ws/workspace-files';
|
||||
import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
|
||||
import { isDirectory, getFileName } from '@/lib/utils/file-path';
|
||||
import type { ContextFile } from '@/lib/state/context-files-store';
|
||||
|
||||
type ContextPopoverProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
trigger: React.ReactNode;
|
||||
sessionId: string | null;
|
||||
/** Whether plan is included as context (from context files store, not plan panel) */
|
||||
planContextEnabled: boolean;
|
||||
contextFiles: ContextFile[];
|
||||
onToggleFile: (file: ContextFile) => void;
|
||||
};
|
||||
|
||||
const FILE_SEARCH_DEBOUNCE = 300;
|
||||
|
||||
const PLAN_CONTEXT_FILE: ContextFile = { path: 'plan:context', name: 'Plan', pinned: true };
|
||||
|
||||
export function ContextPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
sessionId,
|
||||
planContextEnabled,
|
||||
contextFiles,
|
||||
onToggleFile,
|
||||
}: ContextPopoverProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [fileResults, setFileResults] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { prompts } = useCustomPrompts();
|
||||
|
||||
// Focus input when popover opens
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- resetting state on open/close is intentional */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
} else {
|
||||
setQuery('');
|
||||
setFileResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// Debounced file search
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- loading state sync is intentional for UX */
|
||||
useEffect(() => {
|
||||
if (!open || !sessionId) return;
|
||||
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
const delay = query === '' ? 0 : FILE_SEARCH_DEBOUNCE;
|
||||
setIsLoading(true);
|
||||
|
||||
let cancelled = false;
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) {
|
||||
if (!cancelled) { setFileResults([]); setIsLoading(false); }
|
||||
return;
|
||||
}
|
||||
const response = await searchWorkspaceFiles(client, sessionId, query || '', 20);
|
||||
if (!cancelled) setFileResults(response.files || []);
|
||||
} catch {
|
||||
if (!cancelled) setFileResults([]);
|
||||
}
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
};
|
||||
}, [open, sessionId, query]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
const handleToggleFile = useCallback(
|
||||
(filePath: string) => {
|
||||
const name = getFileName(filePath);
|
||||
onToggleFile({ path: filePath, name, pinned: true });
|
||||
},
|
||||
[onToggleFile]
|
||||
);
|
||||
|
||||
const isFileSelected = useCallback(
|
||||
(path: string) => contextFiles.some((f) => f.path === path),
|
||||
[contextFiles]
|
||||
);
|
||||
|
||||
// Filter prompts by query
|
||||
const filteredPrompts = query
|
||||
? prompts.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()))
|
||||
: prompts;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 p-0 gap-1"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 pt-3 pb-2 flex items-baseline gap-1.5">
|
||||
<p className="text-xs font-medium">Context</p>
|
||||
<p className="text-[10px] text-muted-foreground">· Select files and prompts to include</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-3 pb-2">
|
||||
<div className="relative">
|
||||
<IconSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search files and prompts..."
|
||||
className="h-7 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="max-h-60 overflow-y-auto border-t border-border">
|
||||
{/* Plan item - always first, only when no search query */}
|
||||
{!query && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => onToggleFile(PLAN_CONTEXT_FILE)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={planContextEnabled}
|
||||
onCheckedChange={() => onToggleFile(PLAN_CONTEXT_FILE)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<IconListCheck className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs flex-1 truncate">Plan</span>
|
||||
{planContextEnabled && (
|
||||
<span className="text-[9px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
|
||||
ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompts section */}
|
||||
{filteredPrompts.length > 0 && (
|
||||
<>
|
||||
{!query && (
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Prompts</p>
|
||||
</div>
|
||||
)}
|
||||
{filteredPrompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => {
|
||||
// Insert prompt content by simulating a mention select - for now just copy to clipboard or similar
|
||||
// Prompts in context popover: clicking inserts the prompt name as a context file reference
|
||||
// This toggles the prompt as a context file
|
||||
onToggleFile({ path: `prompt:${prompt.id}`, name: prompt.name, pinned: true });
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={contextFiles.some((f) => f.path === `prompt:${prompt.id}`)}
|
||||
onCheckedChange={() => onToggleFile({ path: `prompt:${prompt.id}`, name: prompt.name, pinned: true })}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<IconAt className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs truncate block">{prompt.name}</span>
|
||||
{prompt.content && (
|
||||
<span className="text-[10px] text-muted-foreground truncate block">
|
||||
{prompt.content.length > 60 ? prompt.content.slice(0, 60) + '...' : prompt.content}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Files section header */}
|
||||
{!query && fileResults.length > 0 && (
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Files</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File results */}
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">Loading...</div>
|
||||
) : fileResults.length === 0 && query && filteredPrompts.length === 0 ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">No results found</div>
|
||||
) : (
|
||||
fileResults.map((filePath) => {
|
||||
const isDir = isDirectory(filePath);
|
||||
const name = getFileName(filePath);
|
||||
const parent = filePath.slice(0, filePath.length - name.length);
|
||||
return (
|
||||
<div
|
||||
key={filePath}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleToggleFile(filePath)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isFileSelected(filePath)}
|
||||
onCheckedChange={() => handleToggleFile(filePath)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
{isDir ? (
|
||||
<IconFolder className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<IconFile className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs truncate block">{name}</span>
|
||||
{parent && (
|
||||
<span className="text-[10px] text-muted-foreground truncate block">{parent}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { IconFileText, IconListCheck } from '@tabler/icons-react';
|
||||
import type { ActiveDocument } from '@/lib/state/slices/ui/types';
|
||||
|
||||
type DocumentReferenceIndicatorProps = {
|
||||
activeDocument: ActiveDocument;
|
||||
};
|
||||
|
||||
export const DocumentReferenceIndicator = memo(function DocumentReferenceIndicator({
|
||||
activeDocument,
|
||||
}: DocumentReferenceIndicatorProps) {
|
||||
const isPlan = activeDocument.type === 'plan';
|
||||
const label = isPlan
|
||||
? 'Plan will be included in the prompt'
|
||||
: `${activeDocument.name} will be included in the prompt`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-primary/80 bg-primary/5 rounded-lg border border-primary/10">
|
||||
{isPlan ? (
|
||||
<IconListCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<IconFileText className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
type EditorContextValue = {
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
};
|
||||
|
||||
const EditorContext = createContext<EditorContextValue>({ sessionId: null, taskId: null });
|
||||
|
||||
export const EditorContextProvider = EditorContext.Provider;
|
||||
export const useEditorContext = () => useContext(EditorContext);
|
||||
@@ -10,6 +10,8 @@ export type ImageAttachment = {
|
||||
mimeType: string; // "image/png", "image/jpeg", "image/gif", "image/webp"
|
||||
preview: string; // Data URL for preview display
|
||||
size: number; // File size in bytes
|
||||
width: number; // Image width in pixels
|
||||
height: number; // Image height in pixels
|
||||
};
|
||||
|
||||
// Supported image types
|
||||
@@ -20,7 +22,7 @@ export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB per image
|
||||
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB total
|
||||
export const MAX_IMAGES = 10;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -113,13 +115,21 @@ export function processImageFile(file: File): Promise<ImageAttachment | null> {
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
const mimeType = dataUrl.split(';')[0].split(':')[1];
|
||||
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
data: base64,
|
||||
mimeType,
|
||||
preview: dataUrl,
|
||||
size: file.size,
|
||||
});
|
||||
// Read image dimensions
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
id: crypto.randomUUID(),
|
||||
data: base64,
|
||||
mimeType,
|
||||
preview: dataUrl,
|
||||
size: file.size,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = dataUrl;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read image file');
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { IconAt, IconFile, IconFolder } from '@tabler/icons-react';
|
||||
import { IconAt, IconFile, IconFolder, IconListCheck } from '@tabler/icons-react';
|
||||
import type { MentionItem } from '@/hooks/use-inline-mention';
|
||||
import { getFileName, isDirectory } from '@/lib/utils/file-path';
|
||||
import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu';
|
||||
|
||||
type MentionMenuProps = {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
position: { x: number; y: number } | null;
|
||||
position?: { x: number; y: number } | null;
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
items: MentionItem[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
@@ -16,36 +18,25 @@ type MentionMenuProps = {
|
||||
setSelectedIndex: (index: number) => void;
|
||||
};
|
||||
|
||||
// Extract filename and parent path from a full path
|
||||
function parseFilePath(filePath: string): { name: string; parent: string } {
|
||||
const parts = filePath.split('/');
|
||||
const name = parts.pop() || filePath;
|
||||
const parent = parts.length > 0 ? parts.join('/') + '/' : '';
|
||||
return { name, parent };
|
||||
}
|
||||
|
||||
// Check if path looks like a directory (ends with / or has no extension)
|
||||
function isDirectory(filePath: string): boolean {
|
||||
if (filePath.endsWith('/')) return true;
|
||||
const name = filePath.split('/').pop() || '';
|
||||
return !name.includes('.');
|
||||
}
|
||||
|
||||
// Get the appropriate icon for an item
|
||||
function getItemIcon(item: MentionItem) {
|
||||
if (item.type === 'prompt') {
|
||||
if (item.kind === 'prompt') {
|
||||
return <IconAt className="h-4 w-4" />;
|
||||
}
|
||||
if (item.kind === 'plan') {
|
||||
return <IconListCheck className="h-4 w-4" />;
|
||||
}
|
||||
const isDir = isDirectory(item.label);
|
||||
return isDir ? <IconFolder className="h-4 w-4" /> : <IconFile className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
// Get the label and description for an item
|
||||
function getItemDisplay(item: MentionItem): { label: string; description?: string } {
|
||||
if (item.type === 'prompt') {
|
||||
if (item.kind === 'prompt' || item.kind === 'plan') {
|
||||
return { label: item.label, description: item.description };
|
||||
}
|
||||
const { name, parent } = parseFilePath(item.label);
|
||||
const name = getFileName(item.label);
|
||||
const parent = item.label.slice(0, item.label.length - name.length);
|
||||
return { label: name, description: parent || undefined };
|
||||
}
|
||||
|
||||
@@ -53,6 +44,7 @@ export function MentionMenu({
|
||||
isOpen,
|
||||
isLoading,
|
||||
position,
|
||||
clientRect,
|
||||
items,
|
||||
query,
|
||||
selectedIndex,
|
||||
@@ -71,7 +63,8 @@ export function MentionMenu({
|
||||
return (
|
||||
<PopupMenu
|
||||
isOpen={isOpen}
|
||||
position={position}
|
||||
position={position ?? null}
|
||||
clientRect={clientRect}
|
||||
title="Mention files, prompts"
|
||||
selectedIndex={selectedIndex}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { memo, isValidElement, useState, type ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { IconWand, IconMessageDots } from '@tabler/icons-react';
|
||||
import { IconWand, IconMessageDots, IconFile } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import { RichBlocks } from '@/components/task/chat/messages/rich-blocks';
|
||||
@@ -138,9 +138,15 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
|
||||
|
||||
// User message: right-aligned bubble with markdown support
|
||||
if (isUser) {
|
||||
// Extract image attachments and context flags from metadata
|
||||
const metadata = comment.metadata as { attachments?: Array<{ type: string; data: string; mime_type: string }>; plan_mode?: boolean; has_review_comments?: boolean } | undefined;
|
||||
// Extract image attachments, context files, and context flags from metadata
|
||||
const metadata = comment.metadata as {
|
||||
attachments?: Array<{ type: string; data: string; mime_type: string }>;
|
||||
plan_mode?: boolean;
|
||||
has_review_comments?: boolean;
|
||||
context_files?: Array<{ path: string; name: string }>;
|
||||
} | undefined;
|
||||
const imageAttachments = (metadata?.attachments || []).filter(att => att.type === 'image');
|
||||
const contextFiles = metadata?.context_files || [];
|
||||
const hasPlanMode = !!metadata?.plan_mode;
|
||||
const hasReviewComments = !!metadata?.has_review_comments;
|
||||
const hasContent = comment.content && comment.content.trim() !== '';
|
||||
@@ -150,8 +156,8 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
|
||||
<div className="flex justify-end w-full overflow-hidden">
|
||||
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl overflow-hidden group">
|
||||
{/* Context badges */}
|
||||
{(hasPlanMode || hasReviewComments) && (
|
||||
<div className="flex justify-end gap-1.5 mb-1">
|
||||
{(hasPlanMode || hasReviewComments || contextFiles.length > 0) && (
|
||||
<div className="flex justify-end gap-1.5 mb-1 flex-wrap">
|
||||
{hasPlanMode && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/20 px-2 py-0.5 text-[10px] text-purple-400">
|
||||
<IconWand size={10} /> Plan mode
|
||||
@@ -162,6 +168,11 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
|
||||
<IconMessageDots size={10} /> Review comments
|
||||
</span>
|
||||
)}
|
||||
{contextFiles.map((f) => (
|
||||
<span key={f.path} className="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
<IconFile size={10} /> {f.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-2xl bg-primary/30 px-4 py-2.5 text-xs overflow-hidden">
|
||||
|
||||
@@ -10,6 +10,8 @@ const MENU_MAX_WIDTH = 700;
|
||||
export type PopupMenuProps = {
|
||||
isOpen: boolean;
|
||||
position: { x: number; y: number } | null;
|
||||
/** Alternative to position: a function returning a DOMRect (from TipTap suggestion) */
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
title: string;
|
||||
selectedIndex: number;
|
||||
onClose: () => void;
|
||||
@@ -21,6 +23,7 @@ export type PopupMenuProps = {
|
||||
export function PopupMenu({
|
||||
isOpen,
|
||||
position,
|
||||
clientRect: clientRectFn,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
@@ -43,16 +46,26 @@ export function PopupMenu({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !position) {
|
||||
// Resolve position from clientRect function or direct position
|
||||
const resolvedPosition = (() => {
|
||||
if (position) return position;
|
||||
if (clientRectFn) {
|
||||
const rect = clientRectFn();
|
||||
if (rect) return { x: rect.left, y: rect.top };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!isOpen || !resolvedPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate position (above cursor) with content-based width
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: Math.max(8, position.x),
|
||||
bottom: window.innerHeight - position.y + 8,
|
||||
maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - position.x - 8),
|
||||
left: Math.max(8, resolvedPosition.x),
|
||||
bottom: window.innerHeight - resolvedPosition.y + 8,
|
||||
maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - resolvedPosition.x - 8),
|
||||
maxHeight: MENU_HEIGHT,
|
||||
zIndex: 50,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu';
|
||||
|
||||
type SlashCommandMenuProps = {
|
||||
isOpen: boolean;
|
||||
position: { x: number; y: number } | null;
|
||||
position?: { x: number; y: number } | null;
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
@@ -17,6 +18,7 @@ type SlashCommandMenuProps = {
|
||||
export function SlashCommandMenu({
|
||||
isOpen,
|
||||
position,
|
||||
clientRect,
|
||||
commands,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
@@ -32,7 +34,8 @@ export function SlashCommandMenu({
|
||||
return (
|
||||
<PopupMenu
|
||||
isOpen={isOpen}
|
||||
position={position}
|
||||
position={position ?? null}
|
||||
clientRect={clientRect}
|
||||
title="Commands"
|
||||
selectedIndex={selectedIndex}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useEditor, EditorContent, NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from '@tiptap/react';
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import History from '@tiptap/extension-history';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Code from '@tiptap/extension-code';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getChatDraftContent, setChatDraftContent } from '@/lib/local-storage';
|
||||
import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { searchWorkspaceFiles } from '@/lib/ws/workspace-files';
|
||||
import { EditorContextProvider } from './editor-context';
|
||||
import { MentionMenu } from './mention-menu';
|
||||
import { SlashCommandMenu } from './slash-command-menu';
|
||||
import { ContextMention } from './tiptap-mention-extension';
|
||||
import {
|
||||
createMentionSuggestion,
|
||||
createSlashSuggestion,
|
||||
type MenuState,
|
||||
type MentionSuggestionCallbacks,
|
||||
type SlashSuggestionCallbacks,
|
||||
} from './tiptap-suggestion';
|
||||
import type { MentionItem } from '@/hooks/use-inline-mention';
|
||||
import type { SlashCommand } from '@/hooks/use-inline-slash';
|
||||
import type { ContextFile } from '@/lib/state/context-files-store';
|
||||
|
||||
// ── Lowlight (syntax highlighting) ───────────────────────────────────
|
||||
|
||||
const lowlightInstance = createLowlight(common);
|
||||
|
||||
const CODE_LANGUAGES = [
|
||||
{ value: '', label: 'Plain' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'cpp', label: 'C++' },
|
||||
{ value: 'c', label: 'C' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'xml', label: 'XML' },
|
||||
];
|
||||
|
||||
function CodeBlockView({ node, updateAttributes }: NodeViewProps) {
|
||||
const language = (node.attrs.language as string) || '';
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="pre">
|
||||
<select
|
||||
contentEditable={false}
|
||||
className="code-block-language"
|
||||
value={language}
|
||||
onChange={(e) => updateAttributes({ language: e.target.value })}
|
||||
>
|
||||
{CODE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* @ts-expect-error -- NodeViewContent 'as' prop accepts any HTML tag but types only allow 'div' */}
|
||||
<NodeViewContent as="code" className={language ? `language-${language} hljs` : ''} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Handle ──────────────────────────────────────────────────────────
|
||||
|
||||
export type TipTapInputHandle = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getSelectionStart: () => number;
|
||||
getValue: () => string;
|
||||
setValue: (value: string) => void;
|
||||
clear: () => void;
|
||||
getTextareaElement: () => HTMLElement | null;
|
||||
insertText: (text: string, from: number, to: number) => void;
|
||||
getMentions: () => ContextFile[];
|
||||
};
|
||||
|
||||
// ── Props ───────────────────────────────────────────────────────────
|
||||
|
||||
type TipTapInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
planModeEnabled?: boolean;
|
||||
submitKey?: 'enter' | 'cmd_enter';
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
// TipTap-specific
|
||||
sessionId: string | null;
|
||||
taskId?: string | null;
|
||||
onAddContextFile?: (file: ContextFile) => void;
|
||||
onToggleContextFile?: (file: ContextFile) => void;
|
||||
planContextEnabled?: boolean;
|
||||
onAgentCommand?: (commandName: string) => void;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
};
|
||||
|
||||
// ── Filter items ────────────────────────────────────────────────────
|
||||
function filterItems(items: MentionItem[], query: string): MentionItem[] {
|
||||
if (!query) return items;
|
||||
const lq = query.toLowerCase();
|
||||
return items
|
||||
.map((item) => {
|
||||
const label = item.label.toLowerCase();
|
||||
let score = 0;
|
||||
if (label.startsWith(lq)) score = 100;
|
||||
else if (label.split(/[\s\-_/]/).some((w) => w.startsWith(lq))) score = 50;
|
||||
else if (label.includes(lq)) score = 25;
|
||||
return { item, score };
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────
|
||||
|
||||
export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(
|
||||
function TipTapInput(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
className,
|
||||
planModeEnabled = false,
|
||||
submitKey = 'cmd_enter',
|
||||
onFocus,
|
||||
onBlur,
|
||||
sessionId,
|
||||
taskId,
|
||||
onAgentCommand,
|
||||
onImagePaste,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// ── Refs for stable callbacks ─────────────────────────────────
|
||||
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
const submitKeyRef = useRef(submitKey);
|
||||
submitKeyRef.current = submitKey;
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const onImagePasteRef = useRef(onImagePaste);
|
||||
onImagePasteRef.current = onImagePaste;
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
// ── Menu state ────────────────────────────────────────────────
|
||||
|
||||
const [mentionMenu, setMentionMenu] = useState<MenuState<MentionItem>>({
|
||||
isOpen: false, items: [], query: '', clientRect: null, command: null,
|
||||
});
|
||||
const [slashMenu, setSlashMenu] = useState<MenuState<SlashCommand>>({
|
||||
isOpen: false, items: [], query: '', clientRect: null, command: null,
|
||||
});
|
||||
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
|
||||
// Reset selected index when items change
|
||||
useEffect(() => { setMentionSelectedIndex(0); }, [mentionMenu.items]);
|
||||
useEffect(() => { setSlashSelectedIndex(0); }, [slashMenu.items]);
|
||||
|
||||
// ── Keyboard handler refs (set by suggestion lifecycle) ───────
|
||||
|
||||
const mentionKeyDownRef = useRef<((event: KeyboardEvent) => boolean) | null>(null);
|
||||
const slashKeyDownRef = useRef<((event: KeyboardEvent) => boolean) | null>(null);
|
||||
|
||||
const mentionSelectedIndexRef = useRef(mentionSelectedIndex);
|
||||
mentionSelectedIndexRef.current = mentionSelectedIndex;
|
||||
|
||||
const slashSelectedIndexRef = useRef(slashSelectedIndex);
|
||||
slashSelectedIndexRef.current = slashSelectedIndex;
|
||||
|
||||
// Wire up keyboard navigation for menus
|
||||
mentionKeyDownRef.current = useCallback((event: KeyboardEvent) => {
|
||||
if (!mentionMenu.isOpen) return false;
|
||||
if (event.key === 'ArrowDown') {
|
||||
setMentionSelectedIndex((i) => Math.min(i + 1, mentionMenu.items.length - 1));
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
setMentionSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (mentionMenu.items.length > 0 && mentionMenu.command) {
|
||||
const idx = mentionSelectedIndexRef.current;
|
||||
const item = mentionMenu.items[idx];
|
||||
if (item) mentionMenu.command(item);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [mentionMenu]);
|
||||
|
||||
slashKeyDownRef.current = useCallback((event: KeyboardEvent) => {
|
||||
if (!slashMenu.isOpen) return false;
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSlashSelectedIndex((i) => Math.min(i + 1, slashMenu.items.length - 1));
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSlashSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (slashMenu.items.length > 0 && slashMenu.command) {
|
||||
const idx = slashSelectedIndexRef.current;
|
||||
const cmd = slashMenu.items[idx];
|
||||
if (cmd) slashMenu.command(cmd);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [slashMenu]);
|
||||
|
||||
// ── Data sources ──────────────────────────────────────────────
|
||||
|
||||
const { prompts } = useCustomPrompts();
|
||||
|
||||
const agentCommands = useAppStore((state) =>
|
||||
sessionId ? state.availableCommands.bySessionId[sessionId] : undefined
|
||||
);
|
||||
|
||||
const slashCommands = useMemo((): SlashCommand[] => {
|
||||
if (!agentCommands || agentCommands.length === 0) return [];
|
||||
return agentCommands
|
||||
.filter((cmd) => {
|
||||
const desc = cmd.description || '';
|
||||
return !desc.includes('(bundled)');
|
||||
})
|
||||
.map((cmd) => ({
|
||||
id: `agent-${cmd.name}`,
|
||||
label: `/${cmd.name}`,
|
||||
description: cmd.description || `Run /${cmd.name} command`,
|
||||
action: 'agent' as const,
|
||||
agentCommandName: cmd.name,
|
||||
}));
|
||||
}, [agentCommands]);
|
||||
|
||||
// ── Mention item search ───────────────────────────────────────
|
||||
|
||||
const promptsRef = useRef(prompts);
|
||||
promptsRef.current = prompts;
|
||||
const lastFileSearchRef = useRef<{ query: string; results: string[] }>({ query: '', results: [] });
|
||||
|
||||
const getMentionItems = useCallback(async (query: string): Promise<MentionItem[]> => {
|
||||
const allItems: MentionItem[] = [];
|
||||
|
||||
// Plan item
|
||||
const planItem: MentionItem = {
|
||||
id: '__plan__',
|
||||
kind: 'plan',
|
||||
label: 'Plan',
|
||||
description: 'Include the plan as context',
|
||||
onSelect: () => {}, // unused in TipTap flow
|
||||
};
|
||||
allItems.push(planItem);
|
||||
|
||||
// Prompt items
|
||||
for (const p of promptsRef.current) {
|
||||
allItems.push({
|
||||
id: p.id,
|
||||
kind: 'prompt',
|
||||
label: p.name,
|
||||
description: p.content.length > 100 ? p.content.slice(0, 100) + '...' : p.content,
|
||||
onSelect: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
// File search
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) {
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (client) {
|
||||
const cacheKey = query || '__empty__';
|
||||
let files: string[];
|
||||
if (lastFileSearchRef.current.query === cacheKey) {
|
||||
files = lastFileSearchRef.current.results;
|
||||
} else {
|
||||
const response = await searchWorkspaceFiles(client, sid, query || '', 20);
|
||||
files = response.files || [];
|
||||
lastFileSearchRef.current = { query: cacheKey, results: files };
|
||||
}
|
||||
for (const filePath of files) {
|
||||
allItems.push({
|
||||
id: filePath,
|
||||
kind: 'file',
|
||||
label: filePath,
|
||||
description: 'File',
|
||||
onSelect: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return filterItems(allItems, query);
|
||||
}, []);
|
||||
|
||||
// ── Suggestion configs ────────────────────────────────────────
|
||||
|
||||
const mentionCallbacks = useMemo((): MentionSuggestionCallbacks => ({
|
||||
getItems: getMentionItems,
|
||||
}), [getMentionItems]);
|
||||
|
||||
const onAgentCommandRef = useRef(onAgentCommand);
|
||||
onAgentCommandRef.current = onAgentCommand;
|
||||
const slashCommandsRef = useRef(slashCommands);
|
||||
slashCommandsRef.current = slashCommands;
|
||||
|
||||
const slashCallbacks = useMemo((): SlashSuggestionCallbacks => ({
|
||||
getCommands: () => slashCommandsRef.current,
|
||||
onAgentCommand: (name) => onAgentCommandRef.current?.(name),
|
||||
}), []);
|
||||
|
||||
const mentionSuggestion = useMemo(
|
||||
() => createMentionSuggestion(mentionCallbacks, setMentionMenu, mentionKeyDownRef),
|
||||
[mentionCallbacks],
|
||||
);
|
||||
|
||||
const slashSuggestion = useMemo(
|
||||
() => createSlashSuggestion(slashCallbacks, setSlashMenu, slashKeyDownRef),
|
||||
[slashCallbacks],
|
||||
);
|
||||
|
||||
// ── Submit keymap extension ───────────────────────────────────
|
||||
|
||||
const SubmitKeymap = useMemo(() => {
|
||||
return Extension.create({
|
||||
name: 'submitKeymap',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Enter': () => {
|
||||
if (disabledRef.current) return true;
|
||||
if (submitKeyRef.current === 'enter') {
|
||||
onSubmitRef.current?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
'Mod-Enter': () => {
|
||||
if (disabledRef.current) return true;
|
||||
if (submitKeyRef.current === 'cmd_enter') {
|
||||
onSubmitRef.current?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Editor ────────────────────────────────────────────────────
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
HardBreak,
|
||||
History,
|
||||
Code,
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight: lowlightInstance }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
ContextMention.configure({
|
||||
suggestions: [mentionSuggestion, slashSuggestion],
|
||||
}),
|
||||
SubmitKeymap,
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: cn(
|
||||
'w-full h-full resize-none bg-transparent px-2 py-2 overflow-y-auto',
|
||||
'text-sm leading-relaxed',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
planModeEnabled && 'border-primary/40',
|
||||
className,
|
||||
),
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
// 1. Image paste
|
||||
const items = event.clipboardData?.items;
|
||||
if (items) {
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePasteRef.current?.(imageFiles);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Markdown code fence paste
|
||||
const text = event.clipboardData?.getData('text/plain');
|
||||
if (text && text.includes('```')) {
|
||||
const segments = parseCodeFences(text);
|
||||
if (segments.some((s) => s.type === 'code')) {
|
||||
event.preventDefault();
|
||||
const { schema } = view.state;
|
||||
const nodes: import('@tiptap/pm/model').Node[] = [];
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'code') {
|
||||
nodes.push(
|
||||
schema.nodes.codeBlock.create(
|
||||
seg.language ? { language: seg.language } : null,
|
||||
seg.text ? schema.text(seg.text) : undefined,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const trimmed = seg.text.trim();
|
||||
if (!trimmed) continue;
|
||||
for (const line of trimmed.split('\n')) {
|
||||
nodes.push(
|
||||
schema.nodes.paragraph.create(
|
||||
null,
|
||||
line ? schema.text(line) : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nodes.length > 0) {
|
||||
const { from, to } = view.state.selection;
|
||||
view.dispatch(view.state.tr.replaceWith(from, to, nodes));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
handleDOMEvents: {
|
||||
focus: () => {
|
||||
onFocus?.();
|
||||
return false;
|
||||
},
|
||||
blur: () => {
|
||||
onBlur?.();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
if (isSyncingRef.current) return;
|
||||
if (!initialSyncDoneRef.current) return;
|
||||
const text = getMarkdownText(e);
|
||||
onChangeRef.current(text);
|
||||
// Persist rich content (mentions, code blocks, etc.)
|
||||
const sid = sessionIdRef.current;
|
||||
if (sid) setChatDraftContent(sid, e.getJSON());
|
||||
},
|
||||
editable: !disabled,
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
// Guard to prevent circular updates when syncing external value → editor
|
||||
const isSyncingRef = useRef(false);
|
||||
// Tracks whether the first value→editor sync has completed.
|
||||
// Until it has, onUpdate callbacks are suppressed so the editor's
|
||||
// init-time empty update doesn't wipe the hydrated draft.
|
||||
const initialSyncDoneRef = useRef(false);
|
||||
|
||||
// Sync disabled state
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(!disabled);
|
||||
}
|
||||
}, [editor, disabled]);
|
||||
|
||||
// Sync placeholder — the Placeholder extension reads its config once,
|
||||
// so we update it when the prop changes.
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.extensionManager.extensions.forEach((ext) => {
|
||||
if (ext.name === 'placeholder') {
|
||||
ext.options.placeholder = placeholder;
|
||||
editor.view.dispatch(editor.state.tr);
|
||||
}
|
||||
});
|
||||
}, [editor, placeholder]);
|
||||
|
||||
// Reset sync flag when session changes so the new session's content is restored
|
||||
const prevSyncSessionRef = useRef(sessionId);
|
||||
useEffect(() => {
|
||||
if (sessionId === prevSyncSessionRef.current) return;
|
||||
prevSyncSessionRef.current = sessionId;
|
||||
initialSyncDoneRef.current = false;
|
||||
}, [sessionId]);
|
||||
|
||||
// Sync value prop changes (for history navigation, clearing after send)
|
||||
// On initial sync, prefer stored JSON (preserves mentions, code blocks) over plain text.
|
||||
useEffect(() => {
|
||||
/* eslint-disable react-hooks/immutability -- initialSyncDoneRef is intentionally read+written as a one-shot gate */
|
||||
if (!editor) return;
|
||||
|
||||
if (!initialSyncDoneRef.current) {
|
||||
// Try to restore rich content from sessionStorage
|
||||
const sid = sessionId;
|
||||
if (sid) {
|
||||
const savedContent = getChatDraftContent(sid);
|
||||
if (savedContent) {
|
||||
isSyncingRef.current = true;
|
||||
editor.commands.setContent(savedContent as import('@tiptap/core').Content);
|
||||
isSyncingRef.current = false;
|
||||
initialSyncDoneRef.current = true;
|
||||
// Sync the plain text value back to parent
|
||||
onChangeRef.current(getMarkdownText(editor));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When value is empty, always clear — plain-text comparison may miss
|
||||
// rich nodes (mentions, code blocks) that have no text representation.
|
||||
if (value === '') {
|
||||
if (!editor.isEmpty) {
|
||||
isSyncingRef.current = true;
|
||||
editor.commands.clearContent();
|
||||
isSyncingRef.current = false;
|
||||
}
|
||||
initialSyncDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentText = getMarkdownText(editor);
|
||||
if (currentText === value) {
|
||||
initialSyncDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isSyncingRef.current = true;
|
||||
editor.commands.setContent(`<p>${escapeHtml(value)}</p>`);
|
||||
isSyncingRef.current = false;
|
||||
initialSyncDoneRef.current = true;
|
||||
/* eslint-enable react-hooks/immutability */
|
||||
}, [editor, value, sessionId]);
|
||||
|
||||
// ── Imperative handle ─────────────────────────────────────────
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
blur: () => editor?.commands.blur(),
|
||||
getSelectionStart: () => editor?.state.selection.from ?? 0,
|
||||
getValue: () => editor ? getMarkdownText(editor) : '',
|
||||
setValue: (v: string) => {
|
||||
if (!editor) return;
|
||||
isSyncingRef.current = true;
|
||||
if (v === '') {
|
||||
editor.commands.clearContent();
|
||||
} else {
|
||||
editor.commands.setContent(`<p>${escapeHtml(v)}</p>`);
|
||||
}
|
||||
isSyncingRef.current = false;
|
||||
onChange(v);
|
||||
},
|
||||
clear: () => {
|
||||
if (!editor) return;
|
||||
isSyncingRef.current = true;
|
||||
editor.commands.clearContent();
|
||||
isSyncingRef.current = false;
|
||||
onChange('');
|
||||
},
|
||||
getTextareaElement: () => editor?.view.dom ?? null,
|
||||
insertText: (text: string, from: number, to: number) => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().insertContentAt({ from, to }, text).run();
|
||||
},
|
||||
getMentions: () => {
|
||||
if (!editor) return [];
|
||||
const mentions: ContextFile[] = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'contextMention') {
|
||||
const { kind, path, label } = node.attrs;
|
||||
if (kind === 'file') mentions.push({ path, name: label, pinned: false });
|
||||
else if (kind === 'prompt') mentions.push({ path, name: label, pinned: false });
|
||||
// Skip plan — handled via context store toggle
|
||||
}
|
||||
});
|
||||
return mentions;
|
||||
},
|
||||
}), [editor, onChange]);
|
||||
|
||||
// ── Menu handlers ─────────────────────────────────────────────
|
||||
|
||||
const handleMentionSelect = useCallback((item: MentionItem) => {
|
||||
mentionMenu.command?.(item);
|
||||
}, [mentionMenu]);
|
||||
|
||||
const handleMentionClose = useCallback(() => {
|
||||
setMentionMenu({ isOpen: false, items: [], query: '', clientRect: null, command: null });
|
||||
}, []);
|
||||
|
||||
const handleSlashSelect = useCallback((cmd: SlashCommand) => {
|
||||
slashMenu.command?.(cmd);
|
||||
}, [slashMenu]);
|
||||
|
||||
const handleSlashClose = useCallback(() => {
|
||||
setSlashMenu({ isOpen: false, items: [], query: '', clientRect: null, command: null });
|
||||
}, []);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
<MentionMenu
|
||||
isOpen={mentionMenu.isOpen}
|
||||
isLoading={false}
|
||||
clientRect={mentionMenu.clientRect}
|
||||
items={mentionMenu.items}
|
||||
query={mentionMenu.query}
|
||||
selectedIndex={mentionSelectedIndex}
|
||||
onSelect={handleMentionSelect}
|
||||
onClose={handleMentionClose}
|
||||
setSelectedIndex={setMentionSelectedIndex}
|
||||
/>
|
||||
<SlashCommandMenu
|
||||
isOpen={slashMenu.isOpen}
|
||||
clientRect={slashMenu.clientRect}
|
||||
commands={slashMenu.items}
|
||||
selectedIndex={slashSelectedIndex}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={handleSlashClose}
|
||||
setSelectedIndex={setSlashSelectedIndex}
|
||||
/>
|
||||
<EditorContextProvider value={{ sessionId, taskId: taskId ?? null }}>
|
||||
<EditorContent editor={editor} className="h-full [&_.tiptap]:h-full [&_.tiptap]:outline-none" />
|
||||
</EditorContextProvider>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize TipTap editor content to markdown-like text.
|
||||
* Preserves inline `code`, ```code blocks```, and @mention labels.
|
||||
*/
|
||||
function getMarkdownText(editor: { getJSON: () => { content?: JSONNode[] } }): string {
|
||||
const doc = editor.getJSON();
|
||||
if (!doc.content) return '';
|
||||
return doc.content.map(serializeNode).join('\n');
|
||||
}
|
||||
|
||||
type JSONNode = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
marks?: Array<{ type: string }>;
|
||||
content?: JSONNode[];
|
||||
};
|
||||
|
||||
function serializeNode(node: JSONNode): string {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
return serializeInline(node.content ?? []);
|
||||
case 'codeBlock': {
|
||||
const lang = (node.attrs?.language as string) || '';
|
||||
const text = serializeInline(node.content ?? []);
|
||||
return '```' + lang + '\n' + text + '\n```';
|
||||
}
|
||||
case 'hardBreak':
|
||||
return '\n';
|
||||
default:
|
||||
// Unknown block — try to serialize children
|
||||
if (node.content) return node.content.map(serializeNode).join('\n');
|
||||
return node.text ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function serializeInline(nodes: JSONNode[]): string {
|
||||
return nodes.map((n) => {
|
||||
if (n.type === 'hardBreak') return '\n';
|
||||
if (n.type === 'contextMention') {
|
||||
return n.attrs?.label ? `@${n.attrs.label}` : '';
|
||||
}
|
||||
const text = n.text ?? '';
|
||||
if (n.marks?.some((m) => m.type === 'code')) {
|
||||
return '`' + text + '`';
|
||||
}
|
||||
return text;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
type FenceSegment = { type: 'text'; text: string } | { type: 'code'; text: string; language: string | null };
|
||||
|
||||
/** Parse text containing markdown ``` fences into text/code segments. */
|
||||
function parseCodeFences(text: string): FenceSegment[] {
|
||||
const lines = text.split('\n');
|
||||
const segments: FenceSegment[] = [];
|
||||
let currentType: 'text' | 'code' = 'text';
|
||||
let currentLines: string[] = [];
|
||||
let currentLang: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
if (currentType === 'text') {
|
||||
if (currentLines.length > 0) {
|
||||
segments.push({ type: 'text', text: currentLines.join('\n') });
|
||||
}
|
||||
// Extract language from opening fence (e.g. ```typescript)
|
||||
currentLang = line.trimStart().slice(3).trim() || null;
|
||||
currentLines = [];
|
||||
currentType = 'code';
|
||||
} else {
|
||||
segments.push({ type: 'code', text: currentLines.join('\n'), language: currentLang });
|
||||
currentLines = [];
|
||||
currentLang = null;
|
||||
currentType = 'text';
|
||||
}
|
||||
} else {
|
||||
currentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLines.length > 0) {
|
||||
if (currentType === 'code') {
|
||||
segments.push({ type: 'code', text: currentLines.join('\n'), language: currentLang });
|
||||
} else {
|
||||
segments.push({ type: 'text', text: currentLines.join('\n') });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { createElement, useCallback, useRef, useState } from 'react';
|
||||
import { mergeAttributes, Node } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps } from '@tiptap/react';
|
||||
import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion';
|
||||
import { IconFile, IconFolder, IconAt, IconListCheck } from '@tabler/icons-react';
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@kandev/ui/hover-card';
|
||||
import { isDirectory } from '@/lib/utils/file-path';
|
||||
import { usePanelActions } from '@/hooks/use-panel-actions';
|
||||
import { useEditorContext } from './editor-context';
|
||||
import { LazyFilePreview } from './context-items/lazy-file-preview';
|
||||
import { LazyPlanPreview } from './context-items/lazy-plan-preview';
|
||||
import { PromptPreviewFromStore } from './context-items/prompt-preview';
|
||||
|
||||
export type MentionKind = 'file' | 'prompt' | 'plan';
|
||||
|
||||
export type ContextMentionOptions = {
|
||||
/** Suggestion configs — one per trigger character (e.g. @ and /) */
|
||||
suggestions: Array<Partial<SuggestionOptions>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom mention node extension that renders as an inline chip.
|
||||
* Stores kind/path as attributes so we know what was mentioned.
|
||||
* Accepts multiple suggestion plugins (@ mentions and / commands).
|
||||
*/
|
||||
export const ContextMention = Node.create<ContextMentionOptions>({
|
||||
name: 'contextMention',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
selectable: false,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestions: [],
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: { default: null },
|
||||
label: { default: null },
|
||||
kind: { default: 'file' as MentionKind },
|
||||
path: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-context-mention]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(
|
||||
{ 'data-context-mention': '' },
|
||||
HTMLAttributes,
|
||||
),
|
||||
HTMLAttributes.label || HTMLAttributes.id || '',
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return `@${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionChipView);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return this.options.suggestions.map((suggestion) =>
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...suggestion,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function MentionChipView({ node }: ReactNodeViewProps) {
|
||||
const { id, label, kind, path } = node.attrs as { id: string; label: string; kind: MentionKind; path: string };
|
||||
const { sessionId, taskId } = useEditorContext();
|
||||
const { openFile, addPlan } = usePanelActions();
|
||||
const [hoverOpen, setHoverOpen] = useState(false);
|
||||
const suppressRef = useRef(false);
|
||||
|
||||
const clickable = kind === 'file' || kind === 'plan';
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
suppressRef.current = true;
|
||||
setHoverOpen(false);
|
||||
setTimeout(() => { suppressRef.current = false; }, 300);
|
||||
if (kind === 'file' && path) {
|
||||
openFile(path);
|
||||
} else if (kind === 'plan') {
|
||||
addPlan();
|
||||
}
|
||||
}, [kind, path, openFile, addPlan]);
|
||||
|
||||
const icon = getMentionIcon(kind, path);
|
||||
|
||||
const preview = useMentionPreview(kind, path, id, sessionId, taskId);
|
||||
|
||||
const chip = (
|
||||
<span
|
||||
contentEditable={false}
|
||||
className={`inline-flex items-center gap-1 bg-muted/70 rounded px-1.5 py-0.5 text-xs align-baseline ${clickable ? 'cursor-pointer hover:bg-muted' : 'cursor-default'}`}
|
||||
onClick={clickable ? handleClick : undefined}
|
||||
>
|
||||
{createElement(icon, { className: "h-3 w-3 shrink-0 text-muted-foreground" })}
|
||||
<span className="truncate max-w-[140px]">{label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!preview) {
|
||||
return <NodeViewWrapper as="span" className="inline">{chip}</NodeViewWrapper>;
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline">
|
||||
<HoverCard
|
||||
open={hoverOpen}
|
||||
onOpenChange={(next) => {
|
||||
if (next && suppressRef.current) return;
|
||||
setHoverOpen(next);
|
||||
}}
|
||||
openDelay={300}
|
||||
>
|
||||
<HoverCardTrigger asChild>{chip}</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="start" className="w-80 max-h-80 overflow-y-auto">
|
||||
{preview}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function useMentionPreview(
|
||||
kind: MentionKind,
|
||||
path: string,
|
||||
id: string,
|
||||
sessionId: string | null,
|
||||
taskId: string | null,
|
||||
): React.ReactNode {
|
||||
if (kind === 'file') {
|
||||
return <LazyFilePreview path={path} sessionId={sessionId} />;
|
||||
}
|
||||
|
||||
if (kind === 'prompt') {
|
||||
return <PromptPreviewFromStore promptId={id} />;
|
||||
}
|
||||
|
||||
if (kind === 'plan') {
|
||||
return <LazyPlanPreview taskId={taskId} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMentionIcon(kind: MentionKind, path?: string) {
|
||||
if (kind === 'prompt') return IconAt;
|
||||
if (kind === 'plan') return IconListCheck;
|
||||
if (path && isDirectory(path)) return IconFolder;
|
||||
return IconFile;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { PluginKey } from '@tiptap/pm/state';
|
||||
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion';
|
||||
import type { MentionItem } from '@/hooks/use-inline-mention';
|
||||
import type { SlashCommand } from '@/hooks/use-inline-slash';
|
||||
|
||||
import { getFileName } from '@/lib/utils/file-path';
|
||||
import type { MentionKind } from './tiptap-mention-extension';
|
||||
|
||||
// ── Shared types ────────────────────────────────────────────────────
|
||||
|
||||
export type MenuState<T> = {
|
||||
isOpen: boolean;
|
||||
items: T[];
|
||||
query: string;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
command: ((item: T) => void) | null;
|
||||
};
|
||||
|
||||
const EMPTY_MENTION_STATE: MenuState<MentionItem> = {
|
||||
isOpen: false,
|
||||
items: [],
|
||||
query: '',
|
||||
clientRect: null,
|
||||
command: null,
|
||||
};
|
||||
|
||||
const EMPTY_SLASH_STATE: MenuState<SlashCommand> = {
|
||||
isOpen: false,
|
||||
items: [],
|
||||
query: '',
|
||||
clientRect: null,
|
||||
command: null,
|
||||
};
|
||||
|
||||
// ── Mention suggestion ──────────────────────────────────────────────
|
||||
|
||||
export type MentionSuggestionCallbacks = {
|
||||
getItems: (query: string) => Promise<MentionItem[]>;
|
||||
};
|
||||
|
||||
export const MentionSuggestionPluginKey = new PluginKey('mentionSuggestion');
|
||||
|
||||
/**
|
||||
* Creates a mention suggestion config for TipTap.
|
||||
* `setMenuState` drives the React rendering of MentionMenu.
|
||||
* `onKeyDownRef` is a mutable ref that the parent uses to delegate keystrokes.
|
||||
*/
|
||||
export function createMentionSuggestion(
|
||||
callbacks: MentionSuggestionCallbacks,
|
||||
setMenuState: (state: MenuState<MentionItem>) => void,
|
||||
onKeyDownRef: React.MutableRefObject<((event: KeyboardEvent) => boolean) | null>,
|
||||
): Partial<SuggestionOptions<MentionItem, { id: string; label: string; kind: MentionKind; path: string }>> {
|
||||
return {
|
||||
char: '@',
|
||||
pluginKey: MentionSuggestionPluginKey,
|
||||
allowSpaces: false,
|
||||
|
||||
items: async ({ query }) => {
|
||||
return callbacks.getItems(query);
|
||||
},
|
||||
|
||||
command: ({ editor, range, props: mentionAttrs }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{ type: 'contextMention', attrs: mentionAttrs },
|
||||
{ type: 'text', text: ' ' },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
|
||||
render: () => {
|
||||
return {
|
||||
onStart(props: SuggestionProps<MentionItem>) {
|
||||
setMenuState({
|
||||
isOpen: true,
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
clientRect: props.clientRect ?? null,
|
||||
command: (item: MentionItem) => {
|
||||
props.command(mentionItemToAttrs(item));
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps<MentionItem>) {
|
||||
setMenuState({
|
||||
isOpen: true,
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
clientRect: props.clientRect ?? null,
|
||||
command: (item: MentionItem) => {
|
||||
props.command(mentionItemToAttrs(item));
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(kd: SuggestionKeyDownProps) {
|
||||
if (kd.event.key === 'Escape') {
|
||||
setMenuState(EMPTY_MENTION_STATE);
|
||||
return true;
|
||||
}
|
||||
return onKeyDownRef.current?.(kd.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
setMenuState(EMPTY_MENTION_STATE);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mentionItemToAttrs(item: MentionItem): { id: string; label: string; kind: MentionKind; path: string } {
|
||||
const name = item.kind === 'file'
|
||||
? getFileName(item.label)
|
||||
: item.label;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
label: name,
|
||||
kind: item.kind,
|
||||
path: item.kind === 'file' ? item.label : (item.kind === 'prompt' ? `prompt:${item.id}` : 'plan:context'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Slash command suggestion ────────────────────────────────────────
|
||||
|
||||
export type SlashSuggestionCallbacks = {
|
||||
getCommands: () => SlashCommand[];
|
||||
onAgentCommand: (commandName: string) => void;
|
||||
};
|
||||
|
||||
export const SlashSuggestionPluginKey = new PluginKey('slashSuggestion');
|
||||
|
||||
export function createSlashSuggestion(
|
||||
callbacks: SlashSuggestionCallbacks,
|
||||
setMenuState: (state: MenuState<SlashCommand>) => void,
|
||||
onKeyDownRef: React.MutableRefObject<((event: KeyboardEvent) => boolean) | null>,
|
||||
): Partial<SuggestionOptions<SlashCommand>> {
|
||||
return {
|
||||
char: '/',
|
||||
pluginKey: SlashSuggestionPluginKey,
|
||||
allowSpaces: false,
|
||||
|
||||
items: ({ query }) => {
|
||||
const allCommands = callbacks.getCommands();
|
||||
if (!query) return allCommands;
|
||||
|
||||
const lq = query.toLowerCase();
|
||||
return allCommands
|
||||
.filter((cmd) => {
|
||||
const name = cmd.agentCommandName?.toLowerCase();
|
||||
return name?.startsWith(lq) || cmd.label.toLowerCase().includes(lq);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const an = a.agentCommandName?.toLowerCase();
|
||||
const bn = b.agentCommandName?.toLowerCase();
|
||||
const aPre = an?.startsWith(lq) ?? false;
|
||||
const bPre = bn?.startsWith(lq) ?? false;
|
||||
if (aPre && !bPre) return -1;
|
||||
if (!aPre && bPre) return 1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
|
||||
command: ({ editor, range, props: cmd }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
if (cmd.agentCommandName) {
|
||||
callbacks.onAgentCommand(cmd.agentCommandName);
|
||||
}
|
||||
},
|
||||
|
||||
render: () => {
|
||||
return {
|
||||
onStart(props: SuggestionProps<SlashCommand>) {
|
||||
if (props.items.length === 0) return;
|
||||
setMenuState({
|
||||
isOpen: true,
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
clientRect: props.clientRect ?? null,
|
||||
command: (cmd: SlashCommand) => props.command(cmd),
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps<SlashCommand>) {
|
||||
if (props.items.length === 0) {
|
||||
setMenuState(EMPTY_SLASH_STATE);
|
||||
return;
|
||||
}
|
||||
setMenuState({
|
||||
isOpen: true,
|
||||
items: props.items,
|
||||
query: props.query,
|
||||
clientRect: props.clientRect ?? null,
|
||||
command: (cmd: SlashCommand) => props.command(cmd),
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(kd: SuggestionKeyDownProps) {
|
||||
if (kd.event.key === 'Escape') {
|
||||
setMenuState(EMPTY_SLASH_STATE);
|
||||
return true;
|
||||
}
|
||||
return onKeyDownRef.current?.(kd.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
setMenuState(EMPTY_SLASH_STATE);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,7 +40,11 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
|
||||
}: VirtualizedMessageListProps) {
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const wasAtBottomRef = useRef(true);
|
||||
const savedScrollTopRef = useRef(0);
|
||||
const previousHeightRef = useRef(0);
|
||||
const previousWidthRef = useRef(0);
|
||||
/** True while the panel is hidden (0 dimensions) to avoid stale scroll updates */
|
||||
const isHiddenRef = useRef(false);
|
||||
|
||||
const isInitialLoading = messagesLoading && messages.length === 0;
|
||||
const isCreatedSession = sessionState === 'CREATED';
|
||||
@@ -79,11 +83,15 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
|
||||
const element = messagesContainerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
// Ignore scroll events while panel is hidden (browser resets scrollTop to 0)
|
||||
if (isHiddenRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
|
||||
// Track if we're at the bottom for auto-scroll behavior (100px threshold)
|
||||
// User is considered "at bottom" if within 100px of the bottom
|
||||
wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
savedScrollTopRef.current = scrollTop;
|
||||
|
||||
// Trigger lazy load when scrolled near top
|
||||
if (scrollTop < 40 && loadingRef.current.hasMore && !loadingRef.current.isLoadingMore) {
|
||||
@@ -106,6 +114,10 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
|
||||
return () => element.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
// Keep a ref so the ResizeObserver closure always has the current virtualizer
|
||||
const virtualizerRef = useRef(virtualizer);
|
||||
virtualizerRef.current = virtualizer;
|
||||
|
||||
// Maintain scroll position at bottom when container resizes (e.g., chat input grows)
|
||||
useEffect(() => {
|
||||
const element = messagesContainerRef.current;
|
||||
@@ -114,7 +126,45 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const currentHeight = entry.contentRect.height;
|
||||
const currentWidth = entry.contentRect.width;
|
||||
const previousHeight = previousHeightRef.current;
|
||||
const previousWidth = previousWidthRef.current;
|
||||
|
||||
// Panel being hidden by dockview — mark as hidden, don't update refs
|
||||
if (currentWidth === 0 || currentHeight === 0) {
|
||||
isHiddenRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Panel becoming visible again — restore scroll position
|
||||
if (isHiddenRef.current) {
|
||||
isHiddenRef.current = false;
|
||||
previousHeightRef.current = currentHeight;
|
||||
previousWidthRef.current = currentWidth;
|
||||
|
||||
// Invalidate stale measurements from when the panel was display:none,
|
||||
// then use the virtualizer's scrollToIndex which persists across
|
||||
// re-measurement cycles (raw scrollTop gets overwritten by the
|
||||
// virtualizer as it re-measures items after becoming visible).
|
||||
const virt = virtualizerRef.current;
|
||||
virt.measure();
|
||||
requestAnimationFrame(() => {
|
||||
if (wasAtBottomRef.current) {
|
||||
const count = virt.options.count;
|
||||
if (count > 0) {
|
||||
virt.scrollToIndex(count - 1, { align: 'end' });
|
||||
}
|
||||
} else {
|
||||
element.scrollTop = savedScrollTopRef.current;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If width genuinely changed, invalidate cached row measurements (text rewraps)
|
||||
if (previousWidth > 0 && currentWidth !== previousWidth) {
|
||||
virtualizerRef.current.measure();
|
||||
}
|
||||
|
||||
// If container got smaller (input grew) and we were at bottom, scroll to bottom
|
||||
if (previousHeight > 0 && currentHeight < previousHeight && wasAtBottomRef.current) {
|
||||
@@ -126,6 +176,7 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
|
||||
}
|
||||
|
||||
previousHeightRef.current = currentHeight;
|
||||
previousWidthRef.current = currentWidth;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -686,6 +686,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
leftHeaderActionsComponent={LeftHeaderActions}
|
||||
rightHeaderActionsComponent={RightHeaderActions}
|
||||
onReady={onReady}
|
||||
defaultRenderer="always"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import type { IDockviewPanelProps } from 'dockview-react';
|
||||
import { PanelRoot, PanelBody } from './panel-primitives';
|
||||
import { FileEditorContent } from './file-editor-content';
|
||||
import { FileImageViewer } from './file-image-viewer';
|
||||
import { FileBinaryViewer } from './file-binary-viewer';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useDockviewStore } from '@/lib/state/dockview-store';
|
||||
import { useDockviewStore, type FileEditorState } from '@/lib/state/dockview-store';
|
||||
import { useFileEditors } from '@/hooks/use-file-editors';
|
||||
import { getFileCategory } from '@/lib/utils/file-types';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { requestFileContent } from '@/lib/ws/workspace-files';
|
||||
import { calculateHash } from '@/lib/utils/file-diff';
|
||||
|
||||
export const FileEditorPanel = memo(function FileEditorPanel(
|
||||
props: IDockviewPanelProps<{ path: string }>
|
||||
) {
|
||||
const path = props.params.path;
|
||||
const fileState = useDockviewStore((s) => s.openFiles.get(path));
|
||||
const setFileState = useDockviewStore((s) => s.setFileState);
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const activeSession = useAppStore((state) =>
|
||||
activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null
|
||||
);
|
||||
const { savingFiles, handleFileChange, saveFile, deleteFile } = useFileEditors();
|
||||
|
||||
// Self-load file content when state is missing (e.g. after hot reload clears the store)
|
||||
const loadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fileState || loadingRef.current || !activeSessionId) return;
|
||||
loadingRef.current = true;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) { loadingRef.current = false; return; }
|
||||
|
||||
requestFileContent(client, activeSessionId, path)
|
||||
.then(async (response) => {
|
||||
const hash = await calculateHash(response.content);
|
||||
const name = path.split('/').pop() || path;
|
||||
const state: FileEditorState = {
|
||||
path,
|
||||
name,
|
||||
content: response.content,
|
||||
originalContent: response.content,
|
||||
originalHash: hash,
|
||||
isDirty: false,
|
||||
isBinary: response.is_binary,
|
||||
};
|
||||
setFileState(path, state);
|
||||
})
|
||||
.catch(() => { /* will stay on loading state */ })
|
||||
.finally(() => { loadingRef.current = false; });
|
||||
}, [fileState, activeSessionId, path, setFileState]);
|
||||
|
||||
if (!fileState) {
|
||||
return (
|
||||
<PanelRoot>
|
||||
|
||||
@@ -11,11 +11,13 @@ import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
|
||||
import { SHORTCUTS } from '@/lib/keyboard/constants';
|
||||
import { useSessionMessages } from '@/hooks/domains/session/use-session-messages';
|
||||
import { useSettingsData } from '@/hooks/domains/settings/use-settings-data';
|
||||
import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
|
||||
import { useSessionState } from '@/hooks/domains/session/use-session-state';
|
||||
import { useProcessedMessages } from '@/hooks/use-processed-messages';
|
||||
import { useSessionModel } from '@/hooks/domains/session/use-session-model';
|
||||
import { useMessageHandler } from '@/hooks/use-message-handler';
|
||||
import { useQueue } from '@/hooks/domains/session/use-queue';
|
||||
import { useContextFilesStore } from '@/lib/state/context-files-store';
|
||||
import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list';
|
||||
import { ChatInputContainer, type ChatInputContainerHandle, type MessageAttachment } from '@/components/task/chat/chat-input-container';
|
||||
import { type QueuedMessageIndicatorHandle } from '@/components/task/chat/queued-message-indicator';
|
||||
@@ -24,10 +26,36 @@ import {
|
||||
useDiffCommentsStore,
|
||||
usePendingCommentsByFile,
|
||||
} from '@/lib/state/slices/diff-comments';
|
||||
import { getFileName } from '@/lib/utils/file-path';
|
||||
import type { ContextItem } from '@/lib/types/context';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import type { DocumentComment } from '@/lib/state/slices/ui/types';
|
||||
|
||||
const EMPTY_COMMENTS: DocumentComment[] = [];
|
||||
const EMPTY_CONTEXT_FILES: import('@/lib/state/context-files-store').ContextFile[] = [];
|
||||
|
||||
/** Sort: pinned first, then by kind order, then by label */
|
||||
const KIND_ORDER: Record<string, number> = {
|
||||
plan: 0,
|
||||
file: 1,
|
||||
prompt: 2,
|
||||
comment: 3,
|
||||
'plan-comment': 4,
|
||||
image: 5,
|
||||
};
|
||||
|
||||
function contextItemSortFn(a: ContextItem, b: ContextItem): number {
|
||||
// Pinned first
|
||||
const aPinned = a.pinned ? 0 : 1;
|
||||
const bPinned = b.pinned ? 0 : 1;
|
||||
if (aPinned !== bPinned) return aPinned - bPinned;
|
||||
// Then by kind
|
||||
const aKind = KIND_ORDER[a.kind] ?? 99;
|
||||
const bKind = KIND_ORDER[b.kind] ?? 99;
|
||||
if (aKind !== bKind) return aKind - bKind;
|
||||
// Then by label
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
type TaskChatPanelProps = {
|
||||
onSend?: (message: string) => void;
|
||||
@@ -55,9 +83,12 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
const chatInputRef = useRef<ChatInputContainerHandle>(null);
|
||||
const queuedMessageRef = useRef<QueuedMessageIndicatorHandle>(null);
|
||||
|
||||
// Ensure agent profile data is loaded (may not be hydrated from SSR in all navigation paths)
|
||||
// Ensure agent profile data is loaded
|
||||
useSettingsData(true);
|
||||
|
||||
// Custom prompts for context
|
||||
const { prompts } = useCustomPrompts();
|
||||
|
||||
// Session state management
|
||||
const {
|
||||
resolvedSessionId,
|
||||
@@ -71,7 +102,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
isFailed,
|
||||
} = useSessionState(sessionId);
|
||||
|
||||
// Plan mode state - derived from active document being a plan in document panel
|
||||
// Plan mode state
|
||||
const activeDocument = useAppStore((state) =>
|
||||
resolvedSessionId ? state.documentPanel.activeDocumentBySessionId[resolvedSessionId] ?? null : null
|
||||
);
|
||||
@@ -88,6 +119,21 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
|
||||
const { addPlan } = usePanelActions();
|
||||
|
||||
// Context files store
|
||||
const contextFiles = useContextFilesStore((s) =>
|
||||
resolvedSessionId ? s.filesBySessionId[resolvedSessionId] ?? EMPTY_CONTEXT_FILES : EMPTY_CONTEXT_FILES
|
||||
);
|
||||
const hydrateContextFiles = useContextFilesStore((s) => s.hydrateSession);
|
||||
const addContextFile = useContextFilesStore((s) => s.addFile);
|
||||
const toggleContextFile = useContextFilesStore((s) => s.toggleFile);
|
||||
const removeContextFile = useContextFilesStore((s) => s.removeFile);
|
||||
const unpinFile = useContextFilesStore((s) => s.unpinFile);
|
||||
const clearEphemeral = useContextFilesStore((s) => s.clearEphemeral);
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedSessionId) hydrateContextFiles(resolvedSessionId);
|
||||
}, [resolvedSessionId, hydrateContextFiles]);
|
||||
|
||||
const handlePlanModeChange = useCallback(
|
||||
(enabled: boolean) => {
|
||||
if (!resolvedSessionId || !taskId) return;
|
||||
@@ -95,29 +141,45 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
setActiveDocument(resolvedSessionId, { type: 'plan', taskId });
|
||||
addPlan();
|
||||
setPlanMode(resolvedSessionId, true);
|
||||
addContextFile(resolvedSessionId, { path: 'plan:context', name: 'Plan', pinned: true });
|
||||
} else {
|
||||
closeDocument(resolvedSessionId);
|
||||
setActiveDocument(resolvedSessionId, null);
|
||||
setPlanMode(resolvedSessionId, false);
|
||||
}
|
||||
},
|
||||
[resolvedSessionId, taskId, setActiveDocument, addPlan, closeDocument, setPlanMode]
|
||||
[resolvedSessionId, taskId, setActiveDocument, addPlan, closeDocument, setPlanMode, addContextFile]
|
||||
);
|
||||
|
||||
// Initialize plan mode from localStorage on mount (restore document panel state)
|
||||
// Initialize plan mode from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (resolvedSessionId) {
|
||||
const stored = getLocalStorage(`plan-mode-${resolvedSessionId}`, false);
|
||||
if (stored) {
|
||||
setPlanMode(resolvedSessionId, stored);
|
||||
setPlanMode(resolvedSessionId, true);
|
||||
addContextFile(resolvedSessionId, { path: 'plan:context', name: 'Plan', pinned: true });
|
||||
}
|
||||
}
|
||||
}, [resolvedSessionId, setPlanMode]);
|
||||
}, [resolvedSessionId, setPlanMode, addContextFile]);
|
||||
|
||||
const handleToggleContextFile = useCallback(
|
||||
(file: { path: string; name: string; pinned?: boolean }) => {
|
||||
if (resolvedSessionId) toggleContextFile(resolvedSessionId, file);
|
||||
},
|
||||
[resolvedSessionId, toggleContextFile]
|
||||
);
|
||||
|
||||
const handleAddContextFile = useCallback(
|
||||
(file: { path: string; name: string; pinned?: boolean }) => {
|
||||
if (resolvedSessionId) addContextFile(resolvedSessionId, file);
|
||||
},
|
||||
[resolvedSessionId, addContextFile]
|
||||
);
|
||||
|
||||
// Fetch messages for this session
|
||||
const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId);
|
||||
|
||||
// Process messages (filtering, todos, etc.)
|
||||
// Process messages
|
||||
const { allMessages, groupedItems, permissionsByToolCallId, childrenByParentToolCallId, todoItems, agentMessageCount, pendingClarification } = useProcessedMessages(
|
||||
messages,
|
||||
taskId,
|
||||
@@ -125,18 +187,9 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
taskDescription
|
||||
);
|
||||
|
||||
// Extract user message history for up/down arrow navigation
|
||||
const userMessageHistory = useMemo(() => {
|
||||
return allMessages
|
||||
.filter(msg => msg.author_type === 'user')
|
||||
.map(msg => msg.content)
|
||||
.filter(content => content && content.trim().length > 0);
|
||||
}, [allMessages]);
|
||||
|
||||
// Track clarification resolved state to trigger re-render after submit
|
||||
// Clarification state
|
||||
const [clarificationKey, setClarificationKey] = useState(0);
|
||||
const handleClarificationResolved = useCallback(() => {
|
||||
// Force re-render which will update messages from backend
|
||||
setClarificationKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
@@ -168,24 +221,26 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
}
|
||||
}, [resolvedSessionId, setDocumentComments]);
|
||||
|
||||
// Message sending
|
||||
const { handleSendMessage } = useMessageHandler(
|
||||
resolvedSessionId,
|
||||
taskId,
|
||||
sessionModel,
|
||||
activeModel,
|
||||
planModeEnabled,
|
||||
isAgentBusy,
|
||||
activeDocument,
|
||||
documentComments
|
||||
// Derive plan context from context files store
|
||||
const planContextEnabled = useMemo(
|
||||
() => contextFiles.some((f) => f.path === 'plan:context'),
|
||||
[contextFiles]
|
||||
);
|
||||
|
||||
// Build prompts map for content lookup
|
||||
const promptsMap = useMemo(() => {
|
||||
const map = new Map<string, { content: string }>();
|
||||
for (const p of prompts) {
|
||||
map.set(p.id, { content: p.content });
|
||||
}
|
||||
return map;
|
||||
}, [prompts]);
|
||||
|
||||
// Diff comments management
|
||||
const pendingCommentsByFile = usePendingCommentsByFile();
|
||||
const markCommentsSent = useDiffCommentsStore((state) => state.markCommentsSent);
|
||||
const removeComment = useDiffCommentsStore((state) => state.removeComment);
|
||||
|
||||
// Handle removing all comments for a file
|
||||
const handleRemoveCommentFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!resolvedSessionId) return;
|
||||
@@ -197,21 +252,131 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
[resolvedSessionId, pendingCommentsByFile, removeComment]
|
||||
);
|
||||
|
||||
// Handle removing a single comment
|
||||
const handleRemoveComment = useCallback(
|
||||
(sessionId: string, filePath: string, commentId: string) => {
|
||||
removeComment(sessionId, filePath, commentId);
|
||||
(sid: string, filePath: string, commentId: string) => {
|
||||
removeComment(sid, filePath, commentId);
|
||||
},
|
||||
[removeComment]
|
||||
);
|
||||
|
||||
// Build unified context items
|
||||
const contextItems = useMemo((): ContextItem[] => {
|
||||
const items: ContextItem[] = [];
|
||||
const sid = resolvedSessionId;
|
||||
|
||||
// Plan
|
||||
if (planContextEnabled) {
|
||||
const planFile = contextFiles.find(f => f.path === 'plan:context');
|
||||
items.push({
|
||||
kind: 'plan',
|
||||
id: 'plan:context',
|
||||
label: 'Plan',
|
||||
taskId: taskId ?? undefined,
|
||||
pinned: planFile?.pinned,
|
||||
onRemove: sid ? () => removeContextFile(sid, 'plan:context') : undefined,
|
||||
onUnpin: planFile?.pinned && sid ? () => unpinFile(sid, 'plan:context') : undefined,
|
||||
onOpen: addPlan,
|
||||
});
|
||||
}
|
||||
|
||||
// Files & Prompts from context store
|
||||
for (const f of contextFiles) {
|
||||
if (f.path === 'plan:context') continue;
|
||||
if (f.path.startsWith('prompt:')) {
|
||||
const promptId = f.path.replace('prompt:', '');
|
||||
const prompt = promptsMap.get(promptId);
|
||||
items.push({
|
||||
kind: 'prompt',
|
||||
id: f.path,
|
||||
label: f.name,
|
||||
pinned: f.pinned,
|
||||
onRemove: sid ? () => removeContextFile(sid, f.path) : undefined,
|
||||
onUnpin: f.pinned && sid ? () => unpinFile(sid, f.path) : undefined,
|
||||
promptContent: prompt?.content,
|
||||
onClick: () => {/* navigate to settings/prompts if desired */},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
kind: 'file',
|
||||
id: f.path,
|
||||
label: f.name,
|
||||
path: f.path,
|
||||
pinned: f.pinned,
|
||||
onRemove: sid ? () => removeContextFile(sid, f.path) : undefined,
|
||||
onUnpin: f.pinned && sid ? () => unpinFile(sid, f.path) : undefined,
|
||||
onOpen: onOpenFile ?? (() => {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Diff/editor comments (per file)
|
||||
if (pendingCommentsByFile) {
|
||||
for (const [filePath, comments] of Object.entries(pendingCommentsByFile)) {
|
||||
if (comments.length === 0) continue;
|
||||
const fileName = getFileName(filePath);
|
||||
items.push({
|
||||
kind: 'comment',
|
||||
id: `comment:${filePath}`,
|
||||
label: `${fileName} (${comments.length})`,
|
||||
filePath,
|
||||
comments,
|
||||
onRemove: () => handleRemoveCommentFile(filePath),
|
||||
onRemoveComment: (cid) => { if (sid) handleRemoveComment(sid, filePath, cid); },
|
||||
onOpen: onOpenFileAtLine ? () => onOpenFileAtLine(filePath) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Plan comments
|
||||
if (documentComments.length > 0) {
|
||||
items.push({
|
||||
kind: 'plan-comment',
|
||||
id: 'plan-comments',
|
||||
label: `${documentComments.length} plan comment${documentComments.length !== 1 ? 's' : ''}`,
|
||||
comments: documentComments,
|
||||
onRemove: handleClearDocumentComments,
|
||||
onOpen: addPlan,
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort(contextItemSortFn);
|
||||
}, [
|
||||
planContextEnabled,
|
||||
contextFiles,
|
||||
resolvedSessionId,
|
||||
removeContextFile,
|
||||
unpinFile,
|
||||
addPlan,
|
||||
promptsMap,
|
||||
onOpenFile,
|
||||
pendingCommentsByFile,
|
||||
handleRemoveCommentFile,
|
||||
handleRemoveComment,
|
||||
onOpenFileAtLine,
|
||||
documentComments,
|
||||
handleClearDocumentComments,
|
||||
taskId,
|
||||
]);
|
||||
|
||||
// Message sending
|
||||
const { handleSendMessage } = useMessageHandler(
|
||||
resolvedSessionId,
|
||||
taskId,
|
||||
sessionModel,
|
||||
activeModel,
|
||||
planContextEnabled,
|
||||
isAgentBusy,
|
||||
activeDocument,
|
||||
documentComments,
|
||||
contextFiles,
|
||||
prompts
|
||||
);
|
||||
|
||||
// Clear awaiting state when a new agent message arrives
|
||||
useEffect(() => {
|
||||
lastAgentMessageCountRef.current = agentMessageCount;
|
||||
}, [agentMessageCount]);
|
||||
|
||||
// Cancels the current agent turn without terminating the agent process,
|
||||
// allowing the user to interrupt and send a new prompt.
|
||||
const handleCancelTurn = useCallback(async () => {
|
||||
if (!resolvedSessionId) return;
|
||||
const client = getWebSocketClient();
|
||||
@@ -224,7 +389,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
}
|
||||
}, [resolvedSessionId]);
|
||||
|
||||
// Handle canceling queued message
|
||||
const handleCancelQueue = useCallback(async () => {
|
||||
try {
|
||||
await cancelQueue();
|
||||
@@ -233,21 +397,14 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
}
|
||||
}, [cancelQueue]);
|
||||
|
||||
// Handle starting edit mode on queued message (from keyboard navigation)
|
||||
const handleStartQueueEdit = useCallback(() => {
|
||||
queuedMessageRef.current?.startEdit();
|
||||
}, []);
|
||||
|
||||
// Handle edit complete - return focus to chat input
|
||||
const handleQueueEditComplete = useCallback(() => {
|
||||
chatInputRef.current?.focusInput();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[]) => {
|
||||
const handleSubmit = useCallback(async (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[], inlineMentions?: import('@/lib/state/context-files-store').ContextFile[]) => {
|
||||
if (isSending) return;
|
||||
setIsSending(true);
|
||||
try {
|
||||
// Build final message with review comments
|
||||
let finalMessage = message;
|
||||
if (reviewComments && reviewComments.length > 0) {
|
||||
const reviewMarkdown = formatReviewCommentsAsMarkdown(reviewComments);
|
||||
@@ -259,42 +416,43 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
if (onSend) {
|
||||
await onSend(finalMessage);
|
||||
} else {
|
||||
await handleSendMessage(finalMessage, attachments, hasReviewComments);
|
||||
await handleSendMessage(finalMessage, attachments, hasReviewComments, inlineMentions);
|
||||
}
|
||||
|
||||
// Mark comments as sent and clear pending
|
||||
// Mark comments as sent
|
||||
if (reviewComments && reviewComments.length > 0) {
|
||||
markCommentsSent(reviewComments.map((c) => c.id));
|
||||
}
|
||||
|
||||
// Clear plan/document comments after sending (they were included via useMessageHandler)
|
||||
// Clear plan/document comments after sending
|
||||
if (documentComments.length > 0) {
|
||||
handleClearDocumentComments();
|
||||
}
|
||||
|
||||
// Clear ephemeral context items after send
|
||||
if (resolvedSessionId) {
|
||||
clearEphemeral(resolvedSessionId);
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [isSending, onSend, handleSendMessage, markCommentsSent, documentComments.length, handleClearDocumentComments]);
|
||||
}, [isSending, onSend, handleSendMessage, markCommentsSent, documentComments.length, handleClearDocumentComments, resolvedSessionId, clearEphemeral]);
|
||||
|
||||
// Focus input with / shortcut (only when input is NOT focused)
|
||||
// Focus input with / shortcut
|
||||
useKeyboardShortcut(
|
||||
SHORTCUTS.FOCUS_INPUT,
|
||||
useCallback((event: KeyboardEvent) => {
|
||||
// Only handle if we're not already in an input/textarea
|
||||
const activeElement = document.activeElement;
|
||||
const isTyping =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement;
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
(activeElement instanceof HTMLElement && activeElement.isContentEditable);
|
||||
|
||||
if (isTyping) {
|
||||
// Don't intercept - let the "/" be typed normally for slash commands
|
||||
return;
|
||||
}
|
||||
if (isTyping) return;
|
||||
|
||||
// Not typing anywhere, so focus the chat input
|
||||
const inputHandle = chatInputRef.current;
|
||||
if (inputHandle) {
|
||||
event.preventDefault(); // Prevent the "/" from being typed when we focus
|
||||
event.preventDefault();
|
||||
inputHandle.focusInput();
|
||||
}
|
||||
}, []),
|
||||
@@ -350,19 +508,16 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
showRequestChangesTooltip={showRequestChangesTooltip}
|
||||
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
|
||||
pendingCommentsByFile={pendingCommentsByFile}
|
||||
onRemoveCommentFile={handleRemoveCommentFile}
|
||||
onRemoveComment={handleRemoveComment}
|
||||
onCommentClick={onOpenFileAtLine ? (comment) => onOpenFileAtLine(comment.filePath) : undefined}
|
||||
submitKey={chatSubmitKey}
|
||||
hasAgentCommands={hasAgentCommands}
|
||||
isFailed={isFailed}
|
||||
isQueued={isQueued}
|
||||
onStartQueueEdit={handleStartQueueEdit}
|
||||
userMessageHistory={userMessageHistory}
|
||||
documentCommentCount={documentComments.length}
|
||||
onClearDocumentComments={handleClearDocumentComments}
|
||||
contextItems={contextItems}
|
||||
planContextEnabled={planContextEnabled}
|
||||
contextFiles={contextFiles}
|
||||
onToggleContextFile={handleToggleContextFile}
|
||||
onAddContextFile={handleAddContextFile}
|
||||
todoItems={todoItems}
|
||||
activeDocument={activeDocument}
|
||||
queuedMessage={queuedMessage?.content}
|
||||
onCancelQueue={handleCancelQueue}
|
||||
updateQueueContent={updateQueueContent}
|
||||
|
||||
Reference in New Issue
Block a user