Queued messages now match the reverted message dock style Added per-message edit and send actions Sending one queued message no longer sends the full queue
4410 lines
193 KiB
TypeScript
4410 lines
193 KiB
TypeScript
import React from 'react';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { BrowserVoiceButton } from '@/components/voice';
|
|
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
import { useInputStore } from '@/sync/input-store';
|
|
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
|
import * as sessionActions from '@/sync/session-actions';
|
|
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
|
|
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
|
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
|
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
|
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
|
import ToolOutputDialog from './message/ToolOutputDialog';
|
|
import type { ToolPopupContent } from './message/types';
|
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
|
import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
|
|
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
|
import { SnippetAutocomplete, type SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
|
import { cn, formatDirectoryName, isMacOS } from '@/lib/utils';
|
|
import { ModelControls } from './ModelControls';
|
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
|
import { StatusRow } from './StatusRow';
|
|
import { PendingChangesBar } from './PendingChangesBar';
|
|
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
|
import { MobileAgentButton } from './MobileAgentButton';
|
|
import { MobileModelButton } from './MobileModelButton';
|
|
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
|
import { toast } from '@/components/ui';
|
|
import { Button } from '@/components/ui/button';
|
|
// useMessageStore removed — messages now come from sync system
|
|
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isIMECompositionEvent } from '@/lib/ime';
|
|
import { StopIcon } from '@/components/icons/StopIcon';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
|
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
|
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { useCommandsStore } from '@/stores/useCommandsStore';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
|
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
|
import { usePermissionStore } from '@/stores/permissionStore';
|
|
import { extractGitChangedFiles } from './changedFiles';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { fetchResponseStyleInstruction } from '@/lib/responseStyle';
|
|
import { wrapSystemReminder } from '@/lib/systemReminder';
|
|
import { getSyncMessages } from '@/sync/sync-refs';
|
|
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
|
import { isSyntheticPart } from '@/lib/messages/synthetic';
|
|
import {
|
|
buildHighlightParts,
|
|
mentionRangesToHighlightRanges,
|
|
tokenizeMarkdown,
|
|
type HighlightRange,
|
|
type MentionRange,
|
|
} from './composerHighlight';
|
|
import { highlightFencedCode } from './composerCodeHighlight';
|
|
import {
|
|
assignImageAttachmentFilenames,
|
|
buildAttachmentCitationText,
|
|
findAttachmentCitationRanges,
|
|
} from './attachmentCitations';
|
|
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
|
|
|
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
|
const EMPTY_MESSAGES: Message[] = [];
|
|
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
|
|
// Single-line URL pasted over a selection becomes a markdown link.
|
|
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
|
|
const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
|
|
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
|
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
|
|
const VS_CODE_DROP_DATA_TYPES = [
|
|
'CodeFiles',
|
|
'codefiles',
|
|
'application/vnd.code.tree',
|
|
'application/vnd.code.tree.explorer',
|
|
'text/uri-list',
|
|
'text/plain',
|
|
];
|
|
|
|
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
|
if (file.name === filename) {
|
|
return file;
|
|
}
|
|
|
|
return new File([file], filename, {
|
|
type: file.type,
|
|
lastModified: file.lastModified,
|
|
});
|
|
};
|
|
|
|
const buildImagePasteInsertion = (pastedText: string, citationText: string): string => {
|
|
const text = pastedText;
|
|
if (!text) {
|
|
return citationText;
|
|
}
|
|
return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`;
|
|
};
|
|
|
|
const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => {
|
|
if (!content) {
|
|
return content;
|
|
}
|
|
|
|
const needsLeadingSpace = before.length > 0
|
|
&& !/\s$/.test(before)
|
|
&& !/^\s/.test(content)
|
|
&& !/[([{]$/.test(before);
|
|
const needsTrailingSpace = after.length > 0
|
|
&& !/\s$/.test(content)
|
|
&& !/^\s/.test(after)
|
|
&& !/^[\])}.,;:!?]/.test(after);
|
|
|
|
return `${needsLeadingSpace ? ' ' : ''}${content}${needsTrailingSpace ? ' ' : ''}`;
|
|
};
|
|
|
|
const collectInlineSkillMentions = (text: string, skillNames: Set<string>): string[] => {
|
|
const mentions: string[] = [];
|
|
INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) {
|
|
const name = match[2] || '';
|
|
if (!skillNames.has(name) || mentions.includes(name)) {
|
|
continue;
|
|
}
|
|
mentions.push(name);
|
|
}
|
|
return mentions;
|
|
};
|
|
|
|
const buildSkillMentionInstruction = (skillNames: string[]): string | null => {
|
|
if (skillNames.length === 0) return null;
|
|
const formatted = skillNames.map((name) => `/${name}`).join(', ');
|
|
return `The user explicitly mentioned these skills in their message: ${formatted}. Use the corresponding skill tool when it is relevant to accomplishing the user's request.`;
|
|
};
|
|
|
|
const hasUserMessages = (sessionId: string, directory?: string) => {
|
|
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
|
|
};
|
|
|
|
const getRevertedPreview = (parts: Part[], fallback: string): string => {
|
|
const text = parts
|
|
.filter((part) => part.type === 'text' && !isSyntheticPart(part))
|
|
.map((part) => {
|
|
const record = part as Record<string, unknown>;
|
|
return typeof record.text === 'string'
|
|
? record.text
|
|
: typeof record.content === 'string'
|
|
? record.content
|
|
: '';
|
|
})
|
|
.join('\n')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
if (text) return text;
|
|
const filePart = parts.find((part) => part.type === 'file') as (Part & { filename?: string }) | undefined;
|
|
return filePart?.filename ? `[${filePart.filename}]` : fallback;
|
|
};
|
|
|
|
const FILE_URI_PREFIX = 'file://';
|
|
|
|
const encodeFilePath = (filepath: string): string => {
|
|
let normalized = filepath.replace(/\\/g, '/');
|
|
if (/^[A-Za-z]:/.test(normalized)) {
|
|
normalized = `/${normalized}`;
|
|
}
|
|
return normalized
|
|
.split('/')
|
|
.map((segment, index) => {
|
|
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment;
|
|
return encodeURIComponent(segment);
|
|
})
|
|
.join('/');
|
|
};
|
|
|
|
const toServerFileUrl = (filepath: string): string => {
|
|
const normalized = filepath.replace(/\\/g, '/').trim();
|
|
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
|
return normalized;
|
|
}
|
|
return `file://${encodeFilePath(normalized)}`;
|
|
};
|
|
|
|
const isLikelyAbsolutePath = (value: string): boolean => (
|
|
value.startsWith('/')
|
|
|| value.startsWith('\\\\')
|
|
|| /^[A-Za-z]:[\\/]/.test(value)
|
|
);
|
|
|
|
const toLikelyFileDropReference = (value: string): string | null => {
|
|
const trimmed = value.trim().replace(/^['"]+|['"]+$/g, '');
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (/[\r\n]/.test(trimmed)) {
|
|
return null;
|
|
}
|
|
|
|
if (trimmed.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
|
return trimmed;
|
|
}
|
|
|
|
if (isLikelyAbsolutePath(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const collectStringLeaves = (input: unknown, output: Set<string>, depth = 0): void => {
|
|
if (depth > 6 || input == null) {
|
|
return;
|
|
}
|
|
|
|
if (typeof input === 'string') {
|
|
output.add(input);
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(input)) {
|
|
for (const item of input) {
|
|
collectStringLeaves(item, output, depth + 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (typeof input !== 'object') {
|
|
return;
|
|
}
|
|
|
|
for (const value of Object.values(input)) {
|
|
collectStringLeaves(value, output, depth + 1);
|
|
}
|
|
};
|
|
|
|
const parseDroppedFileReferences = (rawPayload: string): string[] => {
|
|
const extracted = new Set<string>();
|
|
|
|
const addCandidatesFromText = (value: string): void => {
|
|
const direct = toLikelyFileDropReference(value);
|
|
if (direct) {
|
|
extracted.add(direct);
|
|
return;
|
|
}
|
|
|
|
for (const line of value.split(/\r?\n/)) {
|
|
const candidate = toLikelyFileDropReference(line);
|
|
if (candidate) {
|
|
extracted.add(candidate);
|
|
}
|
|
}
|
|
};
|
|
|
|
addCandidatesFromText(rawPayload);
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawPayload) as unknown;
|
|
const leaves = new Set<string>();
|
|
collectStringLeaves(parsed, leaves);
|
|
for (const leaf of leaves) {
|
|
addCandidatesFromText(leaf);
|
|
}
|
|
} catch {
|
|
// Ignore non-JSON payloads.
|
|
}
|
|
|
|
return Array.from(extracted);
|
|
};
|
|
|
|
const normalizePath = (value?: string | null): string | null => {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const normalized = trimmed.replace(/\\/g, '/');
|
|
if (normalized === '/') {
|
|
return '/';
|
|
}
|
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
};
|
|
|
|
const getProjectDisplayLabel = (project: { label?: string; path: string }): string => {
|
|
const label = project.label?.trim();
|
|
if (label) {
|
|
return label;
|
|
}
|
|
return formatDirectoryName(project.path);
|
|
};
|
|
|
|
const getProjectIconColor = (projectColor?: string | null): string | undefined => {
|
|
if (!projectColor) {
|
|
return undefined;
|
|
}
|
|
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
|
|
};
|
|
|
|
const MemoModelControls = React.memo(ModelControls);
|
|
const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton);
|
|
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
|
const MemoMobileModelButton = React.memo(MobileModelButton);
|
|
const MemoStatusRow = React.memo(StatusRow);
|
|
|
|
type RevertedMessageDockProps = {
|
|
sessionId: string | null;
|
|
directory?: string;
|
|
};
|
|
|
|
const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.memo(({ sessionId, directory }) => {
|
|
const { t } = useI18n();
|
|
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
|
|
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
|
|
const handleSlashRedo = useSessionUIStore((s) => s.handleSlashRedo);
|
|
const [restoringId, setRestoringId] = React.useState<string | null>(null);
|
|
const [forkingId, setForkingId] = React.useState<string | null>(null);
|
|
const [collapsed, setCollapsed] = React.useState(true);
|
|
const revertMessageID = useDirectorySync(
|
|
React.useCallback((state) => {
|
|
if (!sessionId) return undefined;
|
|
const session = state.session.find((item) => item.id === sessionId);
|
|
return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
|
|
}, [sessionId]),
|
|
directory,
|
|
);
|
|
const sessionMessages = useDirectorySync(
|
|
React.useCallback((state) => (sessionId ? state.message[sessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES), [sessionId]),
|
|
directory,
|
|
);
|
|
const partsByMessage = useDirectorySync(React.useCallback((state) => state.part, []), directory);
|
|
|
|
const userMessages = React.useMemo(
|
|
() => sessionMessages.filter((message): message is Message & { role: 'user' } => message.role === 'user'),
|
|
[sessionMessages],
|
|
);
|
|
const noTextContent = t('chat.revertPopover.noTextContent');
|
|
const items = React.useMemo(() => {
|
|
if (!revertMessageID) return [];
|
|
return userMessages
|
|
.filter((message) => message.id >= revertMessageID)
|
|
.map((message) => ({
|
|
id: message.id,
|
|
text: getRevertedPreview(partsByMessage[message.id] ?? [], noTextContent),
|
|
}));
|
|
}, [noTextContent, partsByMessage, revertMessageID, userMessages]);
|
|
const firstRevertedMessageId = items[0]?.id;
|
|
|
|
React.useEffect(() => {
|
|
setCollapsed(true);
|
|
}, [revertMessageID, firstRevertedMessageId]);
|
|
|
|
const handleRestore = React.useCallback(async (messageId: string) => {
|
|
if (!sessionId || restoringId) return;
|
|
setRestoringId(messageId);
|
|
try {
|
|
const nextMessage = userMessages.find((message) => message.id > messageId);
|
|
if (nextMessage) {
|
|
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
|
|
} else {
|
|
await handleSlashRedo(sessionId, { fullUnrevert: true });
|
|
}
|
|
} finally {
|
|
setRestoringId(null);
|
|
}
|
|
}, [handleSlashRedo, revertToMessage, restoringId, sessionId, userMessages]);
|
|
|
|
const handleFork = React.useCallback(async (messageId: string) => {
|
|
if (!sessionId || forkingId) return;
|
|
setForkingId(messageId);
|
|
try {
|
|
await forkFromMessage(sessionId, messageId);
|
|
} finally {
|
|
setForkingId(null);
|
|
}
|
|
}, [forkFromMessage, forkingId, sessionId]);
|
|
|
|
if (!sessionId || items.length === 0) return null;
|
|
|
|
return (
|
|
<div className="pb-2 w-full px-1">
|
|
<div className="rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[var(--interactive-hover)] transition-colors"
|
|
onClick={() => setCollapsed((value) => !value)}
|
|
aria-expanded={!collapsed}
|
|
>
|
|
<span className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
|
{t('chat.revertPopover.title')} messages {items.length}
|
|
</span>
|
|
<Icon
|
|
name="arrow-down-s"
|
|
className={cn("ml-auto h-4 w-4 text-muted-foreground transition-transform", !collapsed && "rotate-180")}
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
{!collapsed && (
|
|
<div className="px-3 pb-3 flex flex-col gap-1.5 max-h-[10.5rem] overflow-y-auto">
|
|
{items.map((item) => (
|
|
<div key={item.id} className="flex min-w-0 items-center gap-2 py-1">
|
|
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
|
{item.text}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="xs"
|
|
disabled={Boolean(restoringId || forkingId)}
|
|
onClick={() => { void handleFork(item.id); }}
|
|
>
|
|
{forkingId === item.id ? (
|
|
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<Icon name="git-branch" className="h-3 w-3" aria-hidden="true" />
|
|
)}
|
|
{t('chat.revertPopover.fork')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="xs"
|
|
disabled={Boolean(restoringId || forkingId)}
|
|
onClick={() => { void handleRestore(item.id); }}
|
|
>
|
|
{restoringId === item.id ? (
|
|
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
|
|
) : (
|
|
<Icon name="arrow-go-forward" className="h-3 w-3" aria-hidden="true" />
|
|
)}
|
|
{t('chat.revertPopover.restore')}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
RevertedMessageDock.displayName = 'RevertedMessageDock';
|
|
|
|
type ComposerAttachmentControlsProps = {
|
|
isVSCode: boolean;
|
|
footerIconButtonClass: string;
|
|
iconSizeClass: string;
|
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
|
handleLocalFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void | Promise<void>;
|
|
handlePickLocalFiles: () => void;
|
|
openIssuePicker: () => void;
|
|
openPrPicker: () => void;
|
|
onOpenSettings?: () => void;
|
|
};
|
|
|
|
const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
|
|
const { t } = useI18n();
|
|
const {
|
|
isVSCode,
|
|
footerIconButtonClass,
|
|
iconSizeClass,
|
|
fileInputRef,
|
|
handleLocalFileSelect,
|
|
handlePickLocalFiles,
|
|
openIssuePicker,
|
|
openPrPicker,
|
|
onOpenSettings,
|
|
} = props;
|
|
|
|
return (
|
|
<div className="flex items-center gap-x-1.5">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleLocalFileSelect}
|
|
accept="*/*"
|
|
/>
|
|
|
|
<div className="relative inline-flex">
|
|
{isVSCode ? (
|
|
<button
|
|
type="button"
|
|
className={footerIconButtonClass}
|
|
onClick={handlePickLocalFiles}
|
|
title={t('chat.chatInput.actions.attachFiles')}
|
|
aria-label={t('chat.chatInput.actions.attachFiles')}
|
|
>
|
|
<Icon name="attachment-2" className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
) : (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={footerIconButtonClass}
|
|
title={t('chat.chatInput.actions.addAttachment')}
|
|
aria-label={t('chat.chatInput.actions.addAttachment')}
|
|
>
|
|
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
requestAnimationFrame(handlePickLocalFiles);
|
|
}}
|
|
>
|
|
<Icon name="attachment-2"/>
|
|
{t('chat.chatInput.actions.attachFiles')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
requestAnimationFrame(openIssuePicker);
|
|
}}
|
|
>
|
|
<Icon name="github"/>
|
|
{t('chat.chatInput.actions.linkGithubIssue')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
requestAnimationFrame(openPrPicker);
|
|
}}
|
|
>
|
|
<Icon name="git-pull-request"/>
|
|
{t('chat.chatInput.actions.linkGithubPr')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
|
|
{onOpenSettings ? (
|
|
<button
|
|
type="button"
|
|
onClick={onOpenSettings}
|
|
className={footerIconButtonClass}
|
|
title={t('chat.chatInput.actions.modelAgentSettings')}
|
|
aria-label={t('chat.chatInput.actions.modelAgentSettings')}
|
|
>
|
|
<Icon name="ai-agent" className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}, (prev, next) => (
|
|
prev.isVSCode === next.isVSCode
|
|
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
|
&& prev.iconSizeClass === next.iconSizeClass
|
|
&& prev.onOpenSettings === next.onOpenSettings
|
|
));
|
|
|
|
type PermissionAutoAcceptButtonProps = {
|
|
footerIconButtonClass: string;
|
|
iconSizeClass: string;
|
|
permissionScopeSessionId: string | null;
|
|
permissionAutoAcceptEnabled: boolean;
|
|
handlePermissionAutoAcceptToggle: () => void;
|
|
withTooltip?: boolean;
|
|
};
|
|
|
|
const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) {
|
|
const { t } = useI18n();
|
|
const {
|
|
footerIconButtonClass,
|
|
iconSizeClass,
|
|
permissionScopeSessionId,
|
|
permissionAutoAcceptEnabled,
|
|
handlePermissionAutoAcceptToggle,
|
|
withTooltip = false,
|
|
} = props;
|
|
|
|
const ariaLabel = permissionAutoAcceptEnabled
|
|
? t('chat.chatInput.permissionAutoAccept.disable')
|
|
: t('chat.chatInput.permissionAutoAccept.enable');
|
|
const tooltipLabel = permissionAutoAcceptEnabled
|
|
? t('chat.chatInput.permissionAutoAccept.on')
|
|
: t('chat.chatInput.permissionAutoAccept.off');
|
|
|
|
const button = (
|
|
<button
|
|
type="button"
|
|
onClick={handlePermissionAutoAcceptToggle}
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
'rounded-md hover:bg-transparent',
|
|
!permissionScopeSessionId && 'opacity-30',
|
|
)}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
}}
|
|
onPointerDownCapture={(event) => {
|
|
if (event.pointerType === 'touch') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
}}
|
|
aria-pressed={permissionAutoAcceptEnabled}
|
|
aria-label={ariaLabel}
|
|
title={ariaLabel}
|
|
>
|
|
{permissionAutoAcceptEnabled ? (
|
|
<Icon name="shield-check" className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
|
|
) : (
|
|
<Icon name="shield-user" className={cn(iconSizeClass)} />
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
if (!withTooltip) {
|
|
return button;
|
|
}
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
{button}
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={8}>
|
|
{tooltipLabel}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
});
|
|
|
|
type FocusModeButtonProps = {
|
|
footerIconButtonClass: string;
|
|
iconSizeClass: string;
|
|
isExpandedInput: boolean;
|
|
onToggle: () => void;
|
|
};
|
|
|
|
const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
|
|
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
'rounded-md',
|
|
isExpandedInput
|
|
? 'text-primary'
|
|
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
|
|
)}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
}}
|
|
onClick={onToggle}
|
|
aria-label={t('chat.chatInput.focusMode.toggleAria')}
|
|
aria-pressed={isExpandedInput}
|
|
>
|
|
<Icon name="fullscreen" className={cn(iconSizeClass)} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={8}>
|
|
<div className="flex flex-col gap-0.5 text-center">
|
|
<span>{t('chat.chatInput.focusMode.label')}</span>
|
|
<span className="font-mono opacity-60">
|
|
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
|
</span>
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
});
|
|
|
|
type ComposerActionButtonsProps = {
|
|
isMobile: boolean;
|
|
footerIconButtonClass: string;
|
|
sendIconSizeClass: string;
|
|
stopIconSizeClass: string;
|
|
canSend: boolean;
|
|
canAbort: boolean;
|
|
hasContent: boolean;
|
|
currentSessionId: string | null;
|
|
newSessionDraftOpen: boolean;
|
|
onPrimaryAction: () => void;
|
|
onQueueMessage: () => void;
|
|
onAbort: () => void;
|
|
};
|
|
|
|
const ComposerActionButtons = React.memo(function ComposerActionButtons(props: ComposerActionButtonsProps) {
|
|
const {
|
|
isMobile,
|
|
footerIconButtonClass,
|
|
sendIconSizeClass,
|
|
stopIconSizeClass,
|
|
canSend,
|
|
canAbort,
|
|
hasContent,
|
|
currentSessionId,
|
|
newSessionDraftOpen,
|
|
onPrimaryAction,
|
|
onQueueMessage,
|
|
onAbort,
|
|
} = props;
|
|
const { t } = useI18n();
|
|
|
|
const sendButton = (
|
|
<button
|
|
type={isMobile ? 'button' : 'submit'}
|
|
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
|
onClick={(event) => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
onPrimaryAction();
|
|
}}
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
canSend && (currentSessionId || newSessionDraftOpen)
|
|
? 'text-primary hover:text-primary'
|
|
: 'opacity-30'
|
|
)}
|
|
aria-label={t('chat.chatInput.actions.sendMessageAria')}
|
|
>
|
|
<Icon name="send-plane-2" className={cn(sendIconSizeClass)} />
|
|
</button>
|
|
);
|
|
|
|
if (!canAbort) {
|
|
return sendButton;
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
{hasContent ? (
|
|
<button
|
|
type="button"
|
|
disabled={!currentSessionId}
|
|
onClick={(event) => {
|
|
if (isMobile) {
|
|
event.preventDefault();
|
|
}
|
|
onQueueMessage();
|
|
}}
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
|
|
currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30'
|
|
)}
|
|
aria-label={t('chat.chatInput.actions.queueMessageAria')}
|
|
>
|
|
<Icon name="send-plane-2" className={cn(sendIconSizeClass, '-rotate-90')} />
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onAbort}
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
'text-[var(--status-error)] hover:text-[var(--status-error)]'
|
|
)}
|
|
aria-label={t('chat.chatInput.actions.stopGeneratingAria')}
|
|
>
|
|
<StopIcon className={cn(stopIconSizeClass)} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}, (prev, next) => (
|
|
prev.isMobile === next.isMobile
|
|
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
|
&& prev.sendIconSizeClass === next.sendIconSizeClass
|
|
&& prev.stopIconSizeClass === next.stopIconSizeClass
|
|
&& prev.canSend === next.canSend
|
|
&& prev.canAbort === next.canAbort
|
|
&& prev.hasContent === next.hasContent
|
|
&& prev.currentSessionId === next.currentSessionId
|
|
&& prev.newSessionDraftOpen === next.newSessionDraftOpen
|
|
&& prev.onPrimaryAction === next.onPrimaryAction
|
|
&& prev.onQueueMessage === next.onQueueMessage
|
|
&& prev.onAbort === next.onAbort
|
|
));
|
|
|
|
const appendWithLineBreaks = (base: string, next: string): string => {
|
|
const separator = !base
|
|
? ''
|
|
: base.endsWith('\n\n')
|
|
? ''
|
|
: base.endsWith('\n')
|
|
? '\n'
|
|
: '\n\n';
|
|
|
|
const nextWithTrailingBreaks = next.endsWith('\n\n')
|
|
? next
|
|
: next.endsWith('\n')
|
|
? `${next}\n`
|
|
: `${next}\n\n`;
|
|
|
|
return `${base}${separator}${nextWithTrailingBreaks}`;
|
|
};
|
|
|
|
const appendInlineText = (base: string, next: string): string => {
|
|
const nextTrimmed = next.trim();
|
|
if (!nextTrimmed) {
|
|
return base;
|
|
}
|
|
if (!base) {
|
|
return `${nextTrimmed} `;
|
|
}
|
|
const separator = /[\s\n]$/.test(base) ? '' : ' ';
|
|
return `${base}${separator}${nextTrimmed} `;
|
|
};
|
|
|
|
interface ChatInputProps {
|
|
onOpenSettings?: () => void;
|
|
scrollToBottom?: () => void;
|
|
}
|
|
|
|
type AutocompleteOverlayPosition = {
|
|
top: number;
|
|
left: number;
|
|
place: 'above' | 'below';
|
|
maxHeight: number;
|
|
};
|
|
|
|
// Per-session draft key — preserves in-progress messages across project switches
|
|
const getDraftKey = (sessionId: string | null): string =>
|
|
`openchamber_chat_input_draft_${sessionId ?? 'new'}`;
|
|
|
|
// Helper to safely read from localStorage for a given session
|
|
const getStoredDraft = (sessionId: string | null): string => {
|
|
try {
|
|
return localStorage.getItem(getDraftKey(sessionId)) ?? '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// Helper to safely write/clear a per-session draft
|
|
const saveStoredDraft = (sessionId: string | null, draft: string): void => {
|
|
try {
|
|
if (draft) {
|
|
localStorage.setItem(getDraftKey(sessionId), draft);
|
|
} else {
|
|
localStorage.removeItem(getDraftKey(sessionId));
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
};
|
|
|
|
// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text
|
|
const getConfirmedMentionsKey = (sessionId: string | null): string =>
|
|
`openchamber_chat_confirmed_mentions_${sessionId ?? 'new'}`;
|
|
|
|
const saveConfirmedMentions = (sessionId: string | null, mentions: Set<string>): void => {
|
|
try {
|
|
if (mentions.size > 0) {
|
|
localStorage.setItem(getConfirmedMentionsKey(sessionId), JSON.stringify([...mentions]));
|
|
} else {
|
|
localStorage.removeItem(getConfirmedMentionsKey(sessionId));
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
};
|
|
|
|
const loadConfirmedMentions = (sessionId: string | null): Set<string> => {
|
|
try {
|
|
const raw = localStorage.getItem(getConfirmedMentionsKey(sessionId));
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
return new Set();
|
|
};
|
|
|
|
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
|
const { t } = useI18n();
|
|
// Track if we restored a draft on mount (for text selection)
|
|
const initialDraftRef = React.useRef<string | null>(null);
|
|
// Track initial session ID (captured at mount time for draft restoration)
|
|
const initialSessionIdRef = React.useRef<string | null>(null);
|
|
const [message, setMessage] = React.useState(() => {
|
|
// Read per-session draft at mount time using the current session from the store
|
|
const sessionId = useSessionUIStore.getState().currentSessionId;
|
|
initialSessionIdRef.current = sessionId;
|
|
const draft = getStoredDraft(sessionId);
|
|
if (draft) {
|
|
initialDraftRef.current = draft;
|
|
}
|
|
return draft;
|
|
});
|
|
// Restore confirmed mentions from localStorage on mount
|
|
const confirmedMentionsRef = React.useRef<Set<string>>(loadConfirmedMentions(initialSessionIdRef.current));
|
|
// Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed)
|
|
const isConfirmedFilePath = (text: string): boolean =>
|
|
text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text);
|
|
const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
const [isInternalDrag, setIsInternalDrag] = React.useState(false);
|
|
const [showFileMention, setShowFileMention] = React.useState(false);
|
|
const [mentionQuery, setMentionQuery] = React.useState('');
|
|
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
|
|
const [commandQuery, setCommandQuery] = React.useState('');
|
|
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
|
|
const [skillQuery, setSkillQuery] = React.useState('');
|
|
const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false);
|
|
const [snippetQuery, setSnippetQuery] = React.useState('');
|
|
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
|
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
|
|
// Message history navigation state (up/down arrow to recall previous messages)
|
|
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
|
|
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
|
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
const cursorPosRef = React.useRef(0);
|
|
const previousMessageLengthRef = React.useRef(message.length);
|
|
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
|
const dragEnterCountRef = React.useRef(0);
|
|
const suppressNextFileDropTextInsertRef = React.useRef(false);
|
|
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
|
|
const canAcceptDropRef = React.useRef(false);
|
|
const nativeDragInsideDropZoneRef = React.useRef(false);
|
|
const mentionRef = React.useRef<FileMentionHandle>(null);
|
|
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
|
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
|
const snippetRef = React.useRef<SnippetAutocompleteHandle>(null);
|
|
// Ref to track current message value without triggering re-renders in effects
|
|
const messageRef = React.useRef(message);
|
|
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const skipNextDraftPersistRef = React.useRef(false);
|
|
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
|
|
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
|
|
const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set());
|
|
|
|
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const sendMessage = React.useRef((...args: any[]) =>
|
|
Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)),
|
|
).current;
|
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
|
const currentSessionDirectoryForSync = useSessionUIStore(
|
|
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
|
|
);
|
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
|
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
|
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
|
const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject);
|
|
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
|
|
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
|
|
const attachedFiles = useInputStore((s) => s.attachedFiles);
|
|
const addAttachedFile = useInputStore((s) => s.addAttachedFile);
|
|
const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles);
|
|
const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection);
|
|
const consumePendingInputText = useInputStore((s) => s.consumePendingInputText);
|
|
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
|
const pendingInputText = useInputStore((s) => s.pendingInputText);
|
|
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
|
|
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
|
|
const abortCurrentOperation = React.useCallback(
|
|
(sessionIdOverride?: string) => sessionActions.abortCurrentOperation(sessionIdOverride ?? currentSessionId ?? ''),
|
|
[currentSessionId],
|
|
);
|
|
const currentManagementSessionId = currentSessionId;
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
|
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
|
|
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
|
const currentVariant = useConfigStore((state) => state.currentVariant);
|
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
|
const setAgent = useConfigStore((state) => state.setAgent);
|
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
|
const agents = getVisibleAgents();
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
|
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
|
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
|
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
|
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
|
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
|
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
|
|
const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent);
|
|
const cycleAgentShortcut = React.useMemo(() => (
|
|
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
|
|
), [cycleAgentShortcutOverride]);
|
|
const { git: runtimeGit } = useRuntimeAPIs();
|
|
const { currentTheme } = useThemeSystem();
|
|
const chatSearchDirectory = useChatSearchDirectory();
|
|
const isGitRepo = useIsGitRepo(currentDirectory);
|
|
const currentGitStatus = useGitStore((state) =>
|
|
currentDirectory ? state.directories.get(currentDirectory)?.status ?? null : null,
|
|
);
|
|
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
|
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
|
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
|
|
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
|
|
open: false,
|
|
title: '',
|
|
content: '',
|
|
});
|
|
|
|
const handleShowAttachmentPreview = React.useCallback((content: ToolPopupContent) => {
|
|
if (!content.image) return;
|
|
setAttachmentPreview(content);
|
|
setImagePreviewOpen(true);
|
|
}, [setImagePreviewOpen]);
|
|
|
|
const handleAttachmentPreviewOpenChange = React.useCallback((open: boolean) => {
|
|
setAttachmentPreview((prev) => ({ ...prev, open }));
|
|
setImagePreviewOpen(open);
|
|
}, [setImagePreviewOpen]);
|
|
|
|
const isDesktopExpanded = isExpandedInput && !isMobile;
|
|
const chatInputRadius = 'var(--radius-xl)';
|
|
const useCompactChatPlaceholder = isMobile || isNarrowComposer;
|
|
|
|
React.useEffect(() => {
|
|
const element = dropZoneRef.current;
|
|
if (!element) return;
|
|
|
|
const updateWidth = (width: number) => {
|
|
const next = width > 0 && width < COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH;
|
|
setIsNarrowComposer((prev) => (prev === next ? prev : next));
|
|
};
|
|
|
|
updateWidth(element.clientWidth);
|
|
|
|
if (typeof ResizeObserver === 'undefined') {
|
|
const handleResize = () => updateWidth(element.clientWidth);
|
|
window.addEventListener('resize', handleResize);
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
updateWidth(entries[0]?.contentRect.width ?? element.clientWidth);
|
|
});
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const sendableAttachedFiles = attachedFiles;
|
|
|
|
const knownAgentNames = React.useMemo(
|
|
() => new Set(agents.map((agent) => agent.name.toLowerCase())),
|
|
[agents]
|
|
);
|
|
const knownAgentNamesRef = React.useRef(knownAgentNames);
|
|
knownAgentNamesRef.current = knownAgentNames;
|
|
|
|
// Known slash-invocations (commands + skills + built-ins) used to highlight
|
|
// matching /tokens in the composer, the same way confirmed @files are.
|
|
const availableCommands = useCommandsStore((s) => s.commands);
|
|
const availableSkills = useSkillsStore((s) => s.skills);
|
|
const knownSlashNames = React.useMemo(() => {
|
|
const names = new Set<string>([
|
|
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review',
|
|
]);
|
|
for (const command of availableCommands) names.add(command.name.toLowerCase());
|
|
for (const skill of availableSkills) names.add(skill.name.toLowerCase());
|
|
return names;
|
|
}, [availableCommands, availableSkills]);
|
|
|
|
// /command and /skill spans (primary color). Only tokens that match a known
|
|
// command/skill name are highlighted — partial/unknown tokens stay plain.
|
|
const composerCommandRanges = React.useMemo<HighlightRange[]>(() => {
|
|
if (!message || !message.includes('/') || inputMode === 'shell' || knownSlashNames.size === 0) {
|
|
return [];
|
|
}
|
|
const ranges: HighlightRange[] = [];
|
|
const slashRegex = /(^|\s)\/([A-Za-z0-9][A-Za-z0-9_-]*)/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = slashRegex.exec(message)) !== null) {
|
|
const name = match[2];
|
|
if (!knownSlashNames.has(name.toLowerCase())) {
|
|
continue;
|
|
}
|
|
const slashStart = match.index + match[1].length;
|
|
ranges.push({ start: slashStart, end: slashStart + 1 + name.length, style: 'mentionCommand' });
|
|
}
|
|
return ranges;
|
|
}, [inputMode, knownSlashNames, message]);
|
|
|
|
// Snippet triggers (#name / #alias). Highlighted like commands once the
|
|
// trigger matches a known snippet name or alias.
|
|
const availableSnippets = useSnippetsStore((s) => s.snippets);
|
|
const knownSnippetTriggers = React.useMemo(() => {
|
|
const triggers = new Set<string>();
|
|
for (const snippet of availableSnippets) {
|
|
triggers.add(snippet.name.toLowerCase());
|
|
for (const alias of snippet.aliases ?? []) triggers.add(alias.toLowerCase());
|
|
}
|
|
return triggers;
|
|
}, [availableSnippets]);
|
|
|
|
const composerSnippetRanges = React.useMemo<HighlightRange[]>(() => {
|
|
if (!message || !message.includes('#') || inputMode === 'shell' || knownSnippetTriggers.size === 0) {
|
|
return [];
|
|
}
|
|
const ranges: HighlightRange[] = [];
|
|
const snippetRegex = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = snippetRegex.exec(message)) !== null) {
|
|
const trigger = match[2];
|
|
if (!knownSnippetTriggers.has(trigger.toLowerCase())) {
|
|
continue;
|
|
}
|
|
const hashStart = match.index + match[1].length;
|
|
ranges.push({ start: hashStart, end: hashStart + 1 + trigger.length, style: 'mentionSnippet' });
|
|
}
|
|
return ranges;
|
|
}, [inputMode, knownSnippetTriggers, message]);
|
|
|
|
// @mention spans (file = blue, agent = green). Computed as character ranges
|
|
// so they can be merged with markdown highlight ranges in a single overlay.
|
|
const composerMentionRanges = React.useMemo<MentionRange[]>(() => {
|
|
if (!message || !message.includes('@') || inputMode === 'shell') {
|
|
return [];
|
|
}
|
|
const ranges: MentionRange[] = [];
|
|
const mentionRegex = /@([^\s]+)/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = mentionRegex.exec(message)) !== null) {
|
|
const full = match[0];
|
|
const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
|
|
const start = match.index;
|
|
const end = start + full.length;
|
|
const charBefore = start > 0 ? message[start - 1] : null;
|
|
const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore);
|
|
if (!isBoundary || mention.length === 0) {
|
|
continue;
|
|
}
|
|
if (knownAgentNames.has(mention.toLowerCase())) {
|
|
ranges.push({ start, end, kind: 'agent' });
|
|
} else if (isConfirmedFilePath(mention)) {
|
|
ranges.push({ start, end, kind: 'file' });
|
|
}
|
|
}
|
|
return ranges;
|
|
}, [inputMode, message, knownAgentNames]);
|
|
|
|
const attachmentCitationRanges = React.useMemo<HighlightRange[]>(() => {
|
|
if (!message || !message.includes('[') || inputMode === 'shell' || sendableAttachedFiles.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return findAttachmentCitationRanges(
|
|
message,
|
|
sendableAttachedFiles.map((file) => file.filename),
|
|
).map((range) => ({
|
|
...range,
|
|
style: 'mentionFile' as const,
|
|
}));
|
|
}, [inputMode, message, sendableAttachedFiles]);
|
|
|
|
// Combined source-mode highlight: markdown syntax + @mentions. Returns null
|
|
// when there's nothing to highlight so the overlay stays off for plain text.
|
|
const highlightedComposerContent = React.useMemo(() => {
|
|
if (!message || inputMode === 'shell') {
|
|
return null;
|
|
}
|
|
const ranges = [
|
|
...tokenizeMarkdown(message),
|
|
...highlightFencedCode(message),
|
|
...mentionRangesToHighlightRanges(composerMentionRanges),
|
|
...composerCommandRanges,
|
|
...composerSnippetRanges,
|
|
...attachmentCitationRanges,
|
|
];
|
|
return buildHighlightParts(message, ranges);
|
|
}, [attachmentCitationRanges, composerCommandRanges, composerSnippetRanges, composerMentionRanges, inputMode, message]);
|
|
|
|
const sanitizeAttachmentsForSend = React.useCallback(
|
|
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
|
.map((file) => ({
|
|
...file,
|
|
dataUrl: file.source === 'server' && file.serverPath
|
|
? toServerFileUrl(file.serverPath)
|
|
: file.dataUrl,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
|
|
if (!rawText || !rawText.includes('@')) {
|
|
return { sanitizedText: rawText, attachments: [] };
|
|
}
|
|
|
|
const clientDirectory = opencodeClient.getDirectory() || '';
|
|
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
const seenPaths = new Set<string>();
|
|
const attachments: AttachedFile[] = [];
|
|
|
|
const mentionRegex = /@([^\s]+)/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = mentionRegex.exec(rawText)) !== null) {
|
|
const rawMentionPath = match[1];
|
|
const offset = match.index;
|
|
const original = rawText;
|
|
const charBefore = offset > 0 ? original[offset - 1] : null;
|
|
if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
|
|
continue;
|
|
}
|
|
|
|
const mentionPath = String(rawMentionPath || '')
|
|
.trim()
|
|
.replace(/^[`"'<(]+/, '')
|
|
.replace(/[),.;:!?`"'>]+$/g, '');
|
|
if (!mentionPath) {
|
|
continue;
|
|
}
|
|
|
|
if (knownAgentNamesRef.current.has(mentionPath.toLowerCase())) {
|
|
continue;
|
|
}
|
|
|
|
const looksLikeFilePath = isConfirmedFilePath(mentionPath);
|
|
if (!looksLikeFilePath) {
|
|
continue;
|
|
}
|
|
|
|
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
if (!normalizedMentionPath) {
|
|
continue;
|
|
}
|
|
|
|
const serverPath = mentionPath.startsWith('/')
|
|
? mentionPath.replace(/\\/g, '/')
|
|
: root
|
|
? `${root}/${normalizedMentionPath}`
|
|
: null;
|
|
|
|
if (!serverPath) {
|
|
continue;
|
|
}
|
|
|
|
const normalizedServerPath = serverPath.replace(/\/+/g, '/');
|
|
if (seenPaths.has(normalizedServerPath)) {
|
|
continue;
|
|
}
|
|
seenPaths.add(normalizedServerPath);
|
|
|
|
const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
|
|
attachments.push({
|
|
id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
file: new File([], filename, { type: 'text/plain' }),
|
|
filename,
|
|
mimeType: 'text/plain',
|
|
size: 0,
|
|
dataUrl: toServerFileUrl(normalizedServerPath),
|
|
source: 'server',
|
|
serverPath: normalizedServerPath,
|
|
});
|
|
}
|
|
|
|
return {
|
|
sanitizedText: rawText,
|
|
attachments,
|
|
};
|
|
}, [chatSearchDirectory]);
|
|
const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
|
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const prevWasAbortedRef = React.useRef(false);
|
|
|
|
// Issue linking state
|
|
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
|
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
|
const [linkedIssue, setLinkedIssue] = React.useState<{
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
contextText: string;
|
|
author?: { login: string; avatarUrl?: string };
|
|
} | null>(null);
|
|
const [linkedPr, setLinkedPr] = React.useState<{
|
|
number: number;
|
|
title: string;
|
|
url: string;
|
|
head: string;
|
|
base: string;
|
|
includeDiff: boolean;
|
|
instructionsText: string;
|
|
contextText: string;
|
|
author?: { login: string; avatarUrl?: string };
|
|
} | null>(null);
|
|
|
|
// Message queue
|
|
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
|
const queuedMessages = useMessageQueueStore(
|
|
React.useCallback(
|
|
(state) => {
|
|
if (!currentSessionId) return EMPTY_QUEUE;
|
|
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
|
},
|
|
[currentSessionId]
|
|
)
|
|
);
|
|
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
|
|
const clearQueue = useMessageQueueStore((state) => state.clearQueue);
|
|
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
|
|
|
|
// Inline comment drafts
|
|
const draftCount = useInlineCommentDraftStore(
|
|
React.useCallback(
|
|
(state) => {
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
|
if (!sessionKey) return 0;
|
|
return (state.drafts[sessionKey] ?? []).length;
|
|
},
|
|
[currentSessionId, newSessionDraftOpen]
|
|
)
|
|
);
|
|
const draftSourceKey = useInlineCommentDraftStore(
|
|
React.useCallback(
|
|
(state) => {
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
|
const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : [];
|
|
let previewConsole = 0;
|
|
let previewAnnotation = 0;
|
|
let review = 0;
|
|
for (const draft of drafts) {
|
|
if (draft.source === 'preview-console') previewConsole += 1;
|
|
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
|
else review += 1;
|
|
}
|
|
return `${previewConsole}:${previewAnnotation}:${review}`;
|
|
},
|
|
[currentSessionId, newSessionDraftOpen]
|
|
)
|
|
);
|
|
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
|
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
|
const hasDrafts = draftCount > 0;
|
|
const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
|
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
|
if (!sessionKey) return;
|
|
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
|
|
for (const draft of drafts) {
|
|
if (draft.source === source) {
|
|
removeInlineCommentDraft(sessionKey, draft.id);
|
|
}
|
|
}
|
|
}, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]);
|
|
|
|
// User message history for up/down arrow navigation.
|
|
// Keep this on a narrow hook instead of full session message records.
|
|
const userMessageHistory = useUserMessageHistory(currentSessionId ?? "");
|
|
|
|
// Keep messageRef in sync with message state
|
|
React.useEffect(() => {
|
|
messageRef.current = message;
|
|
}, [message]);
|
|
|
|
React.useEffect(() => {
|
|
currentSessionIdForDraftRef.current = currentSessionId;
|
|
}, [currentSessionId]);
|
|
|
|
const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => {
|
|
const key = getDraftKey(sessionId);
|
|
const lastPersisted = lastPersistedDraftRef.current.get(key);
|
|
if (lastPersisted === draft) {
|
|
return;
|
|
}
|
|
|
|
saveStoredDraft(sessionId, draft);
|
|
// Only persist confirmed mentions that are actually present in the draft text
|
|
const activeMentions = new Set<string>();
|
|
for (const mention of confirmedMentionsRef.current) {
|
|
if (draft.includes(`@${mention}`)) {
|
|
activeMentions.add(mention);
|
|
}
|
|
}
|
|
confirmedMentionsRef.current = activeMentions;
|
|
saveConfirmedMentions(sessionId, activeMentions);
|
|
lastPersistedDraftRef.current.set(key, draft);
|
|
}, []);
|
|
|
|
const clearPendingDraftPersist = React.useCallback(() => {
|
|
if (!draftPersistTimerRef.current) {
|
|
return;
|
|
}
|
|
clearTimeout(draftPersistTimerRef.current);
|
|
draftPersistTimerRef.current = null;
|
|
}, []);
|
|
|
|
// Handle initial draft restoration and text selection
|
|
const hasHandledInitialDraftRef = React.useRef(false);
|
|
React.useEffect(() => {
|
|
if (hasHandledInitialDraftRef.current) return;
|
|
hasHandledInitialDraftRef.current = true;
|
|
|
|
const draft = initialDraftRef.current;
|
|
if (!draft) return;
|
|
|
|
if (!persistChatDraft) {
|
|
// Setting disabled - clear the restored draft
|
|
setMessage('');
|
|
try {
|
|
localStorage.removeItem(getDraftKey(initialSessionIdRef.current));
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
} else {
|
|
// Setting enabled - select all text
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.select();
|
|
});
|
|
}
|
|
}, [persistChatDraft]);
|
|
|
|
// Handle session switching: save draft for old session, restore draft for new session
|
|
const prevSessionIdRef = React.useRef(currentSessionId);
|
|
React.useEffect(() => {
|
|
if (prevSessionIdRef.current !== currentSessionId) {
|
|
const oldSessionId = prevSessionIdRef.current;
|
|
prevSessionIdRef.current = currentSessionId;
|
|
setInputMode('normal');
|
|
clearPendingDraftPersist();
|
|
skipNextDraftPersistRef.current = true;
|
|
|
|
if (persistChatDraft) {
|
|
// Save current draft for the session we're leaving
|
|
persistDraftImmediately(oldSessionId, messageRef.current);
|
|
// Restore draft for the session we're entering
|
|
const newDraft = getStoredDraft(currentSessionId);
|
|
setMessage(newDraft);
|
|
confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId);
|
|
if (newDraft) {
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.select();
|
|
});
|
|
}
|
|
} else {
|
|
// Persist disabled: clear input without saving
|
|
setMessage('');
|
|
confirmedMentionsRef.current = new Set();
|
|
}
|
|
}
|
|
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
|
|
|
|
// Focus textarea when new session draft is opened
|
|
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
|
|
React.useEffect(() => {
|
|
if (!prevNewSessionDraftOpenRef.current && newSessionDraftOpen) {
|
|
// New session draft just opened - focus the textarea
|
|
requestAnimationFrame(() => {
|
|
if (isMobile) {
|
|
// On mobile, use preventScroll to avoid viewport jumping
|
|
textareaRef.current?.focus({ preventScroll: true });
|
|
} else {
|
|
textareaRef.current?.focus();
|
|
}
|
|
});
|
|
}
|
|
prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
|
|
}, [newSessionDraftOpen, isMobile]);
|
|
|
|
// Persist chat input draft to localStorage per session (only if setting enabled)
|
|
React.useEffect(() => {
|
|
if (!persistChatDraft) {
|
|
clearPendingDraftPersist();
|
|
persistDraftImmediately(currentSessionId, '');
|
|
return;
|
|
}
|
|
|
|
if (skipNextDraftPersistRef.current) {
|
|
skipNextDraftPersistRef.current = false;
|
|
return;
|
|
}
|
|
|
|
clearPendingDraftPersist();
|
|
const draftSnapshot = message;
|
|
const sessionSnapshot = currentSessionId;
|
|
draftPersistTimerRef.current = setTimeout(() => {
|
|
draftPersistTimerRef.current = null;
|
|
persistDraftImmediately(sessionSnapshot, draftSnapshot);
|
|
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
clearPendingDraftPersist();
|
|
};
|
|
}, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
clearPendingDraftPersist();
|
|
if (persistChatDraft) {
|
|
persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current);
|
|
}
|
|
};
|
|
}, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
|
|
|
|
// Session activity for queue availability and controls
|
|
const { phase: sessionPhase } = useCurrentSessionActivity();
|
|
|
|
const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
textareaRef.current?.blur();
|
|
requestAnimationFrame(() => {
|
|
setMobileControlsPanel(panel);
|
|
});
|
|
}, [isMobile]);
|
|
|
|
// Consume pending input text (e.g., from revert action)
|
|
React.useEffect(() => {
|
|
if (pendingInputText !== null) {
|
|
const pending = consumePendingInputText();
|
|
if (pending?.text) {
|
|
if (pending.mode === 'append') {
|
|
setMessage((prev) => {
|
|
const next = pending.text;
|
|
if (!next.trim()) return prev;
|
|
return appendWithLineBreaks(prev, next);
|
|
});
|
|
} else if (pending.mode === 'append-inline') {
|
|
setMessage((prev) => appendInlineText(prev, pending.text));
|
|
} else {
|
|
setMessage(pending.text);
|
|
}
|
|
// Focus textarea after setting message
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 0);
|
|
}
|
|
}
|
|
}, [pendingInputText, consumePendingInputText]);
|
|
|
|
const hasContent = message.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts;
|
|
const hasQueuedMessages = queuedMessages.length > 0;
|
|
const canSend = hasContent || hasQueuedMessages;
|
|
|
|
const canAbort = sessionPhase !== 'idle';
|
|
|
|
const getCurrentInputSnapshot = React.useCallback(() => {
|
|
const currentMessage = textareaRef.current?.value ?? message;
|
|
return {
|
|
message: currentMessage,
|
|
hasContent: currentMessage.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts,
|
|
};
|
|
}, [hasDrafts, message, sendableAttachedFiles.length]);
|
|
|
|
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
|
type SubmitOptions = {
|
|
queuedOnly?: boolean;
|
|
queuedMessageId?: string;
|
|
};
|
|
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
|
|
|
|
// Add message to queue instead of sending
|
|
const handleQueueMessage = React.useCallback(() => {
|
|
const inputSnapshot = getCurrentInputSnapshot();
|
|
if (!inputSnapshot.hasContent || !currentSessionId) return;
|
|
|
|
const drafts = consumeDrafts(currentSessionId);
|
|
|
|
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
|
if (drafts.length > 0) {
|
|
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
|
}
|
|
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
|
|
|
addToQueue(currentSessionId, {
|
|
content: messageToQueue,
|
|
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
|
sendConfig: currentProviderId && currentModelId ? {
|
|
providerID: currentProviderId,
|
|
modelID: currentModelId,
|
|
agent: currentAgentName ?? undefined,
|
|
variant: currentVariant ?? undefined,
|
|
} : undefined,
|
|
});
|
|
|
|
// Clear input and attachments
|
|
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
|
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
|
// The ref is cleared in handleSubmit after all queued messages are sent.
|
|
setMessage('');
|
|
if (attachmentsToQueue.length > 0) {
|
|
clearAttachedFiles();
|
|
}
|
|
|
|
if (!isMobile) {
|
|
textareaRef.current?.focus();
|
|
}
|
|
}, [getCurrentInputSnapshot, currentSessionId, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
|
|
|
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
|
setMessage(content);
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 0);
|
|
}, []);
|
|
|
|
const handleQueuedMessageSend = React.useCallback((messageId: string) => {
|
|
void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId });
|
|
}, []);
|
|
|
|
const handleOpenAgentPanel = React.useCallback(() => {
|
|
setMobileControlsPanel('agent');
|
|
}, []);
|
|
|
|
const handleToggleExpandedInput = React.useCallback(() => {
|
|
setExpandedInput(!isExpandedInput);
|
|
}, [isExpandedInput, setExpandedInput]);
|
|
|
|
const openIssuePicker = React.useCallback(() => {
|
|
setIssuePickerOpen(true);
|
|
}, []);
|
|
|
|
const openPrPicker = React.useCallback(() => {
|
|
setPrPickerOpen(true);
|
|
}, []);
|
|
|
|
const handleSubmit = async (options?: SubmitOptions) => {
|
|
const queuedOnly = options?.queuedOnly ?? false;
|
|
const queuedMessageId = options?.queuedMessageId;
|
|
const inputSnapshot = getCurrentInputSnapshot();
|
|
const queuedMessagesToSend = queuedMessageId
|
|
? queuedMessages.filter((message) => message.id === queuedMessageId)
|
|
: queuedMessages;
|
|
|
|
if (queuedOnly) {
|
|
if (queuedMessagesToSend.length === 0 || !currentSessionId) return;
|
|
} else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) {
|
|
return;
|
|
}
|
|
|
|
const capturedSendConfig = queuedOnly ? queuedMessagesToSend[0]?.sendConfig : undefined;
|
|
const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId;
|
|
const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId;
|
|
const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName;
|
|
const variantToSend = capturedSendConfig?.variant ?? currentVariant;
|
|
|
|
if (!providerIdToSend || !modelIdToSend) {
|
|
console.warn('Cannot send message: provider or model not selected');
|
|
return;
|
|
}
|
|
|
|
// Build the primary message (first part) and additional parts
|
|
let primaryText = '';
|
|
let primaryAttachments: AttachedFile[] = [];
|
|
let agentMentionName: string | undefined;
|
|
const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = [];
|
|
const availableSkillNames = new Set(useSkillsStore.getState().skills.map((skill) => skill.name));
|
|
const mentionedSkillNames: string[] = [];
|
|
const addMentionedSkills = (text: string) => {
|
|
for (const name of collectInlineSkillMentions(text, availableSkillNames)) {
|
|
if (!mentionedSkillNames.includes(name)) mentionedSkillNames.push(name);
|
|
}
|
|
};
|
|
|
|
// Consume any pending synthetic parts (from conflict resolution, etc.)
|
|
const syntheticParts = consumePendingSyntheticParts();
|
|
|
|
// Process queued messages first
|
|
for (let i = 0; i < queuedMessagesToSend.length; i++) {
|
|
const queuedMsg = queuedMessagesToSend[i];
|
|
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
|
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
|
addMentionedSkills(queuedText);
|
|
|
|
// Use agent mention from first message that has one
|
|
if (!agentMentionName && mention?.name) {
|
|
agentMentionName = mention.name;
|
|
}
|
|
|
|
if (i === 0) {
|
|
// First queued message becomes primary
|
|
primaryText = queuedText;
|
|
primaryAttachments = [
|
|
...sanitizeAttachmentsForSend(queuedMsg.attachments),
|
|
...mentionAttachments,
|
|
];
|
|
} else {
|
|
// Subsequent queued messages become additional parts
|
|
const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments);
|
|
additionalParts.push({
|
|
text: queuedText,
|
|
attachments: [...queuedAttachments, ...mentionAttachments],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add current input (skip for queued-only auto-send)
|
|
if (!queuedOnly && inputSnapshot.hasContent) {
|
|
const messageToSend = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
|
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
|
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
|
const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
|
addMentionedSkills(messageText);
|
|
|
|
if (!agentMentionName && mention?.name) {
|
|
agentMentionName = mention.name;
|
|
}
|
|
|
|
if (queuedMessagesToSend.length === 0) {
|
|
// No queue - current input is primary
|
|
primaryText = messageText;
|
|
primaryAttachments = [...attachmentsToSend, ...mentionAttachments];
|
|
} else {
|
|
// Has queue - current input is additional part
|
|
additionalParts.push({
|
|
text: messageText,
|
|
attachments: [...attachmentsToSend, ...mentionAttachments],
|
|
});
|
|
}
|
|
}
|
|
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
|
let drafts: InlineCommentDraft[] = [];
|
|
if (!queuedOnly && sessionKey) {
|
|
drafts = consumeDrafts(sessionKey);
|
|
}
|
|
|
|
if (drafts.length > 0) {
|
|
if (queuedMessagesToSend.length === 0) {
|
|
primaryText = appendInlineComments(primaryText, drafts);
|
|
} else if (additionalParts.length > 0) {
|
|
const lastPart = additionalParts[additionalParts.length - 1];
|
|
lastPart.text = appendInlineComments(lastPart.text, drafts);
|
|
} else {
|
|
primaryText = appendInlineComments(primaryText, drafts);
|
|
}
|
|
}
|
|
|
|
// Add synthetic parts (from conflict resolution, etc.)
|
|
if (syntheticParts && syntheticParts.length > 0) {
|
|
for (const part of syntheticParts) {
|
|
additionalParts.push({
|
|
text: part.text,
|
|
synthetic: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add linked issue as synthetic part (only the parts with synthetic: true)
|
|
// The text part (synthetic: false) is completely dropped per requirements
|
|
if (linkedIssue) {
|
|
additionalParts.push({
|
|
text: linkedIssue.contextText,
|
|
synthetic: true,
|
|
});
|
|
}
|
|
|
|
if (linkedPr) {
|
|
additionalParts.push({
|
|
text: linkedPr.instructionsText,
|
|
synthetic: true,
|
|
});
|
|
additionalParts.push({
|
|
text: linkedPr.contextText,
|
|
synthetic: true,
|
|
});
|
|
}
|
|
|
|
const skillMentionInstruction = buildSkillMentionInstruction(mentionedSkillNames);
|
|
if (skillMentionInstruction) {
|
|
additionalParts.push({
|
|
text: skillMentionInstruction,
|
|
synthetic: true,
|
|
});
|
|
}
|
|
|
|
if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return;
|
|
|
|
// Clear queue and input
|
|
if (currentSessionId && queuedMessageId) {
|
|
removeFromQueue(currentSessionId, queuedMessageId);
|
|
} else if (currentSessionId && hasQueuedMessages) {
|
|
clearQueue(currentSessionId);
|
|
}
|
|
if (!queuedOnly) {
|
|
setMessage('');
|
|
confirmedMentionsRef.current.clear();
|
|
// Clear per-session draft on submit
|
|
saveStoredDraft(currentSessionId, '');
|
|
saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current);
|
|
// Reset message history navigation state
|
|
setHistoryIndex(-1);
|
|
setDraftMessage('');
|
|
if (attachedFiles.length > 0) {
|
|
clearAttachedFiles();
|
|
}
|
|
// Close expanded input overlay when submitting
|
|
setExpandedInput(false);
|
|
}
|
|
|
|
if (isMobile) {
|
|
textareaRef.current?.blur();
|
|
}
|
|
|
|
// Handle local slash commands only in normal mode
|
|
const normalizedCommand = primaryText.trimStart();
|
|
if (inputMode === 'normal' && normalizedCommand.startsWith('/')) {
|
|
const commandName = normalizedCommand
|
|
.slice(1)
|
|
.trim()
|
|
.split(/\s+/)[0]
|
|
?.toLowerCase();
|
|
|
|
if (commandName === 'undo' && currentSessionId) {
|
|
await useSessionUIStore.getState().handleSlashUndo(currentSessionId);
|
|
scrollToBottom?.();
|
|
return;
|
|
}
|
|
else if (commandName === 'redo' && currentSessionId) {
|
|
await useSessionUIStore.getState().handleSlashRedo(currentSessionId);
|
|
scrollToBottom?.();
|
|
return;
|
|
}
|
|
else if (commandName === 'timeline' && currentSessionId) {
|
|
setTimelineDialogOpen(true);
|
|
return;
|
|
}
|
|
else if (commandName === 'compact' && currentSessionId) {
|
|
try {
|
|
await sessionActions.waitForConnectionOrThrow();
|
|
const { opencodeClient } = await import('@/lib/opencode/client');
|
|
const sdk = opencodeClient.getSdkClient();
|
|
const configState = useConfigStore.getState();
|
|
await sdk.session.summarize({
|
|
sessionID: currentSessionId,
|
|
modelID: configState.currentModelId || '',
|
|
providerID: configState.currentProviderId || '',
|
|
});
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
|
|
}
|
|
return;
|
|
}
|
|
else if (commandName === 'summary' && currentSessionId) {
|
|
try {
|
|
await sessionActions.waitForConnectionOrThrow();
|
|
// Everything after `/summary ` is an optional topic hint
|
|
// the user wants the summary focused on.
|
|
const topic = normalizedCommand.replace(/^\/summary\b/i, '').trim();
|
|
const topicLine = topic ? ` focused on: ${topic}` : '';
|
|
const topicBlock = topic
|
|
? `The user asked you to focus this summary on: ${topic}. Prioritize that topic; mention unrelated threads only in passing.`
|
|
: '';
|
|
const visibleText = await renderMagicPrompt('session.summary.visible', { topic_line: topicLine });
|
|
const instructionsText = await renderMagicPrompt('session.summary.instructions', { topic_block: topicBlock });
|
|
await sendMessage(
|
|
visibleText,
|
|
providerIdToSend,
|
|
modelIdToSend,
|
|
agentNameToSend,
|
|
[],
|
|
agentMentionName,
|
|
[{ text: instructionsText, synthetic: true }],
|
|
variantToSend,
|
|
inputMode,
|
|
);
|
|
scrollToBottom?.();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed'));
|
|
}
|
|
return;
|
|
}
|
|
else if (commandName === 'workspace-review' && (currentSessionId || newSessionDraftOpen)) {
|
|
try {
|
|
await sessionActions.waitForConnectionOrThrow();
|
|
const visibleText = await renderMagicPrompt('session.review.visible');
|
|
const instructionsText = await renderMagicPrompt('session.review.instructions');
|
|
await sendMessage(
|
|
visibleText,
|
|
providerIdToSend,
|
|
modelIdToSend,
|
|
agentNameToSend,
|
|
[],
|
|
agentMentionName,
|
|
[{ text: instructionsText, synthetic: true }],
|
|
variantToSend,
|
|
inputMode,
|
|
);
|
|
scrollToBottom?.();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed'));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const currentSessionDirectory = currentSessionId
|
|
? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory
|
|
: currentDirectory;
|
|
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
|
|
if (shouldAddResponseStyle) {
|
|
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
|
|
if (responseStyleInstruction) {
|
|
additionalParts.push({
|
|
text: wrapSystemReminder(responseStyleInstruction),
|
|
synthetic: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
const expandText = useSnippetsStore.getState().expandText;
|
|
primaryText = await expandText(primaryText);
|
|
for (const part of additionalParts) {
|
|
if (!part.synthetic) part.text = await expandText(part.text);
|
|
}
|
|
} catch (error) {
|
|
console.warn('[ChatInput] Failed to expand snippets, sending original text:', error);
|
|
}
|
|
|
|
// Collect all attachments for error recovery
|
|
const allAttachments = [
|
|
...primaryAttachments,
|
|
...additionalParts.flatMap(p => p.attachments ?? []),
|
|
];
|
|
|
|
const sendPromise = sendMessage(
|
|
primaryText,
|
|
providerIdToSend,
|
|
modelIdToSend,
|
|
agentNameToSend,
|
|
primaryAttachments,
|
|
agentMentionName,
|
|
additionalParts.length > 0 ? additionalParts : undefined,
|
|
variantToSend,
|
|
inputMode
|
|
);
|
|
|
|
if (typeof window === 'undefined') {
|
|
scrollToBottom?.();
|
|
} else {
|
|
window.requestAnimationFrame(() => {
|
|
scrollToBottom?.();
|
|
});
|
|
}
|
|
|
|
void sendPromise.then(() => {
|
|
// Clear linked issue after successful message send
|
|
if (linkedIssue) {
|
|
setLinkedIssue(null);
|
|
}
|
|
if (linkedPr) {
|
|
setLinkedPr(null);
|
|
}
|
|
}).catch((error: unknown) => {
|
|
const rawMessage =
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === 'string'
|
|
? error
|
|
: String(error ?? '');
|
|
const normalized = rawMessage.toLowerCase();
|
|
|
|
console.error('Message send failed:', rawMessage || error);
|
|
|
|
const isSoftNetworkError =
|
|
normalized.includes('timeout') ||
|
|
normalized.includes('timed out') ||
|
|
normalized.includes('may still be processing') ||
|
|
normalized.includes('being processed') ||
|
|
normalized.includes('failed to fetch') ||
|
|
normalized.includes('networkerror') ||
|
|
normalized.includes('network error') ||
|
|
normalized.includes('gateway timeout') ||
|
|
normalized === 'failed to send message';
|
|
|
|
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
|
|
toast.error(t('chat.chatInput.toast.attachmentsTooLarge'));
|
|
if (allAttachments.length > 0) {
|
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isSoftNetworkError) {
|
|
if (allAttachments.length > 0) {
|
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
|
toast.error(t('chat.chatInput.toast.sendAttachmentsFailed'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (allAttachments.length > 0) {
|
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
|
}
|
|
toast.error(rawMessage || t('chat.chatInput.toast.messageSendFailed'));
|
|
});
|
|
|
|
if (!isMobile) {
|
|
textareaRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
// Update ref with latest handleSubmit on every render
|
|
handleSubmitRef.current = handleSubmit;
|
|
|
|
// Primary action for send button - respects queue mode setting
|
|
const handlePrimaryAction = React.useCallback(() => {
|
|
const inputSnapshot = getCurrentInputSnapshot();
|
|
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && sessionPhase !== 'idle';
|
|
if (queueModeEnabled && canQueue) {
|
|
handleQueueMessage();
|
|
} else {
|
|
void handleSubmitRef.current();
|
|
}
|
|
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Early return during IME composition to prevent interference with autocomplete.
|
|
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
|
|
if (isIMECompositionEvent(e)) return;
|
|
|
|
if (inputMode === 'shell' && e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setInputMode('normal');
|
|
return;
|
|
}
|
|
|
|
if (inputMode === 'shell' && e.key === 'Backspace' && message.length === 0) {
|
|
e.preventDefault();
|
|
setInputMode('normal');
|
|
return;
|
|
}
|
|
|
|
if ((e.key === 'Backspace' || e.key === 'Delete') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
const textarea = textareaRef.current;
|
|
const selectionStart = textarea?.selectionStart ?? message.length;
|
|
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
|
const hasCollapsedSelection = selectionStart === selectionEnd;
|
|
|
|
if (hasCollapsedSelection) {
|
|
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
|
|
if (probeIndex >= 0 && probeIndex < message.length) {
|
|
let tokenStart = probeIndex;
|
|
while (tokenStart > 0 && !/\s/.test(message[tokenStart - 1])) {
|
|
tokenStart -= 1;
|
|
}
|
|
|
|
let tokenEnd = probeIndex + 1;
|
|
while (tokenEnd < message.length && !/\s/.test(message[tokenEnd])) {
|
|
tokenEnd += 1;
|
|
}
|
|
|
|
const token = message.slice(tokenStart, tokenEnd);
|
|
const mentionContent = token.slice(1);
|
|
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
|
|
&& !knownAgentNamesRef.current.has(mentionContent.toLowerCase())
|
|
&& isConfirmedFilePath(mentionContent);
|
|
|
|
if (looksLikeFileMention) {
|
|
confirmedMentionsRef.current.delete(mentionContent);
|
|
const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd;
|
|
const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`;
|
|
e.preventDefault();
|
|
setMessage(nextMessage);
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = tokenStart;
|
|
textareaRef.current.selectionEnd = tokenStart;
|
|
}
|
|
adjustTextareaHeight();
|
|
});
|
|
updateAutocompleteState(nextMessage, tokenStart);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showCommandAutocomplete && commandRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
commandRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (showSkillAutocomplete && skillRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
skillRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (showSnippetAutocomplete && snippetRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
snippetRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (showFileMention && mentionRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
mentionRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isDesktopExpanded && e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setExpandedInput(false);
|
|
return;
|
|
}
|
|
|
|
const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift')
|
|
? normalizeCombo(`shift+${cycleAgentShortcut}`)
|
|
: '';
|
|
const cycleAgentDirection = cycleAgentBackwardShortcut && eventMatchesShortcut(e, cycleAgentBackwardShortcut)
|
|
? -1
|
|
: eventMatchesShortcut(e, cycleAgentShortcut)
|
|
? 1
|
|
: 0;
|
|
|
|
if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleCycleAgent(cycleAgentDirection);
|
|
return;
|
|
}
|
|
|
|
// Handle ArrowUp/ArrowDown for message history navigation
|
|
// ArrowUp: only when cursor at start (position 0) or input is empty
|
|
// ArrowDown: also works when cursor at end (to cycle forward through history)
|
|
const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showSnippetAutocomplete || showFileMention;
|
|
const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
|
|
const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
|
|
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
|
|
const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd);
|
|
|
|
// Markdown-aware auto-pairing (source mode), normal input only.
|
|
if (inputMode === 'normal' && !isAnyAutocompleteOpen && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
const ta = textareaRef.current;
|
|
const selStart = ta?.selectionStart ?? -1;
|
|
const selEnd = ta?.selectionEnd ?? -1;
|
|
|
|
if (ta && selStart >= 0) {
|
|
const applyEdit = (next: string, caretStart: number, caretEnd: number) => {
|
|
e.preventDefault();
|
|
setMessage(next);
|
|
requestAnimationFrame(() => {
|
|
const current = textareaRef.current;
|
|
if (current) {
|
|
current.selectionStart = caretStart;
|
|
current.selectionEnd = caretEnd;
|
|
}
|
|
adjustTextareaHeight();
|
|
});
|
|
updateAutocompleteState(next, caretEnd);
|
|
};
|
|
|
|
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
|
|
const WRAP_PAIRS: Record<string, [string, string]> = {
|
|
'`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'],
|
|
'(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'],
|
|
'"': ['"', '"'], "'": ["'", "'"],
|
|
};
|
|
if (selEnd > selStart && WRAP_PAIRS[e.key]) {
|
|
const [open, close] = WRAP_PAIRS[e.key];
|
|
const selected = message.slice(selStart, selEnd);
|
|
const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`;
|
|
applyEdit(next, selStart + open.length, selEnd + open.length);
|
|
return;
|
|
}
|
|
|
|
// Typing the third backtick at line start expands into a fenced
|
|
// code block with the caret on the empty middle line (Slack-like).
|
|
if (e.key === '`' && selStart === selEnd) {
|
|
const before = message.slice(0, selStart);
|
|
if (/(^|\n)``$/.test(before)) {
|
|
const after = message.slice(selEnd);
|
|
const next = `${before}\`\n\n\`\`\`${after}`;
|
|
const caret = before.length + 2; // after the completed ``` and first newline
|
|
applyEdit(next, caret, caret);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) {
|
|
e.preventDefault();
|
|
if (historyIndex === -1) {
|
|
// Entering history mode - save current input as draft
|
|
setDraftMessage(message);
|
|
setHistoryIndex(0);
|
|
setMessage(userMessageHistory[0]);
|
|
} else if (historyIndex < userMessageHistory.length - 1) {
|
|
// Navigate to older message
|
|
const newIndex = historyIndex + 1;
|
|
setHistoryIndex(newIndex);
|
|
setMessage(userMessageHistory[newIndex]);
|
|
}
|
|
// Move cursor to start after history navigation
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.setSelectionRange(0, 0);
|
|
});
|
|
// If at oldest message, do nothing
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0) {
|
|
e.preventDefault();
|
|
if (historyIndex === 0) {
|
|
// Exit history mode - restore draft
|
|
setHistoryIndex(-1);
|
|
setMessage(draftMessage);
|
|
setDraftMessage('');
|
|
} else {
|
|
// Navigate to newer message
|
|
const newIndex = historyIndex - 1;
|
|
setHistoryIndex(newIndex);
|
|
setMessage(userMessageHistory[newIndex]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle Enter/Ctrl+Enter based on queue mode
|
|
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
|
|
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
|
|
|
// Queue mode: Enter queues, Ctrl+Enter sends
|
|
// Normal mode: Enter sends, Ctrl+Enter queues
|
|
// Note: Queueing only works when there's an existing session (currentSessionId)
|
|
// For new sessions (draft), always send immediately
|
|
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
|
|
|
|
if (queueModeEnabled) {
|
|
if (isCtrlEnter || !canQueue) {
|
|
// Ctrl+Enter sends, or Enter when can't queue (new session)
|
|
handleSubmit();
|
|
} else {
|
|
// Enter queues when we have a session
|
|
handleQueueMessage();
|
|
}
|
|
} else {
|
|
if (isCtrlEnter && canQueue) {
|
|
// Ctrl+Enter queues when we have a session
|
|
handleQueueMessage();
|
|
} else {
|
|
// Enter sends
|
|
handleSubmit();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const measureCaretInTextarea = React.useCallback((textarea: HTMLTextAreaElement, cursorPosition: number) => {
|
|
const doc = textarea.ownerDocument;
|
|
const win = doc.defaultView;
|
|
if (!win) return null;
|
|
|
|
const style = win.getComputedStyle(textarea);
|
|
const mirror = doc.createElement('div');
|
|
const mirrorStyle = mirror.style;
|
|
|
|
mirrorStyle.position = 'absolute';
|
|
mirrorStyle.visibility = 'hidden';
|
|
mirrorStyle.pointerEvents = 'none';
|
|
mirrorStyle.whiteSpace = 'pre-wrap';
|
|
mirrorStyle.wordWrap = 'break-word';
|
|
mirrorStyle.overflow = 'hidden';
|
|
mirrorStyle.left = '-9999px';
|
|
mirrorStyle.top = '0';
|
|
|
|
mirrorStyle.width = `${textarea.clientWidth}px`;
|
|
mirrorStyle.font = style.font;
|
|
mirrorStyle.fontSize = style.fontSize;
|
|
mirrorStyle.fontFamily = style.fontFamily;
|
|
mirrorStyle.fontWeight = style.fontWeight;
|
|
mirrorStyle.fontStyle = style.fontStyle;
|
|
mirrorStyle.fontVariant = style.fontVariant;
|
|
mirrorStyle.letterSpacing = style.letterSpacing;
|
|
mirrorStyle.textTransform = style.textTransform;
|
|
mirrorStyle.textIndent = style.textIndent;
|
|
mirrorStyle.padding = style.padding;
|
|
mirrorStyle.border = style.border;
|
|
mirrorStyle.boxSizing = style.boxSizing;
|
|
mirrorStyle.lineHeight = style.lineHeight;
|
|
mirrorStyle.tabSize = style.tabSize;
|
|
|
|
mirror.textContent = textarea.value.slice(0, cursorPosition);
|
|
const marker = doc.createElement('span');
|
|
marker.textContent = textarea.value.slice(cursorPosition, cursorPosition + 1) || ' ';
|
|
mirror.appendChild(marker);
|
|
|
|
doc.body.appendChild(mirror);
|
|
const top = marker.offsetTop;
|
|
const left = marker.offsetLeft;
|
|
doc.body.removeChild(mirror);
|
|
|
|
return { top, left };
|
|
}, []);
|
|
|
|
const updateAutocompleteOverlayPosition = React.useCallback(() => {
|
|
if (!isDesktopExpanded) {
|
|
setAutocompleteOverlayPosition(null);
|
|
return;
|
|
}
|
|
|
|
if (!showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) {
|
|
setAutocompleteOverlayPosition(null);
|
|
return;
|
|
}
|
|
|
|
const textarea = textareaRef.current;
|
|
const container = dropZoneRef.current;
|
|
if (!textarea || !container) return;
|
|
|
|
const cursor = textarea.selectionStart ?? message.length;
|
|
const caret = measureCaretInTextarea(textarea, cursor);
|
|
if (!caret) return;
|
|
|
|
const textareaRect = textarea.getBoundingClientRect();
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
const caretY = textareaRect.top - containerRect.top + (caret.top - textarea.scrollTop);
|
|
const caretX = textareaRect.left - containerRect.left + (caret.left - textarea.scrollLeft);
|
|
|
|
const popupMargin = 8;
|
|
const estimatedPopupHeight = 260;
|
|
const spaceAbove = caretY - popupMargin;
|
|
const spaceBelow = containerRect.height - caretY - popupMargin;
|
|
const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above';
|
|
|
|
const desiredWidth = showFileMention ? 520 : showCommandAutocomplete || showSnippetAutocomplete ? 450 : 360;
|
|
const clampedLeft = Math.max(
|
|
popupMargin,
|
|
Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin)
|
|
);
|
|
|
|
const maxHeight = Math.max(120, Math.min(estimatedPopupHeight, place === 'below' ? spaceBelow : spaceAbove));
|
|
|
|
setAutocompleteOverlayPosition({
|
|
top: place === 'below' ? caretY + 22 : caretY - 6,
|
|
left: clampedLeft,
|
|
place,
|
|
maxHeight,
|
|
});
|
|
}, [
|
|
isDesktopExpanded,
|
|
measureCaretInTextarea,
|
|
message.length,
|
|
showCommandAutocomplete,
|
|
showFileMention,
|
|
showSnippetAutocomplete,
|
|
showSkillAutocomplete,
|
|
]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
updateAutocompleteOverlayPosition();
|
|
}, [
|
|
updateAutocompleteOverlayPosition,
|
|
message,
|
|
showCommandAutocomplete,
|
|
showSkillAutocomplete,
|
|
showSnippetAutocomplete,
|
|
showFileMention,
|
|
isDesktopExpanded,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isDesktopExpanded) return;
|
|
const onResize = () => updateAutocompleteOverlayPosition();
|
|
window.addEventListener('resize', onResize);
|
|
return () => {
|
|
window.removeEventListener('resize', onResize);
|
|
};
|
|
}, [isDesktopExpanded, updateAutocompleteOverlayPosition]);
|
|
|
|
const startAbortIndicator = React.useCallback(() => {
|
|
if (abortTimeoutRef.current) {
|
|
clearTimeout(abortTimeoutRef.current);
|
|
abortTimeoutRef.current = null;
|
|
}
|
|
|
|
setShowAbortStatus(true);
|
|
|
|
abortTimeoutRef.current = setTimeout(() => {
|
|
setShowAbortStatus(false);
|
|
abortTimeoutRef.current = null;
|
|
}, 1800);
|
|
}, []);
|
|
|
|
const handleAbort = React.useCallback(() => {
|
|
clearAbortPrompt();
|
|
startAbortIndicator();
|
|
|
|
void abortCurrentOperation(currentSessionId || undefined);
|
|
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]);
|
|
|
|
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
|
|
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
|
|
if (!nextAgentName) return;
|
|
|
|
setAgent(nextAgentName);
|
|
|
|
if (currentSessionId) {
|
|
saveSessionAgentSelection(currentSessionId, nextAgentName);
|
|
}
|
|
}, [agents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]);
|
|
|
|
const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) {
|
|
return;
|
|
}
|
|
|
|
const previousScrollTop = textarea.scrollTop;
|
|
|
|
if (isDesktopExpanded) {
|
|
textarea.style.height = '100%';
|
|
textarea.style.maxHeight = 'none';
|
|
setTextareaSize(null);
|
|
if (textarea.scrollTop !== previousScrollTop) {
|
|
textarea.scrollTop = previousScrollTop;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (options?.allowShrink ?? true) {
|
|
textarea.style.height = 'auto';
|
|
}
|
|
|
|
const view = textarea.ownerDocument?.defaultView;
|
|
const computedStyle = view ? view.getComputedStyle(textarea) : null;
|
|
const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN;
|
|
const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN;
|
|
const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN;
|
|
const fallbackLineHeight = 22;
|
|
const fallbackPadding = 16;
|
|
const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom)
|
|
? fallbackPadding
|
|
: paddingTop + paddingBottom;
|
|
const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight;
|
|
const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal;
|
|
const scrollHeight = textarea.scrollHeight || textarea.offsetHeight;
|
|
const nextHeight = Math.min(scrollHeight, maxHeight);
|
|
|
|
textarea.style.height = `${nextHeight}px`;
|
|
textarea.style.maxHeight = `${maxHeight}px`;
|
|
if (textarea.scrollTop !== previousScrollTop) {
|
|
textarea.scrollTop = previousScrollTop;
|
|
}
|
|
|
|
setTextareaSize((prev) => {
|
|
if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
|
|
return prev;
|
|
}
|
|
return { height: nextHeight, maxHeight };
|
|
});
|
|
}, [isDesktopExpanded]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
const allowShrink = message.length < previousMessageLengthRef.current;
|
|
previousMessageLengthRef.current = message.length;
|
|
adjustTextareaHeight({ allowShrink });
|
|
}, [adjustTextareaHeight, message, isMobile]);
|
|
|
|
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
|
if (inputMode === 'shell') {
|
|
setShowCommandAutocomplete(false);
|
|
setShowFileMention(false);
|
|
setShowSkillAutocomplete(false);
|
|
setShowSnippetAutocomplete(false);
|
|
return;
|
|
}
|
|
|
|
if (value.startsWith('/')) {
|
|
const firstSpace = value.indexOf(' ');
|
|
const firstNewline = value.indexOf('\n');
|
|
const commandEnd = Math.min(
|
|
firstSpace === -1 ? value.length : firstSpace,
|
|
firstNewline === -1 ? value.length : firstNewline
|
|
);
|
|
|
|
if (cursorPosition <= commandEnd && firstSpace === -1) {
|
|
const commandText = value.substring(1, commandEnd);
|
|
setCommandQuery(commandText);
|
|
setShowCommandAutocomplete(true);
|
|
setShowFileMention(false);
|
|
setShowSkillAutocomplete(false);
|
|
setShowSnippetAutocomplete(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setShowCommandAutocomplete(false);
|
|
|
|
const textBeforeCursor = value.substring(0, cursorPosition);
|
|
|
|
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
|
if (lastSlashSymbol !== -1) {
|
|
const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null;
|
|
const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1);
|
|
const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n');
|
|
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
|
|
|
if (isWordBoundary && !hasSeparator) {
|
|
setSkillQuery(textAfterSlash);
|
|
setShowSkillAutocomplete(true);
|
|
setShowFileMention(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setShowSkillAutocomplete(false);
|
|
setSkillQuery('');
|
|
|
|
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
|
if (lastHashSymbol !== -1) {
|
|
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
|
|
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
|
|
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
|
if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) {
|
|
setSnippetQuery(textAfterHash);
|
|
setShowSnippetAutocomplete(true);
|
|
setShowFileMention(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setShowSnippetAutocomplete(false);
|
|
|
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
|
if (lastAtSymbol !== -1) {
|
|
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
|
|
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
|
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
|
if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
|
setMentionQuery(textAfterAt);
|
|
setShowFileMention(true);
|
|
} else {
|
|
setShowFileMention(false);
|
|
}
|
|
} else {
|
|
setShowFileMention(false);
|
|
}
|
|
}, [inputMode, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
|
|
|
|
const insertTextAtSelection = React.useCallback((text: string) => {
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) {
|
|
const nextValue = message + text;
|
|
setMessage(nextValue);
|
|
updateAutocompleteState(nextValue, nextValue.length);
|
|
requestAnimationFrame(() => adjustTextareaHeight());
|
|
return;
|
|
}
|
|
|
|
const start = textarea.selectionStart ?? message.length;
|
|
const end = textarea.selectionEnd ?? message.length;
|
|
const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`;
|
|
setMessage(nextValue);
|
|
const cursorPosition = start + text.length;
|
|
|
|
requestAnimationFrame(() => {
|
|
const currentTextarea = textareaRef.current;
|
|
if (currentTextarea) {
|
|
currentTextarea.selectionStart = cursorPosition;
|
|
currentTextarea.selectionEnd = cursorPosition;
|
|
}
|
|
adjustTextareaHeight();
|
|
});
|
|
|
|
updateAutocompleteState(nextValue, cursorPosition);
|
|
}, [adjustTextareaHeight, message, updateAutocompleteState]);
|
|
|
|
const clearDropTextSuppression = React.useCallback(() => {
|
|
suppressNextFileDropTextInsertRef.current = false;
|
|
pendingDroppedAbsolutePathsRef.current = [];
|
|
if (suppressNextFileDropTextInsertTimeoutRef.current) {
|
|
clearTimeout(suppressNextFileDropTextInsertTimeoutRef.current);
|
|
suppressNextFileDropTextInsertTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const scheduleDropTextSuppressionExpiry = React.useCallback(() => {
|
|
if (suppressNextFileDropTextInsertTimeoutRef.current) {
|
|
clearTimeout(suppressNextFileDropTextInsertTimeoutRef.current);
|
|
}
|
|
suppressNextFileDropTextInsertTimeoutRef.current = setTimeout(() => {
|
|
clearDropTextSuppression();
|
|
}, 700);
|
|
}, [clearDropTextSuppression]);
|
|
|
|
const handleBeforeInput = React.useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
|
if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) {
|
|
return;
|
|
}
|
|
|
|
const nativeInputEvent = e.nativeEvent as InputEvent | undefined;
|
|
if (nativeInputEvent?.inputType === 'insertFromDrop') {
|
|
e.preventDefault();
|
|
clearDropTextSuppression();
|
|
}
|
|
}, [clearDropTextSuppression]);
|
|
|
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const nativeInputEvent = e.nativeEvent as InputEvent | undefined;
|
|
if (isVSCodeRuntime() && suppressNextFileDropTextInsertRef.current) {
|
|
const candidateAbsolutePaths = pendingDroppedAbsolutePathsRef.current;
|
|
const isLikelyDropTextInsertion = nativeInputEvent?.inputType === 'insertFromDrop'
|
|
|| candidateAbsolutePaths.some((path) => path.length > 0 && e.target.value.includes(path));
|
|
|
|
if (isLikelyDropTextInsertion) {
|
|
clearDropTextSuppression();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const value = e.target.value;
|
|
const cursorPosition = e.target.selectionStart ?? value.length;
|
|
|
|
if (inputMode === 'normal' && value.startsWith('!')) {
|
|
const shellCommand = value.slice(1);
|
|
const nextCursor = Math.max(0, cursorPosition - 1);
|
|
setInputMode('shell');
|
|
setMessage(shellCommand);
|
|
adjustTextareaHeight();
|
|
setShowCommandAutocomplete(false);
|
|
setShowSkillAutocomplete(false);
|
|
setShowFileMention(false);
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
setMessage(value);
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(value, cursorPosition);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
clearDropTextSuppression();
|
|
};
|
|
}, [clearDropTextSuppression]);
|
|
|
|
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
// Pasting a URL over a selection wraps it as a markdown link:
|
|
// [selected text](pasted url).
|
|
if (inputMode === 'normal' && (currentSessionId || newSessionDraftOpen)) {
|
|
const ta = textareaRef.current;
|
|
const selStart = ta?.selectionStart ?? -1;
|
|
const selEnd = ta?.selectionEnd ?? -1;
|
|
if (ta && selEnd > selStart) {
|
|
const clipboardText = e.clipboardData.getData('text');
|
|
const url = clipboardText.trim();
|
|
const selected = message.slice(selStart, selEnd);
|
|
if (
|
|
PASTE_LINK_URL_PATTERN.test(url)
|
|
&& !/\s/.test(url)
|
|
&& selected.trim().length > 0
|
|
&& !selected.includes('](')
|
|
) {
|
|
e.preventDefault();
|
|
const next = `${message.slice(0, selStart)}[${selected}](${url})${message.slice(selEnd)}`;
|
|
const caret = selStart + 1 + selected.length + 2 + url.length + 1;
|
|
setMessage(next);
|
|
requestAnimationFrame(() => {
|
|
const current = textareaRef.current;
|
|
if (current) {
|
|
current.selectionStart = caret;
|
|
current.selectionEnd = caret;
|
|
}
|
|
adjustTextareaHeight();
|
|
});
|
|
updateAutocompleteState(next, caret);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const fileMap = new Map<string, File>();
|
|
|
|
Array.from(e.clipboardData.files || []).forEach(file => {
|
|
if (file.type.startsWith('image/')) {
|
|
fileMap.set(`${file.name}-${file.size}`, file);
|
|
}
|
|
});
|
|
|
|
Array.from(e.clipboardData.items || []).forEach(item => {
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
fileMap.set(`${file.name}-${file.size}`, file);
|
|
}
|
|
}
|
|
});
|
|
|
|
const imageFiles = Array.from(fileMap.values());
|
|
if (imageFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!currentSessionId && !newSessionDraftOpen) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
const pastedText = e.clipboardData.getData('text');
|
|
const assignedFilenames = assignImageAttachmentFilenames(
|
|
imageFiles,
|
|
[
|
|
...attachedFiles.map((file) => file.filename),
|
|
...pendingPastedAttachmentFilenamesRef.current,
|
|
],
|
|
);
|
|
const citationText = buildAttachmentCitationText(assignedFilenames);
|
|
const textarea = textareaRef.current;
|
|
const selectionStart = textarea?.selectionStart ?? message.length;
|
|
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
|
const insertionText = withInlineInsertionBoundaries(
|
|
buildImagePasteInsertion(pastedText, citationText),
|
|
message.slice(0, selectionStart),
|
|
message.slice(selectionEnd),
|
|
);
|
|
|
|
insertTextAtSelection(insertionText);
|
|
|
|
for (let index = 0; index < imageFiles.length; index += 1) {
|
|
const filename = assignedFilenames[index];
|
|
const file = renameFileForAttachmentCitation(imageFiles[index], filename);
|
|
pendingPastedAttachmentFilenamesRef.current.add(filename);
|
|
try {
|
|
await addAttachedFile(file);
|
|
} catch (error) {
|
|
console.error('Clipboard image attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed'));
|
|
} finally {
|
|
pendingPastedAttachmentFilenamesRef.current.delete(filename);
|
|
}
|
|
}
|
|
}, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
|
|
|
|
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
|
|
|
|
const cursorPosition = textareaRef.current?.selectionStart || 0;
|
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
|
|
|
const mentionPath = (file.relativePath && file.relativePath.trim().length > 0)
|
|
? file.relativePath.trim()
|
|
: (toProjectRelativeMentionPath(file.path) || file.name);
|
|
|
|
confirmedMentionsRef.current.add(mentionPath);
|
|
|
|
if (lastAtSymbol !== -1) {
|
|
const newMessage =
|
|
message.substring(0, lastAtSymbol) +
|
|
`@${mentionPath} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
const nextCursor = lastAtSymbol + mentionPath.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
} else if (textareaRef.current) {
|
|
const newMessage =
|
|
message.substring(0, cursorPosition) +
|
|
`@${mentionPath} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
const nextCursor = cursorPosition + mentionPath.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
}
|
|
|
|
setShowFileMention(false);
|
|
setMentionQuery('');
|
|
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const handleAgentSelect = (agentName: string) => {
|
|
const textarea = textareaRef.current;
|
|
const cursorPosition = textarea?.selectionStart ?? message.length;
|
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
|
|
|
if (lastAtSymbol !== -1) {
|
|
const newMessage =
|
|
message.substring(0, lastAtSymbol) +
|
|
`@${agentName} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
|
|
const nextCursor = lastAtSymbol + agentName.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
} else if (textareaRef.current) {
|
|
const newMessage =
|
|
message.substring(0, cursorPosition) +
|
|
`@${agentName} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
|
|
const nextCursor = cursorPosition + agentName.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
}
|
|
|
|
setShowFileMention(false);
|
|
setMentionQuery('');
|
|
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const handleSkillSelect = (skillName: string) => {
|
|
const textarea = textareaRef.current;
|
|
const cursorPosition = textarea?.selectionStart ?? message.length;
|
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
|
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
|
|
|
if (lastSlashSymbol !== -1) {
|
|
const newMessage =
|
|
message.substring(0, lastSlashSymbol) +
|
|
`/${skillName} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
|
|
const nextCursor = lastSlashSymbol + skillName.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
}
|
|
|
|
setShowSkillAutocomplete(false);
|
|
setSkillQuery('');
|
|
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const handleSnippetSelect = (_snippet: unknown, trigger: string) => {
|
|
const textarea = textareaRef.current;
|
|
const cursorPosition = textarea?.selectionStart ?? message.length;
|
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
|
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
|
const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition;
|
|
const newMessage = `${message.substring(0, startIndex)}#${trigger} ${message.substring(cursorPosition)}`;
|
|
setMessage(newMessage);
|
|
const nextCursor = startIndex + trigger.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
setShowSnippetAutocomplete(false);
|
|
setSnippetQuery('');
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const handleCommandSelect = (command: CommandInfo) => {
|
|
|
|
setMessage(`/${command.name} `);
|
|
|
|
const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command };
|
|
if (textareaElement) {
|
|
textareaElement._commandMetadata = command;
|
|
}
|
|
|
|
setShowCommandAutocomplete(false);
|
|
setCommandQuery('');
|
|
|
|
const refocus = () => {
|
|
if (textareaRef.current) {
|
|
try {
|
|
textareaRef.current.focus({ preventScroll: true });
|
|
} catch {
|
|
textareaRef.current.focus();
|
|
}
|
|
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(() => {
|
|
refocus();
|
|
requestAnimationFrame(refocus);
|
|
});
|
|
setTimeout(refocus, 60);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (currentSessionId && textareaRef.current && !isMobile) {
|
|
textareaRef.current.focus();
|
|
}
|
|
}, [currentSessionId, isMobile]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileControlsPanel(null);
|
|
}
|
|
}, [isMobile]);
|
|
|
|
React.useEffect(() => {
|
|
if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) {
|
|
clearAbortPrompt();
|
|
}
|
|
}, [abortPromptSessionId, currentSessionId, clearAbortPrompt]);
|
|
|
|
React.useEffect(() => {
|
|
canAcceptDropRef.current = Boolean(currentSessionId || newSessionDraftOpen);
|
|
}, [currentSessionId, newSessionDraftOpen]);
|
|
|
|
const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => {
|
|
if (!dataTransfer) return false;
|
|
if (dataTransfer.files && dataTransfer.files.length > 0) return true;
|
|
if (dataTransfer.types) {
|
|
const types = Array.from(dataTransfer.types);
|
|
const lowerTypes = types.map((type) => type.toLowerCase());
|
|
if (lowerTypes.includes('files')) return true;
|
|
if (lowerTypes.includes('text/uri-list')) return true;
|
|
if (lowerTypes.includes('codefiles')) return true;
|
|
if (lowerTypes.includes('application/x-openchamber-file-path')) return true;
|
|
if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true;
|
|
}
|
|
|
|
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
|
|
let payload = '';
|
|
try {
|
|
payload = dataTransfer.getData(dataType);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (payload && parseDroppedFileReferences(payload).length > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}, []);
|
|
|
|
const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => {
|
|
if (!dataTransfer) return [];
|
|
|
|
const directFiles = Array.from(dataTransfer.files || []);
|
|
if (directFiles.length > 0) {
|
|
return directFiles;
|
|
}
|
|
|
|
const fromItems = Array.from(dataTransfer.items || [])
|
|
.filter((item) => item.kind === 'file')
|
|
.map((item) => item.getAsFile())
|
|
.filter((file): file is File => Boolean(file));
|
|
|
|
return fromItems;
|
|
}, []);
|
|
|
|
const collectDroppedFileUris = React.useCallback((dataTransfer: DataTransfer | null | undefined): string[] => {
|
|
if (!dataTransfer || typeof dataTransfer.getData !== 'function') return [];
|
|
|
|
const extracted = new Set<string>();
|
|
|
|
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
|
|
let rawPayload = '';
|
|
try {
|
|
rawPayload = dataTransfer.getData(dataType);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!rawPayload) {
|
|
continue;
|
|
}
|
|
|
|
for (const candidate of parseDroppedFileReferences(rawPayload)) {
|
|
extracted.add(candidate);
|
|
}
|
|
}
|
|
|
|
return Array.from(extracted);
|
|
}, []);
|
|
|
|
const normalizeDroppedPath = React.useCallback((rawPath: string): string => {
|
|
const input = rawPath.trim();
|
|
if (!input.toLowerCase().startsWith('file://')) {
|
|
return input;
|
|
}
|
|
|
|
try {
|
|
let pathname = decodeURIComponent(new URL(input).pathname || '');
|
|
if (/^\/[A-Za-z]:\//.test(pathname)) {
|
|
pathname = pathname.slice(1);
|
|
}
|
|
return pathname || input;
|
|
} catch {
|
|
const stripped = input.replace(/^file:\/\//i, '');
|
|
try {
|
|
return decodeURIComponent(stripped);
|
|
} catch {
|
|
return stripped;
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const toProjectRelativeMentionPath = React.useCallback((absolutePath: string): string => {
|
|
const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim();
|
|
const normalizedRoot = (chatSearchDirectory || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
if (!normalizedRoot) {
|
|
return normalizedAbsolutePath;
|
|
}
|
|
if (normalizedAbsolutePath === normalizedRoot) {
|
|
return normalizedAbsolutePath;
|
|
}
|
|
const rootWithSlash = `${normalizedRoot}/`;
|
|
if (normalizedAbsolutePath.startsWith(rootWithSlash)) {
|
|
return normalizedAbsolutePath.slice(rootWithSlash.length);
|
|
}
|
|
return normalizedAbsolutePath;
|
|
}, [chatSearchDirectory]);
|
|
|
|
const addVSCodeDroppedUrisAsMentions = React.useCallback((uris: string[]) => {
|
|
if (uris.length === 0) return;
|
|
|
|
const paths = uris
|
|
.map((entry) => normalizeDroppedPath(entry))
|
|
.map((entry) => toProjectRelativeMentionPath(entry))
|
|
.map((entry) => entry.trim().replace(/^\.\//, ''))
|
|
.filter((entry) => entry.length > 0);
|
|
|
|
for (const p of paths) {
|
|
confirmedMentionsRef.current.add(p);
|
|
}
|
|
|
|
const mentions = Array.from(new Set(paths.map((entry) => `@${entry}`)));
|
|
|
|
if (mentions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
setPendingInputText(mentions.join(' '), 'append-inline');
|
|
toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length }));
|
|
}, [normalizeDroppedPath, setPendingInputText, t, toProjectRelativeMentionPath]);
|
|
|
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
if (!hasDraggedFiles(e.dataTransfer)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragEnterCountRef.current++;
|
|
const isInternal = e.dataTransfer.types?.includes('application/x-openchamber-file-path') ?? false;
|
|
if (isInternal !== isInternalDrag) {
|
|
setIsInternalDrag(isInternal);
|
|
}
|
|
if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
|
|
setIsDragging(true);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
if (!hasDraggedFiles(e.dataTransfer)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
|
|
setIsDragging(true);
|
|
}
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dragEnterCountRef.current--;
|
|
if (dragEnterCountRef.current <= 0) {
|
|
dragEnterCountRef.current = 0;
|
|
setIsDragging(false);
|
|
setIsInternalDrag(false);
|
|
clearDropTextSuppression();
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
dragEnterCountRef.current = 0;
|
|
setIsDragging(false);
|
|
setIsInternalDrag(false);
|
|
clearDropTextSuppression();
|
|
};
|
|
|
|
const handleDrop = async (e: React.DragEvent) => {
|
|
dragEnterCountRef.current = 0;
|
|
const draggedFiles = hasDraggedFiles(e.dataTransfer);
|
|
if (!draggedFiles) {
|
|
clearDropTextSuppression();
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
|
|
if (!currentSessionId && !newSessionDraftOpen) return;
|
|
|
|
// Internal drag: file tree → chat input (relative path as @mention)
|
|
const internalPath = e.dataTransfer.getData('application/x-openchamber-file-path');
|
|
if (internalPath && internalPath !== '.') {
|
|
confirmedMentionsRef.current.add(internalPath);
|
|
const mention = `@${internalPath}`;
|
|
const textarea = textareaRef.current;
|
|
const currentMessage = messageRef.current;
|
|
if (textarea) {
|
|
const pos = textarea.selectionStart ?? cursorPosRef.current;
|
|
const end = textarea.selectionEnd ?? pos;
|
|
const before = currentMessage.slice(0, pos);
|
|
const after = currentMessage.slice(end);
|
|
const needSpaceBefore = before.length > 0 && !/\s$/.test(before);
|
|
const needSpaceAfter = after.length > 0 && !/^\s/.test(after);
|
|
const insert = `${needSpaceBefore ? ' ' : ''}${mention}${needSpaceAfter ? ' ' : ''}`;
|
|
const nextMessage = `${before}${insert}${after}`;
|
|
setMessage(nextMessage);
|
|
requestAnimationFrame(() => {
|
|
const cursorPos = pos + insert.length;
|
|
textarea.selectionStart = cursorPos;
|
|
textarea.selectionEnd = cursorPos;
|
|
cursorPosRef.current = cursorPos;
|
|
textarea.focus();
|
|
});
|
|
} else {
|
|
setMessage((prev) => appendInlineText(prev, mention));
|
|
}
|
|
clearDropTextSuppression();
|
|
return;
|
|
}
|
|
|
|
const files = collectDroppedFiles(e.dataTransfer);
|
|
|
|
if (files.length === 0 && isVSCodeRuntime()) {
|
|
const droppedUris = collectDroppedFileUris(e.dataTransfer);
|
|
if (droppedUris.length > 0) {
|
|
pendingDroppedAbsolutePathsRef.current = droppedUris
|
|
.map((entry) => normalizeDroppedPath(entry))
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
addVSCodeDroppedUrisAsMentions(droppedUris);
|
|
} else {
|
|
clearDropTextSuppression();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (files.length > 0) {
|
|
for (const file of files) {
|
|
try {
|
|
await addAttachedFile(file);
|
|
} catch (error) {
|
|
console.error('File attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
|
|
}
|
|
}
|
|
}
|
|
clearDropTextSuppression();
|
|
};
|
|
|
|
const handleDropCapture = (e: React.DragEvent) => {
|
|
if (!hasDraggedFiles(e.dataTransfer)) {
|
|
return;
|
|
}
|
|
// Prevent native textarea drop text insertion for all runtimes
|
|
e.preventDefault();
|
|
if (isVSCodeRuntime()) {
|
|
suppressNextFileDropTextInsertRef.current = true;
|
|
scheduleDropTextSuppressionExpiry();
|
|
}
|
|
};
|
|
|
|
// Tauri desktop: handle native file drops via onDragDropEvent
|
|
React.useEffect(() => {
|
|
if (!isTauriShell()) return;
|
|
let cancelled = false;
|
|
let unlisten: (() => void) | null = null;
|
|
|
|
void (async () => {
|
|
try {
|
|
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
|
const webviewWindow = getCurrentWebviewWindow();
|
|
const removeListener = await webviewWindow.onDragDropEvent(async (event) => {
|
|
if (!canAcceptDropRef.current) return;
|
|
|
|
const payload = (event as { payload?: unknown }).payload;
|
|
if (!payload || typeof payload !== 'object') return;
|
|
|
|
const typed = payload as { type?: string; paths?: string[]; position?: { x?: number; y?: number } };
|
|
const type = typed.type;
|
|
const x = typed.position?.x;
|
|
const y = typed.position?.y;
|
|
|
|
// Check if drop is inside the chat input area
|
|
const zone = dropZoneRef.current;
|
|
let inZone: boolean | null = null;
|
|
if (zone && typeof x === 'number' && typeof y === 'number') {
|
|
const rect = zone.getBoundingClientRect();
|
|
inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
|
// Handle retina displays where Tauri might report physical pixels
|
|
if (!inZone && window.devicePixelRatio > 1) {
|
|
const sx = x / window.devicePixelRatio;
|
|
const sy = y / window.devicePixelRatio;
|
|
inZone = sx >= rect.left && sx <= rect.right && sy >= rect.top && sy <= rect.bottom;
|
|
}
|
|
}
|
|
|
|
if (type === 'enter' || type === 'over') {
|
|
if (inZone !== null) {
|
|
nativeDragInsideDropZoneRef.current = inZone;
|
|
}
|
|
setIsDragging(nativeDragInsideDropZoneRef.current);
|
|
return;
|
|
}
|
|
if (type === 'leave') {
|
|
nativeDragInsideDropZoneRef.current = false;
|
|
setIsDragging(false);
|
|
return;
|
|
}
|
|
if (type === 'drop') {
|
|
const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current;
|
|
nativeDragInsideDropZoneRef.current = false;
|
|
setIsDragging(false);
|
|
if (!shouldHandleDrop) return;
|
|
|
|
const paths = Array.isArray(typed.paths)
|
|
? typed.paths.filter((p): p is string => typeof p === 'string')
|
|
: [];
|
|
if (paths.length === 0) return;
|
|
|
|
for (const path of paths) {
|
|
try {
|
|
const normalizedPath = normalizeDroppedPath(path);
|
|
const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath;
|
|
let file: File;
|
|
|
|
// In Tauri shell, dropped paths are local machine paths.
|
|
// Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions.
|
|
if (isTauriShell()) {
|
|
const { invoke } = await import('@tauri-apps/api/core');
|
|
const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath });
|
|
const byteCharacters = atob(result.base64);
|
|
const byteNumbers = new Array(byteCharacters.length);
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' });
|
|
file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' });
|
|
} else {
|
|
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to read dropped file (${response.status})`);
|
|
}
|
|
const blob = await response.blob();
|
|
file = new File([blob], fileName, { type: blob.type || 'application/octet-stream' });
|
|
}
|
|
|
|
await addAttachedFile(file);
|
|
} catch (error) {
|
|
console.error('Failed to attach dropped file:', path, error);
|
|
toast.error(t('chat.chatInput.toast.attachNamedFailed', {
|
|
name: path.split(/[\\/]/).pop() || t('chat.chatInput.fileFallback'),
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (cancelled) {
|
|
removeListener();
|
|
return;
|
|
}
|
|
unlisten = removeListener;
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
console.warn('Failed to register Tauri drag-drop listener:', error);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (unlisten) unlisten();
|
|
};
|
|
}, [addAttachedFile, normalizeDroppedPath, t]);
|
|
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
|
const list = Array.isArray(files) ? files : Array.from(files);
|
|
|
|
for (const file of list) {
|
|
try {
|
|
await addAttachedFile(file);
|
|
} catch (error) {
|
|
console.error('File attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed'));
|
|
}
|
|
}
|
|
}, [addAttachedFile, t]);
|
|
|
|
const handleVSCodePickFiles = React.useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/vscode/pick-files');
|
|
const data = await response.json();
|
|
const picked = Array.isArray(data?.files) ? data.files : [];
|
|
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
|
|
|
|
if (skipped.length > 0) {
|
|
const summary = skipped
|
|
.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`)
|
|
.join('\n');
|
|
toast.error(t('chat.chatInput.toast.someFilesSkipped', { summary }));
|
|
}
|
|
|
|
const asFiles = picked
|
|
.map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
|
|
if (!file?.dataUrl) return null;
|
|
try {
|
|
const [meta, base64] = file.dataUrl.split(',');
|
|
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
|
|
if (!base64) return null;
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
const blob = new Blob([bytes], { type: mime });
|
|
return new File([blob], file.name || 'file', { type: mime });
|
|
} catch (err) {
|
|
console.error('Failed to decode VS Code picked file', err);
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean) as File[];
|
|
|
|
if (asFiles.length > 0) {
|
|
await attachFiles(asFiles);
|
|
}
|
|
} catch (error) {
|
|
console.error('VS Code file pick failed', error);
|
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
|
|
}
|
|
}, [attachFiles, t]);
|
|
|
|
const handlePickLocalFiles = React.useCallback(() => {
|
|
if (isVSCodeRuntime()) {
|
|
void handleVSCodePickFiles();
|
|
return;
|
|
}
|
|
fileInputRef.current?.click();
|
|
}, [handleVSCodePickFiles]);
|
|
|
|
const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = event.target.files;
|
|
if (!files) return;
|
|
await attachFiles(files);
|
|
event.target.value = '';
|
|
}, [attachFiles]);
|
|
|
|
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
|
const isVSCode = isVSCodeRuntime();
|
|
const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode;
|
|
|
|
const selectedDraftProject = React.useMemo(() => {
|
|
const explicit = newSessionDraft?.selectedProjectId
|
|
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
|
: null;
|
|
if (explicit) {
|
|
return explicit;
|
|
}
|
|
|
|
const active = activeProjectId
|
|
? projects.find((project) => project.id === activeProjectId) ?? null
|
|
: null;
|
|
if (active) {
|
|
return active;
|
|
}
|
|
|
|
return projects[0] ?? null;
|
|
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
|
|
|
const selectedDraftProjectPath = React.useMemo(
|
|
() => normalizePath(selectedDraftProject?.path ?? null),
|
|
[selectedDraftProject?.path],
|
|
);
|
|
|
|
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
|
|
const selectedDraftProjectIsGitRepo = useIsGitRepo(selectedDraftProjectPath);
|
|
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
|
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
|
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (!showDraftTargetSelectors || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) {
|
|
return;
|
|
}
|
|
|
|
void fetchGitStatus(selectedDraftProjectPath, runtimeGit, { silent: true });
|
|
}, [fetchGitStatus, runtimeGit, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]);
|
|
|
|
React.useEffect(() => {
|
|
if (!showDraftTargetSelectors || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit || selectedDraftProjectIsGitRepo !== true) {
|
|
setIsDiscoveringDraftBranches(false);
|
|
return;
|
|
}
|
|
|
|
if (selectedDraftProjectBranches?.all) {
|
|
setIsDiscoveringDraftBranches(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setIsDiscoveringDraftBranches(true);
|
|
|
|
void fetchBranches(selectedDraftProjectPath, runtimeGit)
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setIsDiscoveringDraftBranches(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]);
|
|
|
|
const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
|
|
|
|
const projectRootBranchOption = React.useMemo(() => {
|
|
if (!selectedDraftProject) {
|
|
return null;
|
|
}
|
|
const value = normalizePath(selectedDraftProject.path);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (!selectedDraftProjectCurrentBranch) {
|
|
return null;
|
|
}
|
|
return {
|
|
value,
|
|
label: selectedDraftProjectCurrentBranch,
|
|
};
|
|
}, [selectedDraftProject, selectedDraftProjectCurrentBranch]);
|
|
|
|
const worktreeBranchOptions = React.useMemo(() => {
|
|
if (!selectedDraftProject) {
|
|
return [];
|
|
}
|
|
|
|
const worktrees = (() => {
|
|
if (!selectedDraftProjectPath) {
|
|
return [];
|
|
}
|
|
return availableWorktreesByProject.get(selectedDraftProjectPath)
|
|
?? availableWorktreesByProject.get(selectedDraftProject.path)
|
|
?? [];
|
|
})();
|
|
|
|
return buildSessionTargetOptions({
|
|
projectRoot: normalizePath(selectedDraftProject.path) ?? '',
|
|
rootBranch: selectedDraftProjectCurrentBranch,
|
|
worktrees,
|
|
pendingBootstrapDirectory: newSessionDraft?.bootstrapPendingDirectory ?? null,
|
|
});
|
|
}, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectCurrentBranch, selectedDraftProjectPath]);
|
|
|
|
const selectedDraftDirectory = React.useMemo(
|
|
() => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null)
|
|
?? normalizePath(newSessionDraft?.directoryOverride ?? null)
|
|
?? selectedDraftProjectPath,
|
|
[newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
|
);
|
|
|
|
const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => {
|
|
const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
|
|
return Boolean(
|
|
newSessionDraft?.preserveDirectoryOverride
|
|
||
|
|
newSessionDraft?.pendingWorktreeRequestId
|
|
|| (pendingDirectory && pendingDirectory === selectedDraftDirectory)
|
|
);
|
|
}, [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory]);
|
|
|
|
const draftBranchItems = React.useMemo(() => {
|
|
const baseItems: Array<{ value: string; label: string }> = [];
|
|
if (projectRootBranchOption) {
|
|
baseItems.push(projectRootBranchOption);
|
|
}
|
|
baseItems.push(...worktreeBranchOptions);
|
|
|
|
if (!selectedDraftDirectory) {
|
|
return baseItems;
|
|
}
|
|
if (baseItems.some((option) => option.value === selectedDraftDirectory)) {
|
|
return baseItems;
|
|
}
|
|
if (!shouldKeepMissingSelectedDraftDirectory) {
|
|
return baseItems;
|
|
}
|
|
return [
|
|
...baseItems,
|
|
{ value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) },
|
|
];
|
|
}, [projectRootBranchOption, selectedDraftDirectory, shouldKeepMissingSelectedDraftDirectory, worktreeBranchOptions]);
|
|
|
|
const selectedDraftBranchLabel = React.useMemo(() => {
|
|
const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null;
|
|
if (!selectedValue) {
|
|
return null;
|
|
}
|
|
return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue);
|
|
}, [draftBranchItems, selectedDraftDirectory]);
|
|
|
|
const chatSurfaceMode = useChatSurfaceMode();
|
|
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
|
|
|
|
const hasPendingChanges = React.useMemo(() => {
|
|
if (isMiniChatSurface) {
|
|
return false;
|
|
}
|
|
if (isGitRepo !== true || !currentGitStatus || currentGitStatus.isClean) {
|
|
return false;
|
|
}
|
|
return extractGitChangedFiles(currentGitStatus.files, currentGitStatus.diffStats, currentDirectory).length > 0;
|
|
}, [currentDirectory, currentGitStatus, isGitRepo, isMiniChatSurface]);
|
|
|
|
const selectedDraftBranchIsKnown = React.useMemo(() => {
|
|
if (!selectedDraftDirectory) {
|
|
return true;
|
|
}
|
|
if (projectRootBranchOption?.value === selectedDraftDirectory) {
|
|
return true;
|
|
}
|
|
return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory);
|
|
}, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]);
|
|
|
|
React.useEffect(() => {
|
|
if (!newSessionDraft?.open || !newSessionDraft?.preserveDirectoryOverride) {
|
|
return;
|
|
}
|
|
if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) {
|
|
return;
|
|
}
|
|
useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false);
|
|
}, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]);
|
|
|
|
const shouldShowDraftBranchSelector = React.useMemo(() => {
|
|
if (selectedDraftProjectIsGitRepo !== true) {
|
|
return false;
|
|
}
|
|
if (isDiscoveringDraftBranches) {
|
|
return false;
|
|
}
|
|
if (projectRootBranchOption) {
|
|
return true;
|
|
}
|
|
return worktreeBranchOptions.length > 0;
|
|
}, [isDiscoveringDraftBranches, projectRootBranchOption, selectedDraftProjectIsGitRepo, worktreeBranchOptions.length]);
|
|
|
|
const handleDraftProjectChange = React.useCallback((projectId: string) => {
|
|
const draft = useSessionUIStore.getState().newSessionDraft;
|
|
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
|
return;
|
|
}
|
|
const project = projects.find((entry) => entry.id === projectId);
|
|
if (!project) {
|
|
return;
|
|
}
|
|
if (activeProjectId !== projectId) {
|
|
setActiveProjectIdOnly(projectId);
|
|
}
|
|
setNewSessionDraftTarget({
|
|
projectId,
|
|
directoryOverride: project.path,
|
|
}, { force: true });
|
|
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
|
|
|
|
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
|
|
const draft = useSessionUIStore.getState().newSessionDraft;
|
|
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
|
|
return;
|
|
}
|
|
if (!selectedDraftProject) {
|
|
return;
|
|
}
|
|
setNewSessionDraftTarget({
|
|
projectId: selectedDraftProject.id,
|
|
directoryOverride: directory,
|
|
}, { force: true });
|
|
}, [selectedDraftProject, setNewSessionDraftTarget]);
|
|
|
|
const renderProjectLabelWithIcon = React.useCallback((project: {
|
|
id: string;
|
|
path: string;
|
|
label?: string;
|
|
icon?: string | null;
|
|
color?: string | null;
|
|
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
|
|
iconBackground?: string | null;
|
|
}) => {
|
|
const imageUrl = getProjectIconImageUrl(
|
|
{ id: project.id, iconImage: project.iconImage ?? null },
|
|
{
|
|
themeVariant: currentTheme.metadata.variant,
|
|
iconColor: currentTheme.colors.surface.foreground,
|
|
},
|
|
);
|
|
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
|
const iconColor = getProjectIconColor(project.color);
|
|
|
|
return (
|
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
{imageUrl ? (
|
|
<span
|
|
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
|
|
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
|
>
|
|
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
|
|
</span>
|
|
) : projectIconName ? (
|
|
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
|
|
) : (
|
|
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
|
|
)}
|
|
<span className="truncate">{getProjectDisplayLabel(project)}</span>
|
|
</span>
|
|
);
|
|
}, [currentTheme.colors.surface.foreground, currentTheme.metadata.variant]);
|
|
|
|
React.useEffect(() => {
|
|
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
|
|
return;
|
|
}
|
|
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
|
|
return;
|
|
}
|
|
const valid = draftBranchItems.some((option) => option.value === selectedDraftDirectory);
|
|
if (valid) {
|
|
return;
|
|
}
|
|
setNewSessionDraftTarget({
|
|
projectId: selectedDraftProject.id,
|
|
directoryOverride: selectedDraftProject.path,
|
|
});
|
|
}, [draftBranchItems, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
|
|
|
|
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
|
|
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
|
|
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
|
|
const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5');
|
|
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
|
|
|
const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed';
|
|
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
|
|
const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId;
|
|
const permissionAutoAcceptEnabled = usePermissionStore((state) => {
|
|
if (!permissionScopeSessionId) {
|
|
return false;
|
|
}
|
|
return state.isSessionAutoAccepting(permissionScopeSessionId);
|
|
});
|
|
|
|
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
|
|
if (!permissionScopeSessionId) {
|
|
toast.error(t('chat.chatInput.toast.openSessionFirst'));
|
|
return;
|
|
}
|
|
|
|
const nextEnabled = !permissionAutoAcceptEnabled;
|
|
setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => {
|
|
toast.error(t('chat.chatInput.toast.togglePermissionAutoAcceptFailed'));
|
|
});
|
|
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept, t]);
|
|
|
|
React.useEffect(() => {
|
|
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
|
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
|
startAbortIndicator();
|
|
if (currentSessionId) {
|
|
acknowledgeSessionAbort(currentSessionId);
|
|
}
|
|
}
|
|
prevWasAbortedRef.current = pendingAbortBanner;
|
|
}, [
|
|
abortPromptSessionId,
|
|
acknowledgeSessionAbort,
|
|
currentSessionId,
|
|
showAbortStatus,
|
|
startAbortIndicator,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (abortTimeoutRef.current) {
|
|
clearTimeout(abortTimeoutRef.current);
|
|
abortTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<form
|
|
onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }}
|
|
className={cn(
|
|
"relative pt-0 pb-4",
|
|
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4',
|
|
isMobile && 'bottom-safe-area'
|
|
)}
|
|
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
|
>
|
|
<div className={cn('chat-input-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
|
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
|
|
<QueuedMessageChips
|
|
onEditMessage={handleQueuedMessageEdit}
|
|
onSendMessage={handleQueuedMessageSend}
|
|
/>
|
|
{hasDrafts && (
|
|
<div className="flex flex-wrap items-center gap-2 pb-2">
|
|
{reviewCount > 0 ? (
|
|
<div
|
|
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
|
style={{
|
|
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
|
borderColor: currentTheme?.colors?.interactive?.border,
|
|
}}
|
|
>
|
|
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.reviewComments')}</span>
|
|
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{reviewCount}</span>
|
|
</div>
|
|
) : null}
|
|
{previewConsoleCount > 0 ? (
|
|
<div
|
|
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
|
style={{
|
|
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
|
borderColor: currentTheme?.colors?.interactive?.border,
|
|
}}
|
|
>
|
|
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.devServerLogs')}</span>
|
|
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewConsoleCount}</span>
|
|
<button
|
|
type="button"
|
|
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
|
onClick={() => removePreviewDrafts('preview-console')}
|
|
aria-label={t('chat.chatInput.devServerLogsRemove')}
|
|
title={t('chat.chatInput.devServerLogsRemove')}
|
|
>
|
|
<Icon name="close" className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
{previewAnnotationCount > 0 ? (
|
|
<div
|
|
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
|
style={{
|
|
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
|
borderColor: currentTheme?.colors?.interactive?.border,
|
|
}}
|
|
>
|
|
<span className="text-xs font-medium text-muted-foreground">{t('chat.chatInput.previewAnnotations')}</span>
|
|
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>{previewAnnotationCount}</span>
|
|
<button
|
|
type="button"
|
|
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
|
onClick={() => removePreviewDrafts('preview-annotation')}
|
|
aria-label={t('chat.chatInput.previewContextRemove')}
|
|
title={t('chat.chatInput.previewContextRemove')}
|
|
>
|
|
<Icon name="close" className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* Linked Issue row */}
|
|
{linkedIssue && !isVSCode && (
|
|
<div className="pb-2 w-full px-1">
|
|
<div className="flex w-full items-center gap-1.5 text-sm h-5 px-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIssuePickerOpen(true)}
|
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left hover:opacity-80 transition-opacity"
|
|
>
|
|
{linkedIssue.author?.avatarUrl && (
|
|
<img
|
|
src={linkedIssue.author.avatarUrl}
|
|
alt={linkedIssue.author.login}
|
|
className="h-5 w-5 rounded-full flex-shrink-0"
|
|
/>
|
|
)}
|
|
<span className="text-muted-foreground flex-shrink-0">
|
|
#{linkedIssue.number}
|
|
{linkedIssue.author && (
|
|
<span className="ml-1">{t('chat.chatInput.linked.byAuthor', { author: linkedIssue.author.login })}</span>
|
|
)}
|
|
</span>
|
|
<span className="text-foreground truncate">
|
|
{linkedIssue.title}
|
|
</span>
|
|
</button>
|
|
<span className="flex items-center gap-0.5 flex-shrink-0">
|
|
<a
|
|
href={linkedIssue.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
|
aria-label={t('chat.chatInput.linked.issue.openInBrowserAria')}
|
|
>
|
|
<Icon name="external-link" className="h-4 w-4 text-muted-foreground" />
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setLinkedIssue(null);
|
|
}}
|
|
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
|
aria-label={t('chat.chatInput.linked.issue.removeAria')}
|
|
title={t('chat.chatInput.linked.issue.removeAria')}
|
|
>
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{linkedPr && !isVSCode && (
|
|
<div className="pb-2 w-full px-1">
|
|
<div className="flex w-full items-center gap-1.5 text-sm h-5 px-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPrPickerOpen(true)}
|
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left hover:opacity-80 transition-opacity"
|
|
>
|
|
{linkedPr.author?.avatarUrl && (
|
|
<img
|
|
src={linkedPr.author.avatarUrl}
|
|
alt={linkedPr.author.login}
|
|
className="h-5 w-5 rounded-full flex-shrink-0"
|
|
/>
|
|
)}
|
|
<span className="text-muted-foreground flex-shrink-0">
|
|
{t('chat.chatInput.linked.pr.number', { number: linkedPr.number })}
|
|
{linkedPr.author && (
|
|
<span className="ml-1">{t('chat.chatInput.linked.byAuthor', { author: linkedPr.author.login })}</span>
|
|
)}
|
|
</span>
|
|
<span className="text-foreground truncate">
|
|
{linkedPr.title}
|
|
</span>
|
|
<span className="text-muted-foreground flex-shrink-0 typography-meta">
|
|
{linkedPr.head} → {linkedPr.base}
|
|
</span>
|
|
</button>
|
|
<span className="flex items-center gap-0.5 flex-shrink-0">
|
|
<a
|
|
href={linkedPr.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
|
aria-label={t('chat.chatInput.linked.pr.openInBrowserAria')}
|
|
>
|
|
<Icon name="external-link" className="h-4 w-4 text-muted-foreground" />
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setLinkedPr(null);
|
|
}}
|
|
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
|
aria-label={t('chat.chatInput.linked.pr.removeAria')}
|
|
title={t('chat.chatInput.linked.pr.removeAria')}
|
|
>
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<RevertedMessageDock
|
|
sessionId={currentSessionId}
|
|
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
|
/>
|
|
<MemoStatusRow
|
|
showAbortStatus={showAbortStatus}
|
|
showAssistantStatus={false}
|
|
showTodos
|
|
leftAccessory={newSessionDraftOpen || !hasPendingChanges ? null : <PendingChangesBar />}
|
|
/>
|
|
{showDraftTargetSelectors && selectedDraftProject ? (
|
|
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
|
<Select
|
|
value={selectedDraftProject.id}
|
|
onValueChange={handleDraftProjectChange}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
|
>
|
|
<SelectValue>
|
|
{renderProjectLabelWithIcon(selectedDraftProject)}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent fitContent>
|
|
{projects.map((project) => (
|
|
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
|
|
{renderProjectLabelWithIcon(project)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{shouldShowDraftBranchSelector ? (
|
|
<Select
|
|
value={selectedDraftDirectory ?? draftBranchItems[0]?.value ?? normalizePath(selectedDraftProject.path) ?? ''}
|
|
onValueChange={handleDraftDirectoryChange}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
|
|
>
|
|
<SelectValue>
|
|
{selectedDraftBranchLabel ?? t('chat.chatInput.branch')}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent fitContent>
|
|
{projectRootBranchOption ? (
|
|
<SelectGroup>
|
|
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
|
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
|
|
{projectRootBranchOption.label}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
) : null}
|
|
{projectRootBranchOption ? <SelectSeparator /> : null}
|
|
<SelectGroup>
|
|
<div className="flex items-center justify-between px-2 py-1.5">
|
|
<span className="text-muted-foreground typography-meta">{t('chat.chatInput.worktrees')}</span>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground typography-meta hover:text-foreground cursor-pointer"
|
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); void createWorktreeDraft(); }}
|
|
>
|
|
{t('chat.chatInput.worktreeNew')}
|
|
</button>
|
|
</div>
|
|
{worktreeBranchOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
|
|
{option.pending ? '⏳ ' : ''}{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
{selectedDraftDirectory && !selectedDraftBranchIsKnown ? (
|
|
<SelectItem value={selectedDraftDirectory} className="max-w-[24rem] truncate">
|
|
{selectedDraftBranchLabel}
|
|
</SelectItem>
|
|
) : null}
|
|
</SelectContent>
|
|
</Select>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<div
|
|
className={cn(
|
|
"flex flex-col relative overflow-visible",
|
|
isDesktopExpanded && 'flex-1 min-h-0',
|
|
"border border-border/80",
|
|
"focus-within:ring-1",
|
|
inputMode === 'shell'
|
|
? 'focus-within:ring-[var(--status-info)]'
|
|
: 'focus-within:ring-primary/50',
|
|
isDragging && "ring-2 ring-primary ring-offset-2"
|
|
)}
|
|
style={{
|
|
borderRadius: chatInputRadius,
|
|
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
|
}}
|
|
ref={dropZoneRef}
|
|
onDropCapture={handleDropCapture}
|
|
onDragEnter={handleDragEnter}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
{isDragging && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/90 rounded-xl">
|
|
<div className="text-center">
|
|
<div className="inline-flex justify-center">
|
|
<button
|
|
type="button"
|
|
className={iconButtonBaseClass}
|
|
onClick={() => handlePickLocalFiles()}
|
|
title={t('chat.chatInput.actions.attachFiles')}
|
|
aria-label={t('chat.chatInput.actions.attachFiles')}
|
|
>
|
|
<Icon name="attachment-2" className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
</div>
|
|
<p className="mt-2 typography-ui-label text-muted-foreground">
|
|
{isInternalDrag ? t('chat.chatInput.drop.insertMention') : t('chat.chatInput.drop.attachFiles')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showCommandAutocomplete && (
|
|
<CommandAutocomplete
|
|
ref={commandRef}
|
|
searchQuery={commandQuery}
|
|
onCommandSelect={handleCommandSelect}
|
|
onClose={() => setShowCommandAutocomplete(false)}
|
|
style={isDesktopExpanded && autocompleteOverlayPosition
|
|
? {
|
|
left: `${autocompleteOverlayPosition.left}px`,
|
|
top: `${autocompleteOverlayPosition.top}px`,
|
|
bottom: 'auto',
|
|
width: `min(450px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
|
|
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
|
|
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
|
|
}
|
|
: undefined}
|
|
/>
|
|
)}
|
|
{ }
|
|
{showSkillAutocomplete && (
|
|
<SkillAutocomplete
|
|
ref={skillRef}
|
|
searchQuery={skillQuery}
|
|
onSkillSelect={handleSkillSelect}
|
|
onClose={() => setShowSkillAutocomplete(false)}
|
|
style={isDesktopExpanded && autocompleteOverlayPosition
|
|
? {
|
|
left: `${autocompleteOverlayPosition.left}px`,
|
|
top: `${autocompleteOverlayPosition.top}px`,
|
|
bottom: 'auto',
|
|
width: `min(360px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
|
|
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
|
|
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
|
|
}
|
|
: undefined}
|
|
/>
|
|
)}
|
|
|
|
{showSnippetAutocomplete && (
|
|
<SnippetAutocomplete
|
|
ref={snippetRef}
|
|
searchQuery={snippetQuery}
|
|
onSnippetSelect={handleSnippetSelect}
|
|
onClose={() => setShowSnippetAutocomplete(false)}
|
|
style={isDesktopExpanded && autocompleteOverlayPosition
|
|
? {
|
|
left: `${autocompleteOverlayPosition.left}px`,
|
|
top: `${autocompleteOverlayPosition.top}px`,
|
|
bottom: 'auto',
|
|
width: `min(450px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
|
|
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
|
|
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
|
|
}
|
|
: undefined}
|
|
/>
|
|
)}
|
|
|
|
{showFileMention && (
|
|
|
|
<FileMentionAutocomplete
|
|
ref={mentionRef}
|
|
searchQuery={mentionQuery}
|
|
onFileSelect={handleFileSelect}
|
|
onAgentSelect={handleAgentSelect}
|
|
onClose={() => setShowFileMention(false)}
|
|
style={isDesktopExpanded && autocompleteOverlayPosition
|
|
? {
|
|
left: `${autocompleteOverlayPosition.left}px`,
|
|
top: `${autocompleteOverlayPosition.top}px`,
|
|
bottom: 'auto',
|
|
width: `min(520px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`,
|
|
maxHeight: `${autocompleteOverlayPosition.maxHeight}px`,
|
|
transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined,
|
|
}
|
|
: undefined}
|
|
/>
|
|
)}
|
|
<div className={cn("overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
|
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
|
<AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} />
|
|
<ActiveEditorFileSuggestion />
|
|
</div>
|
|
<div className={cn("relative overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
|
{highlightedComposerContent && (
|
|
<div
|
|
aria-hidden
|
|
className={cn(
|
|
'pointer-events-none absolute inset-0 z-0 whitespace-pre-wrap break-words px-3 rounded-b-none',
|
|
isDesktopExpanded
|
|
? 'h-full min-h-0 py-4'
|
|
: isMobile
|
|
? 'py-2.5'
|
|
: 'pt-4 pb-2',
|
|
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
|
|
)}
|
|
ref={composerHighlightRef}
|
|
>
|
|
{highlightedComposerContent.map((part, index) => (
|
|
<span
|
|
key={`${index}-${part.text.length}`}
|
|
className={part.className}
|
|
>
|
|
{part.text}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Textarea
|
|
simple
|
|
ref={textareaRef}
|
|
data-chat-input="true"
|
|
value={message}
|
|
onChange={handleTextChange}
|
|
onBeforeInput={handleBeforeInput}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
onDragEnter={handleDragEnter}
|
|
onDragOver={handleDragOver}
|
|
onDropCapture={handleDropCapture}
|
|
onDrop={handleDrop}
|
|
onDragEnd={handleDragEnd}
|
|
onKeyUp={updateAutocompleteOverlayPosition}
|
|
onClick={updateAutocompleteOverlayPosition}
|
|
onScroll={(event) => {
|
|
updateAutocompleteOverlayPosition();
|
|
const scrollTop = event.currentTarget.scrollTop;
|
|
if (composerHighlightRef.current) {
|
|
composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`;
|
|
}
|
|
}}
|
|
onSelect={(e) => {
|
|
const ta = e.currentTarget;
|
|
cursorPosRef.current = ta.selectionStart ?? 0;
|
|
updateAutocompleteOverlayPosition();
|
|
}}
|
|
placeholder={currentSessionId || newSessionDraftOpen
|
|
? inputMode === 'shell'
|
|
? t('chat.chatInput.placeholder.shell')
|
|
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
|
|
: t('chat.chatInput.placeholder.selectSession')}
|
|
disabled={!currentSessionId && !newSessionDraftOpen}
|
|
autoCorrect={isMobile ? "on" : "off"}
|
|
autoCapitalize={isMobile ? "sentences" : "off"}
|
|
spellCheck={isMobile || inputSpellcheckEnabled}
|
|
fillContainer={isDesktopExpanded}
|
|
outerClassName={cn('ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
|
className={cn(
|
|
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
|
|
isDesktopExpanded
|
|
? 'h-full min-h-0 py-4'
|
|
: isMobile
|
|
? 'py-2.5'
|
|
: 'pt-4 pb-2',
|
|
inputMode === 'shell' && 'font-mono',
|
|
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
|
|
)}
|
|
style={{
|
|
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
|
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
|
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
|
borderTopLeftRadius: chatInputRadius,
|
|
borderTopRightRadius: chatInputRadius,
|
|
}}
|
|
rows={1}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'bg-transparent flex-shrink-0',
|
|
footerPaddingClass,
|
|
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
|
|
)}
|
|
style={{
|
|
borderBottomLeftRadius: chatInputRadius,
|
|
borderBottomRightRadius: chatInputRadius,
|
|
}}
|
|
data-chat-input-footer="true"
|
|
>
|
|
{isMobile ? (
|
|
<>
|
|
<div className="flex w-full items-center justify-between gap-x-1.5">
|
|
<div className="flex items-center gap-x-1.5">
|
|
<ComposerAttachmentControls
|
|
isVSCode={isVSCode}
|
|
footerIconButtonClass={footerIconButtonClass}
|
|
iconSizeClass={iconSizeClass}
|
|
fileInputRef={fileInputRef}
|
|
handleLocalFileSelect={handleLocalFileSelect}
|
|
handlePickLocalFiles={handlePickLocalFiles}
|
|
openIssuePicker={openIssuePicker}
|
|
openPrPicker={openPrPicker}
|
|
onOpenSettings={onOpenSettings}
|
|
/>
|
|
<PermissionAutoAcceptButton
|
|
footerIconButtonClass={footerIconButtonClass}
|
|
iconSizeClass={iconSizeClass}
|
|
permissionScopeSessionId={permissionScopeSessionId}
|
|
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
|
|
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
|
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
|
|
<MemoMobileModelButton onOpenModel={() => handleOpenMobilePanel('model')} className="min-w-0 flex-shrink" />
|
|
<MemoMobileAgentButton
|
|
onOpenAgentPanel={handleOpenAgentPanel}
|
|
onCycleAgent={handleCycleAgent}
|
|
className="min-w-0 flex-shrink"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-x-1 flex-shrink-0">
|
|
<MemoBrowserVoiceButton />
|
|
<ComposerActionButtons
|
|
isMobile={isMobile}
|
|
footerIconButtonClass={footerIconButtonClass}
|
|
sendIconSizeClass={sendIconSizeClass}
|
|
stopIconSizeClass={stopIconSizeClass}
|
|
canSend={canSend}
|
|
canAbort={canAbort}
|
|
hasContent={!!hasContent}
|
|
currentSessionId={currentSessionId}
|
|
newSessionDraftOpen={newSessionDraftOpen}
|
|
onPrimaryAction={handlePrimaryAction}
|
|
onQueueMessage={handleQueueMessage}
|
|
onAbort={handleAbort}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<MemoModelControls
|
|
className="hidden"
|
|
mobilePanel={mobileControlsPanel}
|
|
onMobilePanelChange={setMobileControlsPanel}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
|
<ComposerAttachmentControls
|
|
isVSCode={isVSCode}
|
|
footerIconButtonClass={footerIconButtonClass}
|
|
iconSizeClass={iconSizeClass}
|
|
fileInputRef={fileInputRef}
|
|
handleLocalFileSelect={handleLocalFileSelect}
|
|
handlePickLocalFiles={handlePickLocalFiles}
|
|
openIssuePicker={openIssuePicker}
|
|
openPrPicker={openPrPicker}
|
|
onOpenSettings={onOpenSettings}
|
|
/>
|
|
<FocusModeButton
|
|
footerIconButtonClass={footerIconButtonClass}
|
|
iconSizeClass={iconSizeClass}
|
|
isExpandedInput={isExpandedInput}
|
|
onToggle={handleToggleExpandedInput}
|
|
/>
|
|
<PermissionAutoAcceptButton
|
|
footerIconButtonClass={footerIconButtonClass}
|
|
iconSizeClass={iconSizeClass}
|
|
permissionScopeSessionId={permissionScopeSessionId}
|
|
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
|
|
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
|
|
withTooltip
|
|
/>
|
|
</div>
|
|
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
|
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
|
<MemoBrowserVoiceButton />
|
|
<ComposerActionButtons
|
|
isMobile={isMobile}
|
|
footerIconButtonClass={footerIconButtonClass}
|
|
sendIconSizeClass={sendIconSizeClass}
|
|
stopIconSizeClass={stopIconSizeClass}
|
|
canSend={canSend}
|
|
canAbort={canAbort}
|
|
hasContent={!!hasContent}
|
|
currentSessionId={currentSessionId}
|
|
newSessionDraftOpen={newSessionDraftOpen}
|
|
onPrimaryAction={handlePrimaryAction}
|
|
onQueueMessage={handleQueueMessage}
|
|
onAbort={handleAbort}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile Session Status Bar - above input */}
|
|
{isMobile && <MobileSessionStatusBar />}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Issue Picker Dialog */}
|
|
<GitHubIssuePickerDialog
|
|
open={issuePickerOpen}
|
|
onOpenChange={setIssuePickerOpen}
|
|
mode="select"
|
|
onSelect={(issue) => {
|
|
setLinkedIssue(issue);
|
|
setLinkedPr(null);
|
|
}}
|
|
/>
|
|
<GitHubPrPickerDialog
|
|
open={prPickerOpen}
|
|
onOpenChange={setPrPickerOpen}
|
|
onSelect={(pr) => {
|
|
setLinkedPr(pr);
|
|
setLinkedIssue(null);
|
|
}}
|
|
/>
|
|
<ToolOutputDialog
|
|
popup={attachmentPreview}
|
|
onOpenChange={handleAttachmentPreviewOpenChange}
|
|
syntaxTheme={{}}
|
|
isMobile={isMobile}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
ChatInputComponent.displayName = 'ChatInput';
|
|
|
|
export const ChatInput = React.memo(ChatInputComponent);
|