feat: better context files (#222)

feat: context files
This commit is contained in:
Carlos Florêncio
2026-02-14 20:13:11 +00:00
committed by GitHub
parent ee612a42c5
commit f4d1dfd3a7
49 changed files with 3809 additions and 801 deletions
@@ -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} &times; {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">
&ldquo;{comment.selectedText}&rdquo;
</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');
+14 -21
View 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">
+17 -4
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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>
+34 -2
View File
@@ -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>
+218 -63
View File
@@ -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}