feat: improved chat input (#120)

This commit is contained in:
Carlos Florêncio
2026-01-29 22:34:10 +00:00
committed by GitHub
parent a97c35904f
commit d6755b702f
21 changed files with 1789 additions and 669 deletions
+12 -9
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+41 -19
View File
@@ -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>
);
}
+16 -37
View File
@@ -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>
</>
);
}
-144
View File
@@ -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;
}
+316
View File
@@ -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,
};
}
+249
View File
@@ -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,
};
}
-64
View File
@@ -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',
},
});