feat: improved chat input (#120)
This commit is contained in:
@@ -15,6 +15,7 @@ interface SessionPanelProps {
|
||||
interface SessionPanelContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function SessionPanel({
|
||||
@@ -44,16 +45,18 @@ export function SessionPanel({
|
||||
}
|
||||
|
||||
export const SessionPanelContent = forwardRef<HTMLDivElement, SessionPanelContentProps>(
|
||||
function SessionPanelContent({ children, className }, ref) {
|
||||
function SessionPanelContent({ children, className, wrapperClassName }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex-1 min-h-0 overflow-y-auto rounded-lg bg-background h-full p-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<div className={cn('flex-1 min-h-0 rounded-lg bg-background h-full overflow-hidden', wrapperClassName)}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-full overflow-y-auto overflow-x-hidden p-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,10 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.overflow-wrap-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.kanban-task-pulse {
|
||||
animation: kanban-task-pulse 5s ease-in-out infinite;
|
||||
}
|
||||
@@ -199,8 +203,66 @@
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Thin scrollbar for menus */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid Spinner - 3x3 cube animation */
|
||||
@keyframes spinner-grid {
|
||||
0%, 70%, 100% {
|
||||
transform: scale3d(0.5, 0.5, 1);
|
||||
}
|
||||
35% {
|
||||
transform: scale3d(0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-grid {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.15em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.spinner-grid-cube {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: currentColor;
|
||||
animation: spinner-grid 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.spinner-grid-cube:nth-child(1) { animation-delay: 0.2s; }
|
||||
.spinner-grid-cube:nth-child(2) { animation-delay: 0.3s; }
|
||||
.spinner-grid-cube:nth-child(3) { animation-delay: 0.4s; }
|
||||
.spinner-grid-cube:nth-child(4) { animation-delay: 0.1s; }
|
||||
.spinner-grid-cube:nth-child(5) { animation-delay: 0.2s; }
|
||||
.spinner-grid-cube:nth-child(6) { animation-delay: 0.3s; }
|
||||
.spinner-grid-cube:nth-child(7) { animation-delay: 0s; }
|
||||
.spinner-grid-cube:nth-child(8) { animation-delay: 0.1s; }
|
||||
.spinner-grid-cube:nth-child(9) { animation-delay: 0.2s; }
|
||||
|
||||
@keyframes kanban-task-pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -211,6 +273,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes border-flash-glow {
|
||||
0%, 100% {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 20%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--primary) 60%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.border-flash-animation {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.border-flash-animation::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
animation: border-flash-glow 3s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip {
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--popover, oklch(0.205 0 0)) !important;
|
||||
|
||||
@@ -89,7 +89,6 @@ export function BoardCard({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [board.id, initialWorkflowSteps, isNewBoard, toast]);
|
||||
|
||||
const refreshWorkflowSteps = async () => {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
IconListCheck,
|
||||
IconLoader2,
|
||||
IconPlayerStopFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
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 { RichTextInput, type RichTextInputHandle } from './rich-text-input';
|
||||
import { MentionMenu } from './mention-menu';
|
||||
import { SlashCommandMenu } from './slash-command-menu';
|
||||
import { useInlineMention } from '@/hooks/use-inline-mention';
|
||||
import { useInlineSlash } from '@/hooks/use-inline-slash';
|
||||
|
||||
type ChatInputContainerProps = {
|
||||
onSubmit: (message: string) => void;
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription: string;
|
||||
planModeEnabled: boolean;
|
||||
onPlanModeChange: (enabled: boolean) => void;
|
||||
isAgentBusy: boolean;
|
||||
isStarting: boolean;
|
||||
isSending: boolean;
|
||||
onCancel: () => void;
|
||||
showApproveButton?: boolean;
|
||||
onApprove?: () => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function ChatInputContainer({
|
||||
onSubmit,
|
||||
sessionId,
|
||||
taskId,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
planModeEnabled,
|
||||
onPlanModeChange,
|
||||
isAgentBusy,
|
||||
isStarting,
|
||||
isSending,
|
||||
onCancel,
|
||||
showApproveButton,
|
||||
onApprove,
|
||||
placeholder,
|
||||
}: ChatInputContainerProps) {
|
||||
// Keep input state local to avoid re-rendering parent on every keystroke
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<RichTextInputHandle>(null);
|
||||
|
||||
// Mention menu hook
|
||||
const mention = useInlineMention(inputRef, value, setValue, sessionId);
|
||||
|
||||
// Slash command menu hook
|
||||
const slash = useInlineSlash(inputRef, value, setValue, onPlanModeChange);
|
||||
|
||||
// Combined change handler
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
mention.handleChange(newValue);
|
||||
slash.handleChange(newValue);
|
||||
},
|
||||
[mention, slash]
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
[mention, slash]
|
||||
);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Don't submit if a menu is open
|
||||
if (mention.isOpen || slash.isOpen) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
onSubmit(trimmed);
|
||||
setValue('');
|
||||
}, [mention.isOpen, slash.isOpen, value, onSubmit]);
|
||||
|
||||
const isDisabled = isStarting || isSending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-border bg-background shadow-md overflow-hidden',
|
||||
planModeEnabled && 'border-primary border-dashed'
|
||||
)}
|
||||
>
|
||||
{/* 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}
|
||||
/>
|
||||
|
||||
{/* Input area */}
|
||||
<RichTextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
disabled={isDisabled}
|
||||
planModeEnabled={planModeEnabled}
|
||||
/>
|
||||
|
||||
{/* Integrated toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-2 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)}
|
||||
>
|
||||
<IconListCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Thinking level dropdown */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40"
|
||||
>
|
||||
<IconBrain className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Thinking level</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" side="top">
|
||||
<DropdownMenuItem>High</DropdownMenuItem>
|
||||
<DropdownMenuItem>Medium</DropdownMenuItem>
|
||||
<DropdownMenuItem>Low</DropdownMenuItem>
|
||||
<DropdownMenuItem>Off</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</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} />
|
||||
|
||||
{/* Approve button */}
|
||||
{showApproveButton && onApprove && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 cursor-pointer gap-1.5 px-3 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/25 hover:border-emerald-500/50"
|
||||
onClick={onApprove}
|
||||
>
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Approve</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approve and move to next step</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Submit/Stop button */}
|
||||
<div className="ml-1">
|
||||
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT} enabled={!isAgentBusy && !isStarting}>
|
||||
{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"
|
||||
disabled={isDisabled}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<IconPlayerStopFilled 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={handleSubmit}
|
||||
>
|
||||
{isSending ? (
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<IconArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</KeyboardShortcutTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { IconBrain, IconCheck, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
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';
|
||||
|
||||
type ChatToolbarProps = {
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription: string;
|
||||
planModeEnabled: boolean;
|
||||
onPlanModeChange: (enabled: boolean) => void;
|
||||
isAgentBusy: boolean;
|
||||
isStarting: boolean;
|
||||
isSending: boolean;
|
||||
onCancel: () => void;
|
||||
showApproveButton?: boolean;
|
||||
onApprove?: () => void;
|
||||
};
|
||||
|
||||
export function ChatToolbar({
|
||||
sessionId,
|
||||
taskId,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
planModeEnabled,
|
||||
onPlanModeChange,
|
||||
isAgentBusy,
|
||||
isStarting,
|
||||
isSending,
|
||||
onCancel,
|
||||
showApproveButton,
|
||||
onApprove,
|
||||
}: ChatToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector sessionId={sessionId} />
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" size="icon" className="h-7 w-7 cursor-pointer">
|
||||
<IconBrain className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Thinking level</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" side="top">
|
||||
<DropdownMenuItem>High</DropdownMenuItem>
|
||||
<DropdownMenuItem>Medium</DropdownMenuItem>
|
||||
<DropdownMenuItem>Low</DropdownMenuItem>
|
||||
<DropdownMenuItem>Off</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-7 w-7 cursor-pointer',
|
||||
planModeEnabled &&
|
||||
'bg-primary/15 text-primary border-primary/40 shadow-[0_0_0_1px_rgba(59,130,246,0.35)]'
|
||||
)}
|
||||
onClick={() => onPlanModeChange(!planModeEnabled)}
|
||||
>
|
||||
<IconListCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
{planModeEnabled && <span className="text-xs font-medium text-primary">Plan mode active</span>}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Context window usage indicator */}
|
||||
<TokenUsageDisplay sessionId={sessionId} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SessionsDropdown
|
||||
taskId={taskId}
|
||||
activeSessionId={sessionId}
|
||||
taskTitle={taskTitle}
|
||||
taskDescription={taskDescription}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="outline" size="icon" className="h-7 w-7 cursor-pointer">
|
||||
<IconPaperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add attachments</TooltipContent>
|
||||
</Tooltip>
|
||||
{showApproveButton && onApprove && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 border-green-300 text-green-700 hover:bg-green-50 hover:text-green-800"
|
||||
onClick={onApprove}
|
||||
>
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approve and move to next step</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT} enabled={!isAgentBusy && !isStarting}>
|
||||
<Button
|
||||
type={isAgentBusy ? 'button' : 'submit'}
|
||||
variant={isAgentBusy ? 'destructive' : 'default'}
|
||||
className={cn('h-7', isAgentBusy && 'gap-2')}
|
||||
disabled={isStarting || isSending}
|
||||
onClick={isAgentBusy ? onCancel : undefined}
|
||||
>
|
||||
{isAgentBusy ? (
|
||||
<>
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
'Submit'
|
||||
)}
|
||||
</Button>
|
||||
</KeyboardShortcutTooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconAt, IconFile, IconFolder } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MentionItem } from '@/hooks/use-inline-mention';
|
||||
|
||||
type MentionMenuProps = {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
position: { x: number; y: number } | null;
|
||||
items: MentionItem[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
onClose: () => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
};
|
||||
|
||||
const MENU_HEIGHT = 280;
|
||||
const MENU_WIDTH = 320;
|
||||
|
||||
// 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('.');
|
||||
}
|
||||
|
||||
export function MentionMenu({
|
||||
isOpen,
|
||||
isLoading,
|
||||
position,
|
||||
items,
|
||||
query,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onClose,
|
||||
setSelectedIndex,
|
||||
}: MentionMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedItem = itemRefs.current.get(selectedIndex);
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate position (above cursor)
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: Math.max(8, Math.min(position.x, window.innerWidth - MENU_WIDTH - 8)),
|
||||
bottom: window.innerHeight - position.y + 8,
|
||||
width: MENU_WIDTH,
|
||||
maxHeight: MENU_HEIGHT,
|
||||
zIndex: 50,
|
||||
};
|
||||
|
||||
// Group items by type
|
||||
const prompts = items.filter((item) => item.type === 'prompt');
|
||||
const files = items.filter((item) => item.type === 'file');
|
||||
|
||||
// Calculate indices for sections
|
||||
let currentIndex = 0;
|
||||
|
||||
const menu = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={menuStyle}
|
||||
className="overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 text-xs"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-2 py-1.5 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">Mention files, prompts</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto py-1 scrollbar-thin" style={{ maxHeight: MENU_HEIGHT - 36 }}>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-1 text-center text-xs text-muted-foreground">
|
||||
{isLoading ? 'Loading...' : query ? 'No results found' : 'Type to search...'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{prompts.length > 0 && (
|
||||
<div>
|
||||
{prompts.map((item) => {
|
||||
const index = currentIndex++;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
}}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-[13px] text-left',
|
||||
'hover:bg-muted/50',
|
||||
selectedIndex === index && 'bg-muted/50'
|
||||
)}
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
onClick={() => onSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<IconAt className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex items-baseline gap-2 min-w-0 flex-1">
|
||||
<span className="shrink-0">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="text-[11px] text-muted-foreground truncate">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
{files.map((item) => {
|
||||
const index = currentIndex++;
|
||||
const { name, parent } = parseFilePath(item.label);
|
||||
const isDir = isDirectory(item.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
}}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-xs text-left',
|
||||
'hover:bg-muted/50',
|
||||
selectedIndex === index && 'bg-muted/50'
|
||||
)}
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
onClick={() => onSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{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 items-baseline gap-2 min-w-0 flex-1">
|
||||
<span className="shrink-0">{name}</span>
|
||||
{parent && (
|
||||
<span className="text-[11px] text-muted-foreground truncate">{parent}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render via portal to escape any overflow containers
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(menu, document.body);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { IconAlertCircle, IconAlertTriangle, IconCopy, IconCheck } from '@tabler/icons-react';
|
||||
import type { Message, TaskSessionState } from '@/lib/types/http';
|
||||
import { useSessionTurn } from '@/hooks/domains/session/use-session-turn';
|
||||
|
||||
type AgentStatusProps = {
|
||||
sessionState?: TaskSessionState;
|
||||
sessionId: string | null;
|
||||
messages?: Message[];
|
||||
};
|
||||
|
||||
const STATE_CONFIG: Record<TaskSessionState, { label: string; icon: 'spinner' | 'error' | 'warning' | null }> = {
|
||||
CREATED: { label: 'Agent is being created', icon: 'spinner' },
|
||||
STARTING: { label: 'Agent is starting', icon: 'spinner' },
|
||||
RUNNING: { label: 'Agent is running', icon: 'spinner' },
|
||||
WAITING_FOR_INPUT: { label: '', icon: null },
|
||||
COMPLETED: { label: '', icon: null },
|
||||
FAILED: { label: 'Agent has encountered an error', icon: 'error' },
|
||||
CANCELLED: { label: 'Agent has been cancelled', icon: 'warning' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Format duration in seconds to a human-readable string.
|
||||
*/
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 0) return '0s';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate turn duration from messages as a fallback when turn data is not available.
|
||||
* Finds the last user message and the last agent message to estimate duration.
|
||||
*/
|
||||
function calculateTurnDurationFromMessages(messages: Message[]): string | null {
|
||||
if (messages.length < 2) return null;
|
||||
|
||||
// Find last agent message
|
||||
let lastAgentMsg: Message | null = null;
|
||||
let lastUserMsgBeforeAgent: Message | null = null;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (!lastAgentMsg && msg.author_type === 'agent') {
|
||||
lastAgentMsg = msg;
|
||||
} else if (lastAgentMsg && msg.author_type === 'user') {
|
||||
lastUserMsgBeforeAgent = msg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAgentMsg || !lastUserMsgBeforeAgent) return null;
|
||||
|
||||
const startTime = new Date(lastUserMsgBeforeAgent.created_at).getTime();
|
||||
const endTime = new Date(lastAgentMsg.created_at).getTime();
|
||||
const durationSeconds = Math.floor((endTime - startTime) / 1000);
|
||||
|
||||
if (durationSeconds < 0) return null;
|
||||
return formatDuration(durationSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track elapsed time while agent is running.
|
||||
* Starts timer when isRunning becomes true, resets when it becomes false.
|
||||
*/
|
||||
function useRunningTimer(isRunning: boolean) {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunning) {
|
||||
// Reset refs when not running (state reset handled below)
|
||||
startTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timer
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
if (startTimeRef.current) {
|
||||
setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning]);
|
||||
|
||||
// Reset elapsed seconds when not running (outside effect to avoid lint warning)
|
||||
const displaySeconds = isRunning ? elapsedSeconds : 0;
|
||||
|
||||
// Format as Xs or XmXs
|
||||
const formatted = useMemo(() => {
|
||||
return formatDuration(displaySeconds);
|
||||
}, [displaySeconds]);
|
||||
|
||||
return { elapsedSeconds: displaySeconds, formatted };
|
||||
}
|
||||
|
||||
function GridSpinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={`spinner-grid ${className ?? ''}`} role="status" aria-label="Loading">
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
<span className="spinner-grid-cube" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ content }: { content: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-muted/50 text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
title="Copy last response"
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<IconCopy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentStatusProps) {
|
||||
const { lastTurnDuration, isActive: isTurnActive } = useSessionTurn(sessionId);
|
||||
|
||||
const config = sessionState ? STATE_CONFIG[sessionState] : null;
|
||||
const isRunning = config?.icon === 'spinner';
|
||||
|
||||
// Use local timer that starts when agent is running
|
||||
const { formatted: runningDuration, elapsedSeconds } = useRunningTimer(isRunning);
|
||||
const isError = config?.icon === 'error';
|
||||
const isWarning = config?.icon === 'warning';
|
||||
|
||||
// Get last agent message content for copy button
|
||||
const lastAgentContent = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].author_type === 'agent' && messages[i].content) {
|
||||
return messages[i].content;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
// Calculate duration from messages as fallback
|
||||
const fallbackDuration = useMemo(() => {
|
||||
if (lastTurnDuration) return null; // Don't need fallback
|
||||
return calculateTurnDurationFromMessages(messages);
|
||||
}, [messages, lastTurnDuration]);
|
||||
|
||||
const displayDuration = lastTurnDuration?.formatted ?? fallbackDuration;
|
||||
|
||||
// Error state
|
||||
if (isError && config) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-destructive/10 text-destructive border border-destructive/20"
|
||||
role="status"
|
||||
aria-label={config.label}
|
||||
>
|
||||
<IconAlertCircle className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="font-medium">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Warning state
|
||||
if (isWarning && config) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border border-yellow-500/20"
|
||||
role="status"
|
||||
aria-label={config.label}
|
||||
>
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="font-medium">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Running state
|
||||
if (isRunning && config) {
|
||||
return (
|
||||
<div className="py-2" role="status" aria-label={config.label}>
|
||||
<span className="inline-flex items-center gap-2 text-xs">
|
||||
<GridSpinner className="text-primary" />
|
||||
<span className="text-muted-foreground">{config.label}</span>
|
||||
{elapsedSeconds > 0 && (
|
||||
<span className="text-muted-foreground/60 tabular-nums">
|
||||
{runningDuration}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completed/idle state - show duration and copy button if available
|
||||
const showCompletedState = !isTurnActive && (displayDuration || lastAgentContent);
|
||||
if (showCompletedState) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{displayDuration && <span className="tabular-nums">{displayDuration}</span>}
|
||||
{lastAgentContent && <CopyButton content={lastAgentContent} />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -85,10 +85,10 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM
|
||||
// User message: right-aligned bubble
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end w-full">
|
||||
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl">
|
||||
<div className="rounded-2xl border-primary/30 bg-primary/10 px-4 py-2.5 text-xs">
|
||||
<p className="whitespace-pre-wrap">
|
||||
<div className="flex justify-end w-full overflow-hidden">
|
||||
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl overflow-hidden">
|
||||
<div className="rounded-2xl border-primary/30 bg-primary/10 px-4 py-2.5 text-xs overflow-hidden">
|
||||
<p className="whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
{comment.content ? renderContentWithFileRefs(comment.content) : '(empty)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { IconAlertCircle, IconAlertTriangle, IconClock } from '@tabler/icons-react';
|
||||
import type { TaskSessionState } from '@/lib/types/http';
|
||||
import { useActiveTurnTimer } from '@/hooks/use-active-turn-timer';
|
||||
|
||||
type RunningIndicatorProps = {
|
||||
state: TaskSessionState;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
const STATE_CONFIG: Record<TaskSessionState, { label: string; icon: 'spinner' | 'error' | 'warning' | null }> = {
|
||||
CREATED: { label: 'Agent is being created', icon: 'spinner' },
|
||||
STARTING: { label: 'Agent is starting', icon: 'spinner' },
|
||||
RUNNING: { label: 'Agent is running', icon: 'spinner' },
|
||||
WAITING_FOR_INPUT: { label: 'Agent is waiting for input', icon: null },
|
||||
COMPLETED: { label: '', icon: null },
|
||||
FAILED: { label: 'Agent has encountered an error', icon: 'error' },
|
||||
CANCELLED: { label: 'Agent has been cancelled', icon: 'warning' },
|
||||
};
|
||||
|
||||
export function RunningIndicator({ state, sessionId }: RunningIndicatorProps) {
|
||||
const config = STATE_CONFIG[state];
|
||||
const { isActive, formattedDuration } = useActiveTurnTimer(sessionId ?? null);
|
||||
|
||||
// Don't show indicator for completed state or waiting for input
|
||||
if (!config.icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isError = config.icon === 'error';
|
||||
const isWarning = config.icon === 'warning';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-xs ${
|
||||
isError
|
||||
? 'bg-destructive/10 text-destructive border border-destructive/20'
|
||||
: isWarning
|
||||
? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 border border-yellow-500/20'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
role="status"
|
||||
aria-label={config.label}
|
||||
>
|
||||
{config.icon === 'spinner' && (
|
||||
<>
|
||||
<span className="uppercase tracking-wide font-medium">{config.label}</span>
|
||||
<span className="flex items-center gap-1 ml-1">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-current animate-bounce"
|
||||
style={{ animationDelay: '0ms', animationDuration: '1s' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-current animate-bounce"
|
||||
style={{ animationDelay: '150ms', animationDuration: '1s' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-current animate-bounce"
|
||||
style={{ animationDelay: '300ms', animationDuration: '1s' }}
|
||||
/>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="flex items-center gap-1 ml-2 pl-2 border-l border-current/20">
|
||||
<IconClock className="h-3 w-3" aria-hidden="true" />
|
||||
<span className="font-mono tabular-nums">{formattedDuration}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{config.icon === 'error' && (
|
||||
<>
|
||||
<IconAlertCircle className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="uppercase tracking-wide font-medium">{config.label}</span>
|
||||
</>
|
||||
)}
|
||||
{config.icon === 'warning' && (
|
||||
<>
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="uppercase tracking-wide font-medium">{config.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { IconChevronRight, IconClock, IconCpu } from '@tabler/icons-react';
|
||||
import { useSessionTurn } from '@/hooks/domains/session/use-session-turn';
|
||||
|
||||
type TurnSummaryProps = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A compact display of the last completed turn's duration and model.
|
||||
* Only shows when there's a completed turn and no active turn running.
|
||||
*/
|
||||
export function TurnSummary({ sessionId }: TurnSummaryProps) {
|
||||
const { lastCompletedTurn, lastTurnDuration, isActive, sessionModel } = useSessionTurn(sessionId);
|
||||
|
||||
// Don't show if there's an active turn running or no completed turn
|
||||
if (isActive || !lastCompletedTurn || !lastTurnDuration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2 mt-2 pl-3">
|
||||
<IconChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||
<div className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<IconClock className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{lastTurnDuration.formatted}</span>
|
||||
</div>
|
||||
{sessionModel && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<div className="flex items-center gap-1.5 text-xs text-sky-600 dark:text-sky-400">
|
||||
<IconCpu className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{sessionModel}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useRef,
|
||||
useImperativeHandle,
|
||||
type KeyboardEvent,
|
||||
type ChangeEvent,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type RichTextInputHandle = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getSelectionStart: () => number;
|
||||
getSelectionEnd: () => number;
|
||||
setSelectionRange: (start: number, end: number) => void;
|
||||
getCaretRect: () => DOMRect | null;
|
||||
getValue: () => string;
|
||||
setValue: (value: string) => void;
|
||||
insertText: (text: string, from: number, to: number) => void;
|
||||
};
|
||||
|
||||
type RichTextInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSubmit?: () => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
planModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>(
|
||||
function RichTextInput(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className,
|
||||
planModeEnabled = false,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Calculate new height (clamped to min/max)
|
||||
const minHeight = 88;
|
||||
const maxHeight = Math.min(window.innerHeight * 0.4, 300);
|
||||
const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
||||
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
const getCaretRect = useCallback((): DOMRect | null => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return null;
|
||||
|
||||
const selectionStart = textarea.selectionStart;
|
||||
|
||||
// Create a mirror div to measure caret position
|
||||
const mirror = document.createElement('div');
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
|
||||
// Copy styles that affect text layout
|
||||
const stylesToCopy = [
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'fontWeight',
|
||||
'fontStyle',
|
||||
'letterSpacing',
|
||||
'textTransform',
|
||||
'wordSpacing',
|
||||
'textIndent',
|
||||
'whiteSpace',
|
||||
'wordWrap',
|
||||
'wordBreak',
|
||||
'overflowWrap',
|
||||
'lineHeight',
|
||||
'padding',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'borderWidth',
|
||||
'boxSizing',
|
||||
] as const;
|
||||
|
||||
mirror.style.position = 'absolute';
|
||||
mirror.style.visibility = 'hidden';
|
||||
mirror.style.whiteSpace = 'pre-wrap';
|
||||
mirror.style.wordWrap = 'break-word';
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
|
||||
stylesToCopy.forEach((prop) => {
|
||||
mirror.style[prop as unknown as number] = computed[prop];
|
||||
});
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
|
||||
// Get text before cursor and add a span for measuring
|
||||
const textBeforeCursor = value.substring(0, selectionStart);
|
||||
mirror.textContent = textBeforeCursor;
|
||||
|
||||
// Add a marker span at the cursor position
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = '\u200B'; // Zero-width space
|
||||
mirror.appendChild(marker);
|
||||
|
||||
// Get textarea position and marker position
|
||||
const textareaRect = textarea.getBoundingClientRect();
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const mirrorRect = mirror.getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to viewport
|
||||
const relativeTop = markerRect.top - mirrorRect.top;
|
||||
const relativeLeft = markerRect.left - mirrorRect.left;
|
||||
|
||||
// Account for scroll position in textarea
|
||||
const scrollTop = textarea.scrollTop;
|
||||
|
||||
document.body.removeChild(mirror);
|
||||
|
||||
return new DOMRect(
|
||||
textareaRect.left + relativeLeft,
|
||||
textareaRect.top + relativeTop - scrollTop,
|
||||
0,
|
||||
parseInt(computed.lineHeight, 10) || parseInt(computed.fontSize, 10) * 1.2
|
||||
);
|
||||
}, [value]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
blur: () => textareaRef.current?.blur(),
|
||||
getSelectionStart: () => textareaRef.current?.selectionStart ?? 0,
|
||||
getSelectionEnd: () => textareaRef.current?.selectionEnd ?? 0,
|
||||
setSelectionRange: (start: number, end: number) => {
|
||||
textareaRef.current?.setSelectionRange(start, end);
|
||||
},
|
||||
getCaretRect,
|
||||
getValue: () => textareaRef.current?.value ?? '',
|
||||
setValue: (newValue: string) => onChange(newValue),
|
||||
insertText: (text: string, from: number, to: number) => {
|
||||
const newValue = value.substring(0, from) + text + value.substring(to);
|
||||
onChange(newValue);
|
||||
// Set cursor position after the inserted text
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(from + text.length, from + text.length);
|
||||
});
|
||||
},
|
||||
}),
|
||||
[getCaretRect, onChange, value]
|
||||
);
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(event.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Let parent handle key events first (for menu navigation)
|
||||
if (onKeyDown) {
|
||||
onKeyDown(event);
|
||||
if (event.defaultPrevented) return;
|
||||
}
|
||||
|
||||
// Submit only on Cmd/Ctrl+Enter
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
onSubmit?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full resize-none bg-transparent px-3 py-3',
|
||||
'text-sm leading-relaxed',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
planModeEnabled && 'border-primary/40',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
minHeight: '88px',
|
||||
maxHeight: 'min(40vh, 300px)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconCommand } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SlashCommand } from '@/hooks/use-inline-slash';
|
||||
|
||||
type SlashCommandMenuProps = {
|
||||
isOpen: boolean;
|
||||
position: { x: number; y: number } | null;
|
||||
commands: SlashCommand[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
onClose: () => void;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
};
|
||||
|
||||
const MENU_HEIGHT = 280;
|
||||
const MENU_WIDTH = 320;
|
||||
|
||||
export function SlashCommandMenu({
|
||||
isOpen,
|
||||
position,
|
||||
commands,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onClose,
|
||||
setSelectedIndex,
|
||||
}: SlashCommandMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedItem = itemRefs.current.get(selectedIndex);
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !position || commands.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate position (above cursor)
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: Math.max(8, Math.min(position.x - 16, window.innerWidth - MENU_WIDTH - 8)),
|
||||
bottom: window.innerHeight - position.y + 8,
|
||||
width: MENU_WIDTH,
|
||||
maxHeight: MENU_HEIGHT,
|
||||
zIndex: 50,
|
||||
};
|
||||
|
||||
const menu = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={menuStyle}
|
||||
className="overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-2 py-1.5 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">Commands</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto py-1 scrollbar-thin" style={{ maxHeight: MENU_HEIGHT - 36 }}>
|
||||
{commands.map((command, index) => (
|
||||
<button
|
||||
key={command.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
}}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-[13px] text-left',
|
||||
'hover:bg-muted/50',
|
||||
selectedIndex === index && 'bg-muted/50'
|
||||
)}
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
onClick={() => onSelect(command)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<IconCommand className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{command.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{command.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render via portal to escape any overflow containers
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(menu, document.body);
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IconLoader2 } from '@tabler/icons-react';
|
||||
import { SessionPanelContent } from '@kandev/ui/pannel-session';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import type { Message, TaskSessionState } from '@/lib/types/http';
|
||||
import { MessageRenderer } from '@/components/task/chat/message-renderer';
|
||||
import { TurnSummary } from '@/components/task/chat/messages/turn-summary';
|
||||
import { AgentStatus } from '@/components/task/chat/messages/agent-status';
|
||||
import { useLazyLoadMessages } from '@/hooks/use-lazy-load-messages';
|
||||
|
||||
type VirtualizedMessageListProps = {
|
||||
@@ -16,6 +16,7 @@ type VirtualizedMessageListProps = {
|
||||
sessionId: string | null;
|
||||
messagesLoading: boolean;
|
||||
isWorking: boolean;
|
||||
sessionState?: TaskSessionState;
|
||||
};
|
||||
|
||||
export function VirtualizedMessageList({
|
||||
@@ -25,6 +26,7 @@ export function VirtualizedMessageList({
|
||||
sessionId,
|
||||
messagesLoading,
|
||||
isWorking,
|
||||
sessionState,
|
||||
}: VirtualizedMessageListProps) {
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const wasAtBottomRef = useRef(true);
|
||||
@@ -72,6 +74,18 @@ export function VirtualizedMessageList({
|
||||
}
|
||||
}, [itemCount, lastMessageContent, virtualizer]);
|
||||
|
||||
// Scroll to bottom when agent starts running (running indicator appears)
|
||||
const isRunning = sessionState === 'CREATED' || sessionState === 'STARTING' || sessionState === 'RUNNING';
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
const element = messagesContainerRef.current;
|
||||
if (!element) return;
|
||||
// Scroll to absolute bottom to show the running indicator
|
||||
requestAnimationFrame(() => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
}, [isRunning]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
// Lazy load older messages when scrolling to top
|
||||
@@ -98,10 +112,11 @@ export function VirtualizedMessageList({
|
||||
}, [virtualItems, hasMore, isLoadingMore, loadMore]);
|
||||
|
||||
return (
|
||||
<SessionPanelContent
|
||||
ref={messagesContainerRef}
|
||||
className="relative p-4"
|
||||
>
|
||||
<SessionPanelContent
|
||||
ref={messagesContainerRef}
|
||||
className="relative p-4"
|
||||
wrapperClassName={isRunning ? 'border-flash-animation' : undefined}
|
||||
>
|
||||
{isLoadingMore && hasMore && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 text-xs text-muted-foreground">
|
||||
Loading older messages...
|
||||
@@ -146,8 +161,12 @@ export function VirtualizedMessageList({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Turn summary - shows duration and model after last message */}
|
||||
<TurnSummary sessionId={sessionId} />
|
||||
{/* Agent status - running indicator or completed turn summary */}
|
||||
<AgentStatus
|
||||
sessionState={sessionState}
|
||||
sessionId={sessionId}
|
||||
messages={messages}
|
||||
/>
|
||||
</SessionPanelContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { IconCpu } from '@tabler/icons-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents';
|
||||
import { useSettingsData } from '@/hooks/domains/settings/use-settings-data';
|
||||
@@ -82,30 +88,46 @@ export function ModelSelector({ sessionId }: ModelSelectorProps) {
|
||||
// If no session or no model available, show disabled placeholder
|
||||
if (!sessionId || !currentModel) {
|
||||
return (
|
||||
<Select disabled>
|
||||
<SelectTrigger className="w-[180px] cursor-not-allowed opacity-50">
|
||||
<SelectValue placeholder="No model" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 cursor-not-allowed opacity-50 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
<span className="text-xs">No model</span>
|
||||
<IconChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Get display name from modelOptions (uses backend name)
|
||||
const currentModelOption = modelOptions.find((m) => m.id === currentModel);
|
||||
const displayName = currentModelOption?.name || currentModel;
|
||||
|
||||
return (
|
||||
<Select value={currentModel} onValueChange={handleModelChange}>
|
||||
<SelectTrigger className="w-[180px] cursor-pointer">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<IconCpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select model" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 cursor-pointer hover:bg-muted/40 whitespace-nowrap"
|
||||
>
|
||||
<span className="text-xs">{displayName}</span>
|
||||
<IconChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
{modelOptions.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
className={model.id === currentModel ? 'bg-accent' : ''}
|
||||
>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ export function SessionsDropdown({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 border border-border"
|
||||
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40"
|
||||
>
|
||||
<IconStack2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-xs font-normal">
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||
import { autocompletion, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SHORTCUTS } from '@/lib/keyboard/constants';
|
||||
import { matchesShortcut, shortcutToCodeMirrorKeybinding } from '@/lib/keyboard/utils';
|
||||
import { chatEditorTheme } from '@/lib/editor/chat-editor-theme';
|
||||
import { useChatCompletions } from '@/hooks/use-chat-completions';
|
||||
|
||||
type TaskChatInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
planModeEnabled?: boolean;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export function TaskChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
planModeEnabled,
|
||||
sessionId,
|
||||
}: TaskChatInputProps) {
|
||||
const submitKey = shortcutToCodeMirrorKeybinding(SHORTCUTS.SUBMIT);
|
||||
const completionSource = useChatCompletions(sessionId);
|
||||
|
||||
// Memoize the submit handler - include onSubmit in deps since it's now stable from parent
|
||||
const handleSubmitKey = useCallback(() => {
|
||||
onSubmit();
|
||||
return true;
|
||||
}, [onSubmit]);
|
||||
|
||||
const handleKeydown = useCallback((event: KeyboardEvent) => {
|
||||
if (matchesShortcut(event, SHORTCUTS.SUBMIT)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSubmit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [onSubmit]);
|
||||
|
||||
// Memoize extensions to prevent CodeMirror reconfiguration on every render
|
||||
// Only recreate when dependencies actually change
|
||||
const extensions = useMemo<Extension[]>(() => {
|
||||
const submitKeymap = Prec.highest(
|
||||
keymap.of([
|
||||
{ key: submitKey, run: handleSubmitKey, preventDefault: true },
|
||||
{ key: 'Mod-Enter', run: handleSubmitKey, preventDefault: true },
|
||||
])
|
||||
);
|
||||
|
||||
return [
|
||||
EditorView.lineWrapping,
|
||||
chatEditorTheme,
|
||||
EditorView.domEventHandlers({
|
||||
keydown: handleKeydown,
|
||||
}),
|
||||
history(),
|
||||
closeBrackets(),
|
||||
markdown(),
|
||||
cmPlaceholder(placeholder ?? ''),
|
||||
autocompletion({ override: [completionSource], aboveCursor: true }),
|
||||
submitKeymap,
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
|
||||
];
|
||||
}, [placeholder, completionSource, submitKey, handleSubmitKey, handleKeydown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'task-chat-editor rounded-md border border-input bg-muted/40 focus-within:border-ring focus-within:ring-[2px] focus-within:ring-ring/30 overflow-hidden',
|
||||
planModeEnabled &&
|
||||
'border-dashed border-primary/60 !bg-primary/10 shadow-[inset_0_0_0_1px_rgba(59,130,246,0.35)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
editable={!disabled}
|
||||
minHeight="90px"
|
||||
maxHeight="200px"
|
||||
extensions={extensions}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: false,
|
||||
}}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useSessionMessages } from '@/hooks/domains/session/use-session-messages';
|
||||
@@ -10,11 +9,9 @@ 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 { TaskChatInput } from '@/components/task/task-chat-input';
|
||||
import { RunningIndicator } from '@/components/task/chat/messages/running-indicator';
|
||||
import { TodoSummary } from '@/components/task/chat/todo-summary';
|
||||
import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list';
|
||||
import { ChatToolbar } from '@/components/task/chat/chat-toolbar';
|
||||
import { ChatInputContainer } from '@/components/task/chat/chat-input-container';
|
||||
import { approveSessionAction } from '@/app/actions/workspaces';
|
||||
|
||||
type TaskChatPanelProps = {
|
||||
@@ -26,7 +23,6 @@ export function TaskChatPanel({
|
||||
onSend,
|
||||
sessionId = null,
|
||||
}: TaskChatPanelProps) {
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [planModeEnabled, setPlanModeEnabled] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const lastAgentMessageCountRef = useRef(0);
|
||||
@@ -135,19 +131,14 @@ export function TaskChatPanel({
|
||||
const showApproveButton =
|
||||
session?.review_status != null && session.review_status !== 'approved' && !isWorking;
|
||||
|
||||
const handleSubmit = useCallback(async (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
const trimmed = messageInput.trim();
|
||||
if (!trimmed || isSending) return;
|
||||
const handleSubmit = useCallback(async (message: string) => {
|
||||
if (isSending) return;
|
||||
setIsSending(true);
|
||||
flushSync(() => {
|
||||
setMessageInput('');
|
||||
});
|
||||
try {
|
||||
if (onSend) {
|
||||
await onSend(trimmed);
|
||||
await onSend(message);
|
||||
} else {
|
||||
await handleSendMessage(trimmed);
|
||||
await handleSendMessage(message);
|
||||
}
|
||||
// Reset plan mode after sending - it's a per-message instruction
|
||||
if (planModeEnabled) {
|
||||
@@ -156,7 +147,7 @@ export function TaskChatPanel({
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [messageInput, isSending, planModeEnabled, onSend, handleSendMessage]);
|
||||
}, [isSending, planModeEnabled, onSend, handleSendMessage]);
|
||||
|
||||
|
||||
return (
|
||||
@@ -168,30 +159,13 @@ export function TaskChatPanel({
|
||||
sessionId={resolvedSessionId}
|
||||
messagesLoading={messagesLoading}
|
||||
isWorking={isWorking}
|
||||
sessionState={session?.state}
|
||||
/>
|
||||
|
||||
{/* Session info - shows agent state */}
|
||||
{session?.state && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<RunningIndicator state={session.state} sessionId={resolvedSessionId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
{todoItems.length > 0 && <TodoSummary todos={todoItems} />}
|
||||
<TaskChatInput
|
||||
value={messageInput}
|
||||
onChange={setMessageInput}
|
||||
<ChatInputContainer
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={
|
||||
agentMessageCount > 0
|
||||
? 'Continue working on this task...'
|
||||
: 'Write to submit work to the agent...'
|
||||
}
|
||||
planModeEnabled={planModeEnabled}
|
||||
sessionId={resolvedSessionId}
|
||||
/>
|
||||
<ChatToolbar
|
||||
sessionId={resolvedSessionId}
|
||||
taskId={taskId}
|
||||
taskTitle={task?.title}
|
||||
@@ -204,8 +178,13 @@ export function TaskChatPanel({
|
||||
onCancel={handleCancelTurn}
|
||||
showApproveButton={showApproveButton}
|
||||
onApprove={handleApprove}
|
||||
placeholder={
|
||||
agentMessageCount > 0
|
||||
? 'Continue working on this task...'
|
||||
: 'Write to submit work to the agent...'
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useMemo, useRef, useCallback } from 'react';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
|
||||
import type { CustomPrompt } from '@/lib/types/http';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { searchWorkspaceFiles } from '@/lib/ws/workspace-files';
|
||||
|
||||
const truncatePreview = (text: string, max = 140) => {
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max)}…`;
|
||||
};
|
||||
|
||||
// Debounce delay for file search (ms)
|
||||
const FILE_SEARCH_DEBOUNCE = 300;
|
||||
|
||||
export function useChatCompletions(sessionId?: string | null) {
|
||||
const { prompts } = useCustomPrompts();
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSearchRef = useRef<{ query: string; results: string[] }>({ query: '', results: [] });
|
||||
|
||||
const promptOptions = useMemo(() => {
|
||||
return prompts.map((prompt: CustomPrompt) => ({
|
||||
id: prompt.id,
|
||||
name: prompt.name,
|
||||
content: prompt.content,
|
||||
}));
|
||||
}, [prompts]);
|
||||
|
||||
// File search function with caching
|
||||
const searchFiles = useCallback(async (query: string): Promise<string[]> => {
|
||||
if (!sessionId || !query) return [];
|
||||
|
||||
// Return cached results if query matches
|
||||
if (lastSearchRef.current.query === query) {
|
||||
return lastSearchRef.current.results;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return [];
|
||||
|
||||
const response = await searchWorkspaceFiles(client, sessionId, query, 20);
|
||||
const results = response.files || [];
|
||||
|
||||
// Cache results
|
||||
lastSearchRef.current = { query, results };
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const completionSource = useMemo(() => {
|
||||
return async (context: CompletionContext): Promise<CompletionResult | null> => {
|
||||
// Slash command completions
|
||||
const slashMatch = context.matchBefore(/(^|\s)\/[\w-]*/);
|
||||
if (slashMatch) {
|
||||
const from = slashMatch.from + (slashMatch.text.startsWith('/') ? 0 : 1);
|
||||
return {
|
||||
from,
|
||||
options: [
|
||||
{ label: '/plan', type: 'keyword', info: 'Start a planning response' },
|
||||
{ label: '/todo', type: 'keyword', info: 'Add a todo list' },
|
||||
{ label: '/review', type: 'keyword', info: 'Request a review' },
|
||||
{ label: '/summarize', type: 'keyword', info: 'Summarize the task' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// @ mention completions (prompts and files)
|
||||
const mentionMatch = context.matchBefore(/(^|\s)@[\w.\/\-_]*/);
|
||||
if (mentionMatch) {
|
||||
const from = mentionMatch.from + (mentionMatch.text.startsWith('@') ? 0 : 1);
|
||||
const trimmed = mentionMatch.text.trim();
|
||||
const query = trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase();
|
||||
|
||||
// Prompt completions (existing behavior)
|
||||
const promptMatches = promptOptions
|
||||
.filter((prompt: { id: string; name: string; content: string }) =>
|
||||
prompt.name.toLowerCase().startsWith(query)
|
||||
)
|
||||
.map((prompt: { id: string; name: string; content: string }) => ({
|
||||
label: `@${prompt.name}`,
|
||||
type: 'text',
|
||||
detail: 'Prompt',
|
||||
info: truncatePreview(prompt.content),
|
||||
boost: 10, // Prompts have higher priority
|
||||
apply: (view: EditorView, _completion: unknown, applyFrom: number, applyTo: number) => {
|
||||
const insertText = `${prompt.content}\n`;
|
||||
const cursorPos = applyFrom + insertText.length;
|
||||
view.dispatch({
|
||||
changes: { from: applyFrom, to: applyTo, insert: insertText },
|
||||
selection: { anchor: cursorPos },
|
||||
});
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(cursorPos) });
|
||||
},
|
||||
}));
|
||||
|
||||
// File completions (only if sessionId is available and query has content)
|
||||
let fileMatches: Array<{
|
||||
label: string;
|
||||
type: string;
|
||||
detail: string;
|
||||
info: string;
|
||||
boost: number;
|
||||
}> = [];
|
||||
|
||||
if (sessionId && query.length > 0) {
|
||||
// Cancel previous search timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounced file search
|
||||
const files = await new Promise<string[]>((resolve) => {
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
const results = await searchFiles(query);
|
||||
resolve(results);
|
||||
}, FILE_SEARCH_DEBOUNCE);
|
||||
});
|
||||
|
||||
fileMatches = files.map((filePath) => ({
|
||||
label: `@${filePath}`,
|
||||
type: 'variable',
|
||||
detail: 'File',
|
||||
info: filePath,
|
||||
boost: 5, // Files have lower priority than prompts
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
from,
|
||||
options: [...promptMatches, ...fileMatches],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}, [promptOptions, sessionId, searchFiles]);
|
||||
|
||||
return completionSource;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { searchWorkspaceFiles } from '@/lib/ws/workspace-files';
|
||||
import type { RichTextInputHandle } from '@/components/task/chat/rich-text-input';
|
||||
|
||||
export type MentionItem = {
|
||||
id: string;
|
||||
type: 'prompt' | 'file';
|
||||
label: string;
|
||||
description?: string;
|
||||
content?: string; // For prompts, the full content to insert
|
||||
};
|
||||
|
||||
type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
// Debounce delay for file search (ms)
|
||||
const FILE_SEARCH_DEBOUNCE = 300;
|
||||
|
||||
function isValidMentionTrigger(text: string, pos: number): boolean {
|
||||
if (pos === 0) return true;
|
||||
const charBefore = text[pos - 1];
|
||||
return charBefore === ' ' || charBefore === '\n' || charBefore === '\t';
|
||||
}
|
||||
|
||||
function filterItems(items: MentionItem[], query: string): MentionItem[] {
|
||||
if (!query) return items;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const label = item.label.toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
// Exact prefix match (highest)
|
||||
if (label.startsWith(lowerQuery)) {
|
||||
score = 100;
|
||||
}
|
||||
// Word boundary match
|
||||
else if (label.split(/[\s\-_/]/).some((word) => word.startsWith(lowerQuery))) {
|
||||
score = 50;
|
||||
}
|
||||
// Contains match
|
||||
else if (label.includes(lowerQuery)) {
|
||||
score = 25;
|
||||
}
|
||||
|
||||
return { item, score };
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
export function useInlineMention(
|
||||
inputRef: React.RefObject<RichTextInputHandle | null>,
|
||||
value: string,
|
||||
onChange: (value: string) => void,
|
||||
sessionId?: string | null
|
||||
) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
const [triggerStart, setTriggerStart] = useState<number>(-1);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [fileResults, setFileResults] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSearchRef = useRef<{ query: string; results: string[] }>({
|
||||
query: '',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const { prompts } = useCustomPrompts();
|
||||
|
||||
// File search function with caching
|
||||
const searchFiles = useCallback(
|
||||
async (searchQuery: string): Promise<string[]> => {
|
||||
if (!sessionId) return [];
|
||||
|
||||
// Use special key for empty query
|
||||
const cacheKey = searchQuery || '__empty__';
|
||||
|
||||
// Return cached results if query matches
|
||||
if (lastSearchRef.current.query === cacheKey) {
|
||||
return lastSearchRef.current.results;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return [];
|
||||
|
||||
// For empty query, search with wildcard or empty to get initial files
|
||||
const response = await searchWorkspaceFiles(client, sessionId, searchQuery || '', 20);
|
||||
const results = response.files || [];
|
||||
|
||||
// Cache results
|
||||
lastSearchRef.current = { query: cacheKey, results };
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('File search failed:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
// Debounced file search - search immediately when menu opens, debounce subsequent queries
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- loading state sync is intentional for UX */
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Search immediately for empty query (menu just opened), debounce for typed queries
|
||||
const delay = query === '' ? 0 : FILE_SEARCH_DEBOUNCE;
|
||||
|
||||
// Mark as loading and start search
|
||||
setIsLoading(true);
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
const results = await searchFiles(query);
|
||||
setFileResults(results);
|
||||
setIsLoading(false);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isOpen, query, searchFiles]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// Build prompt items
|
||||
const promptItems = useMemo((): MentionItem[] => {
|
||||
return prompts.map((prompt) => ({
|
||||
id: prompt.id,
|
||||
type: 'prompt' as const,
|
||||
label: prompt.name,
|
||||
description: prompt.content.length > 100 ? prompt.content.slice(0, 100) + '...' : prompt.content,
|
||||
content: prompt.content,
|
||||
}));
|
||||
}, [prompts]);
|
||||
|
||||
// Build file items from search results
|
||||
const fileItems = useMemo((): MentionItem[] => {
|
||||
return fileResults.map((filePath) => ({
|
||||
id: filePath,
|
||||
type: 'file' as const,
|
||||
label: filePath,
|
||||
description: 'File',
|
||||
}));
|
||||
}, [fileResults]);
|
||||
|
||||
// Combine and filter items
|
||||
const filteredItems = useMemo(() => {
|
||||
const filtered = filterItems(promptItems, query);
|
||||
const filteredFiles = filterItems(fileItems, query);
|
||||
|
||||
// Prompts first, then files
|
||||
return [...filtered, ...filteredFiles];
|
||||
}, [promptItems, fileItems, query]);
|
||||
|
||||
// Reset selected index when items change
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- resetting selection on items change is intentional */
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filteredItems.length]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// Handle text changes to detect @ trigger
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
onChange(newValue);
|
||||
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM is updated
|
||||
requestAnimationFrame(() => {
|
||||
const cursorPos = input.getSelectionStart();
|
||||
const textBeforeCursor = newValue.substring(0, cursorPos);
|
||||
|
||||
// Find the last @ before cursor
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex >= 0 && isValidMentionTrigger(newValue, lastAtIndex)) {
|
||||
// Check if we're still within the mention (no space after @)
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1);
|
||||
if (!/\s/.test(textAfterAt)) {
|
||||
// Open menu
|
||||
const caretRect = input.getCaretRect();
|
||||
if (caretRect) {
|
||||
setPosition({ x: caretRect.x, y: caretRect.y });
|
||||
setTriggerStart(lastAtIndex);
|
||||
setQuery(textAfterAt);
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu if no valid trigger
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
}
|
||||
});
|
||||
},
|
||||
[inputRef, isOpen, onChange]
|
||||
);
|
||||
|
||||
// Handle item selection
|
||||
const handleSelect = useCallback(
|
||||
(item: MentionItem) => {
|
||||
const input = inputRef.current;
|
||||
if (!input || triggerStart < 0) return;
|
||||
|
||||
let insertText: string;
|
||||
let cursorOffset: number;
|
||||
|
||||
if (item.type === 'prompt' && item.content) {
|
||||
// For prompts, replace @mention with full prompt content + newline
|
||||
insertText = item.content + '\n';
|
||||
cursorOffset = insertText.length;
|
||||
} else {
|
||||
// For files, replace @mention with @filename
|
||||
insertText = `@${item.label} `;
|
||||
cursorOffset = insertText.length;
|
||||
}
|
||||
|
||||
// Get current cursor position to determine end of replacement
|
||||
const cursorPos = input.getSelectionStart();
|
||||
const newValue = value.substring(0, triggerStart) + insertText + value.substring(cursorPos);
|
||||
|
||||
onChange(newValue);
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
|
||||
// Restore cursor position
|
||||
requestAnimationFrame(() => {
|
||||
const newCursorPos = triggerStart + cursorOffset;
|
||||
input.setSelectionRange(newCursorPos, newCursorPos);
|
||||
input.focus();
|
||||
});
|
||||
},
|
||||
[inputRef, triggerStart, value, onChange]
|
||||
);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
if (filteredItems.length > 0) {
|
||||
event.preventDefault();
|
||||
handleSelect(filteredItems[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen, filteredItems, selectedIndex, handleSelect]
|
||||
);
|
||||
|
||||
// Close menu
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isLoading,
|
||||
position,
|
||||
query,
|
||||
items: filteredItems,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
handleChange,
|
||||
handleSelect,
|
||||
handleKeyDown,
|
||||
closeMenu,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import type { RichTextInputHandle } from '@/components/task/chat/rich-text-input';
|
||||
|
||||
export type SlashCommand = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
action: 'plan' | 'todo' | 'review' | 'summarize';
|
||||
};
|
||||
|
||||
type Position = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
id: 'plan',
|
||||
label: '/plan',
|
||||
description: 'Start a planning response',
|
||||
action: 'plan',
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
label: '/todo',
|
||||
description: 'Add a todo list',
|
||||
action: 'todo',
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
label: '/review',
|
||||
description: 'Request a review',
|
||||
action: 'review',
|
||||
},
|
||||
{
|
||||
id: 'summarize',
|
||||
label: '/summarize',
|
||||
description: 'Summarize the task',
|
||||
action: 'summarize',
|
||||
},
|
||||
];
|
||||
|
||||
function isValidSlashTrigger(text: string, pos: number): boolean {
|
||||
if (pos === 0) return true;
|
||||
const charBefore = text[pos - 1];
|
||||
return charBefore === ' ' || charBefore === '\n' || charBefore === '\t';
|
||||
}
|
||||
|
||||
function filterCommands(query: string): SlashCommand[] {
|
||||
if (!query) return SLASH_COMMANDS;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return SLASH_COMMANDS.filter((cmd) => {
|
||||
const label = cmd.label.toLowerCase();
|
||||
return label.startsWith('/' + lowerQuery) || cmd.action.startsWith(lowerQuery);
|
||||
}).sort((a, b) => {
|
||||
// Prefer exact prefix matches
|
||||
const aStartsWithQuery = a.action.toLowerCase().startsWith(lowerQuery);
|
||||
const bStartsWithQuery = b.action.toLowerCase().startsWith(lowerQuery);
|
||||
if (aStartsWithQuery && !bStartsWithQuery) return -1;
|
||||
if (!aStartsWithQuery && bStartsWithQuery) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function useInlineSlash(
|
||||
inputRef: React.RefObject<RichTextInputHandle | null>,
|
||||
value: string,
|
||||
onChange: (value: string) => void,
|
||||
onPlanModeChange?: (enabled: boolean) => void
|
||||
) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
const [triggerStart, setTriggerStart] = useState<number>(-1);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Filter commands based on query
|
||||
const filteredCommands = useMemo(() => filterCommands(query), [query]);
|
||||
|
||||
// Reset selected index when commands change
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- resetting selection on items change is intentional */
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filteredCommands.length]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// Handle text changes to detect / trigger
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
onChange(newValue);
|
||||
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM is updated
|
||||
requestAnimationFrame(() => {
|
||||
const cursorPos = input.getSelectionStart();
|
||||
const textBeforeCursor = newValue.substring(0, cursorPos);
|
||||
|
||||
// Find the last / before cursor
|
||||
const lastSlashIndex = textBeforeCursor.lastIndexOf('/');
|
||||
|
||||
if (lastSlashIndex >= 0 && isValidSlashTrigger(newValue, lastSlashIndex)) {
|
||||
// Check if we're still within the command (no space after /)
|
||||
const textAfterSlash = textBeforeCursor.substring(lastSlashIndex + 1);
|
||||
if (!/\s/.test(textAfterSlash) && /^[\w-]*$/.test(textAfterSlash)) {
|
||||
// Open menu
|
||||
const caretRect = input.getCaretRect();
|
||||
if (caretRect) {
|
||||
setPosition({ x: caretRect.x, y: caretRect.y });
|
||||
setTriggerStart(lastSlashIndex);
|
||||
setQuery(textAfterSlash);
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu if no valid trigger
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
}
|
||||
});
|
||||
},
|
||||
[inputRef, isOpen, onChange]
|
||||
);
|
||||
|
||||
// Handle command selection
|
||||
const handleSelect = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const input = inputRef.current;
|
||||
if (!input || triggerStart < 0) return;
|
||||
|
||||
// Get current cursor position
|
||||
const cursorPos = input.getSelectionStart();
|
||||
|
||||
// Remove the /command text
|
||||
const newValue = value.substring(0, triggerStart) + value.substring(cursorPos);
|
||||
onChange(newValue);
|
||||
|
||||
// Execute the command action
|
||||
switch (command.action) {
|
||||
case 'plan':
|
||||
onPlanModeChange?.(true);
|
||||
break;
|
||||
case 'todo':
|
||||
// Insert todo template
|
||||
const todoText = '## TODO\n- [ ] ';
|
||||
const todoValue = value.substring(0, triggerStart) + todoText + value.substring(cursorPos);
|
||||
onChange(todoValue);
|
||||
requestAnimationFrame(() => {
|
||||
const newPos = triggerStart + todoText.length;
|
||||
input.setSelectionRange(newPos, newPos);
|
||||
input.focus();
|
||||
});
|
||||
break;
|
||||
case 'review':
|
||||
// Insert review request
|
||||
const reviewText = 'Please review ';
|
||||
const reviewValue = value.substring(0, triggerStart) + reviewText + value.substring(cursorPos);
|
||||
onChange(reviewValue);
|
||||
requestAnimationFrame(() => {
|
||||
const newPos = triggerStart + reviewText.length;
|
||||
input.setSelectionRange(newPos, newPos);
|
||||
input.focus();
|
||||
});
|
||||
break;
|
||||
case 'summarize':
|
||||
// Insert summarize request
|
||||
const summaryText = 'Please summarize ';
|
||||
const summaryValue =
|
||||
value.substring(0, triggerStart) + summaryText + value.substring(cursorPos);
|
||||
onChange(summaryValue);
|
||||
requestAnimationFrame(() => {
|
||||
const newPos = triggerStart + summaryText.length;
|
||||
input.setSelectionRange(newPos, newPos);
|
||||
input.focus();
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
|
||||
// Focus input
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
});
|
||||
},
|
||||
[inputRef, triggerStart, value, onChange, onPlanModeChange]
|
||||
);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filteredCommands.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
if (filteredCommands.length > 0) {
|
||||
event.preventDefault();
|
||||
handleSelect(filteredCommands[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen, filteredCommands, selectedIndex, handleSelect]
|
||||
);
|
||||
|
||||
// Close menu
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setTriggerStart(-1);
|
||||
setQuery('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
position,
|
||||
commands: filteredCommands,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
handleChange,
|
||||
handleSelect,
|
||||
handleKeyDown,
|
||||
closeMenu,
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
export const chatEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
color: 'var(--foreground)',
|
||||
fontSize: '0.8125rem', // 13px
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-editor': {
|
||||
borderRadius: 'inherit',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
boxShadow: 'none',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.cm-editor.cm-focused': {
|
||||
outline: 'none',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'var(--font-sans)',
|
||||
borderRadius: 'inherit',
|
||||
background: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '0.5rem',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
color: 'var(--foreground)',
|
||||
background: 'transparent',
|
||||
},
|
||||
'.cm-line': {
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--muted-foreground)',
|
||||
opacity: '0.8',
|
||||
},
|
||||
'.cm-tooltip-autocomplete, .cm-tooltip-autocomplete li, .cm-completionDetail': {
|
||||
fontSize: '0.8125rem', // 13px
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--foreground)',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'.cm-selectionMatch': {
|
||||
backgroundColor: '#818cf8 !important',
|
||||
},
|
||||
'.cm-selectionLayer .cm-selectionBackground': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'.cm-line ::selection': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
'.cm-content ::selection': {
|
||||
backgroundColor: '#6366f1 !important',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user