mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-25 16:44:36 +00:00
* fix(web-shell): finalize deferred gated submissions * test(web-shell): fix sidebar render result usage --------- Co-authored-by: ytahdn <ytahdn@gmail.com>
1710 lines
55 KiB
TypeScript
1710 lines
55 KiB
TypeScript
import {
|
||
forwardRef,
|
||
memo,
|
||
useImperativeHandle,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
||
import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk';
|
||
import type { CommandInfo } from '../adapters/types';
|
||
import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk';
|
||
import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay';
|
||
import type { SkillInfo } from '../completions/slashCompletion';
|
||
import { useI18n } from '../i18n';
|
||
import {
|
||
useWebShellCustomization,
|
||
type WebShellComposerInput,
|
||
type WebShellComposerTag,
|
||
type WebShellComposerTagIconMap,
|
||
type WebShellAtProvider,
|
||
} from '../customization';
|
||
import {
|
||
useComposerCore,
|
||
type EditorHandle,
|
||
type SlashMenuState,
|
||
getComposerTagDisplay,
|
||
getComposerTagLabel,
|
||
getComposerTagValue,
|
||
} from '../hooks/useComposerCore';
|
||
import { AtMentionPanel } from './AtMentionPanel';
|
||
import { cssUrlVar } from '../utils/cssUrlVar';
|
||
import { getComposerTagIconUrl } from './composerTagIcons';
|
||
import { ModeIcon } from './ModeIcon';
|
||
import { planSlashSectionRows } from '../utils/slashSectionPlan';
|
||
import { getModelDisplayName } from '../utils/modelDisplay';
|
||
import { VoiceButton } from '../voice/VoiceButton';
|
||
import styles from './ChatEditor.module.css';
|
||
|
||
export type ComposerToolbarAction =
|
||
| 'approvalMode'
|
||
| 'model'
|
||
| 'commands'
|
||
| 'files'
|
||
| 'widthMode'
|
||
| 'voice';
|
||
|
||
const ACTIVE_TOOLBAR_ACTIONS = [
|
||
'approvalMode',
|
||
'model',
|
||
'widthMode',
|
||
'voice',
|
||
] as const satisfies readonly ComposerToolbarAction[];
|
||
const ACTIVE_TOOLBAR_ACTION_SET = new Set<ComposerToolbarAction>(
|
||
ACTIVE_TOOLBAR_ACTIONS,
|
||
);
|
||
|
||
interface ChatEditorProps {
|
||
onSubmit: (
|
||
text: string,
|
||
images?: import('../adapters/promptTypes').PromptImage[],
|
||
commitAccepted?: import('../hooks/useComposerCore').ComposerSubmitCommit,
|
||
) => boolean | void;
|
||
onCycleMode?: () => void;
|
||
onToggleShortcuts?: () => void;
|
||
onCancel?: () => void;
|
||
isRunning?: boolean;
|
||
isPreparing?: boolean;
|
||
/** First Esc armed a cancel — the send button shows an "Esc to stop" hint. */
|
||
cancelArmed?: boolean;
|
||
disabled?: boolean;
|
||
placeholderText?: string;
|
||
commands: CommandInfo[];
|
||
skills?: SkillInfo[];
|
||
slashCommandCategoryOrder?: CommandDisplayCategoryOrder;
|
||
queuedMessages?: string[];
|
||
onPopQueuedMessages?: () => boolean;
|
||
onClearQueuedMessages?: () => boolean;
|
||
currentMode?: string;
|
||
currentModel?: string;
|
||
chatWidthMode?: '1000' | 'wide';
|
||
showChatWidthToggle?: boolean;
|
||
chatWidthToggleMin?: number;
|
||
visibleToolbarActions?: readonly ComposerToolbarAction[];
|
||
availableModels?: Array<{ id: string; label?: string }>;
|
||
onSelectMode?: (mode: string) => void;
|
||
onSelectModel?: (model: string) => void;
|
||
onChatWidthModeChange?: (mode: '1000' | 'wide') => void;
|
||
onFocusFooter?: () => boolean;
|
||
dialogOpen?: boolean;
|
||
followupState?: UseDaemonFollowupSuggestionReturn['followupState'];
|
||
onAcceptFollowup?: UseDaemonFollowupSuggestionReturn['onAcceptFollowup'];
|
||
onDismissFollowup?: UseDaemonFollowupSuggestionReturn['onDismissFollowup'];
|
||
sessionName?: string;
|
||
composerInput?: WebShellComposerInput;
|
||
composerInputVersion?: number;
|
||
atProviders?: readonly WebShellAtProvider[];
|
||
composerTagIcons?: WebShellComposerTagIconMap;
|
||
}
|
||
|
||
const CHAT_EDITOR_THEME = {
|
||
'&': {
|
||
fontSize: '14px',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
},
|
||
'&.cm-focused': {
|
||
outline: 'none',
|
||
},
|
||
'.cm-scroller': {
|
||
maxHeight: 'var(--chat-editor-input-max-height, 300px)',
|
||
overflowX: 'hidden',
|
||
overflowY: 'auto',
|
||
},
|
||
'.cm-content': {
|
||
padding: '0',
|
||
fontFamily: 'var(--font-sans, system-ui, sans-serif)',
|
||
color: 'var(--chat-editor-text-primary, #e0e0e0)',
|
||
caretColor: 'var(--chat-editor-accent-color, #4a9eff)',
|
||
fontSize: '14px',
|
||
lineHeight: '1.6',
|
||
},
|
||
'.cm-line': {
|
||
padding: '0',
|
||
},
|
||
'.cm-placeholder': {
|
||
color: 'var(--chat-editor-text-dimmed, #666)',
|
||
},
|
||
'.cm-followup-ghost': {
|
||
color: 'var(--chat-editor-text-dimmed, #666)',
|
||
opacity: '0.72',
|
||
pointerEvents: 'none',
|
||
userSelect: 'none',
|
||
},
|
||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
|
||
backgroundColor: 'var(--chat-editor-selection-bg) !important',
|
||
},
|
||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
||
backgroundColor: 'var(--chat-editor-selection-bg) !important',
|
||
},
|
||
'&.cm-focused .cm-content ::selection': {
|
||
backgroundColor: 'var(--chat-editor-selection-bg)',
|
||
color: 'var(--chat-editor-selection-color)',
|
||
},
|
||
'.cm-content ::selection': {
|
||
backgroundColor: 'var(--chat-editor-selection-bg)',
|
||
color: 'var(--chat-editor-selection-color)',
|
||
},
|
||
'.cm-cursor': {
|
||
borderLeftColor: 'var(--chat-editor-accent-color, #4a9eff)',
|
||
borderLeftWidth: '2px',
|
||
},
|
||
};
|
||
|
||
function isTouchLikeDevice(): boolean {
|
||
if (typeof window === 'undefined') return false;
|
||
return (
|
||
(typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0) ||
|
||
(typeof window.matchMedia === 'function' &&
|
||
window.matchMedia('(hover: none), (pointer: coarse)').matches)
|
||
);
|
||
}
|
||
|
||
const SLASH_PANEL_THEME_VARS = [
|
||
'--chat-editor-accent-color',
|
||
'--accent',
|
||
'--background',
|
||
'--chat-editor-bg-tertiary',
|
||
'--chat-editor-border-color',
|
||
'--foreground',
|
||
'--font-mono',
|
||
'--font-sans',
|
||
'--muted-foreground',
|
||
'--chat-editor-text-primary',
|
||
'--chat-editor-text-secondary',
|
||
] as const;
|
||
|
||
function SendIcon() {
|
||
return (
|
||
<svg
|
||
className={styles.sendIcon}
|
||
viewBox="0 0 20 20"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
d="M10 15.5v-11M5.5 9 10 4.5 14.5 9"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function StopIcon() {
|
||
return <span className={styles.stopIcon} aria-hidden="true" />;
|
||
}
|
||
|
||
function LoadingIcon() {
|
||
return <span className={styles.loadingIcon} aria-hidden="true" />;
|
||
}
|
||
|
||
function QuickActionsIcon() {
|
||
return (
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
{[7, 12, 17].flatMap((y) =>
|
||
[7, 12, 17].map((x) => (
|
||
<circle
|
||
key={`${x}-${y}`}
|
||
cx={x}
|
||
cy={y}
|
||
r="1.35"
|
||
fill="currentColor"
|
||
/>
|
||
)),
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function attachComposerGlow(glowRootEl: HTMLElement, inputEl: HTMLElement) {
|
||
let glowRaf: number | undefined;
|
||
let pulseRaf: number | undefined;
|
||
let pulseDecayTimer: number | undefined;
|
||
let typingTimer: number | undefined;
|
||
let glowCurrent = 0;
|
||
let pulseCurrent = 0;
|
||
|
||
const apply = (on: number, pulse: number) => {
|
||
glowRootEl.style.setProperty('--dac-glow-on', on.toFixed(4));
|
||
glowRootEl.style.setProperty('--dac-glow-pulse', pulse.toFixed(4));
|
||
};
|
||
|
||
const animateGlow = (target: number) => {
|
||
if (glowRaf !== undefined) window.cancelAnimationFrame(glowRaf);
|
||
const start = glowCurrent;
|
||
const diff = target - start;
|
||
if (Math.abs(diff) < 0.001) {
|
||
glowCurrent = target;
|
||
apply(target, pulseCurrent);
|
||
return;
|
||
}
|
||
const t0 = performance.now();
|
||
const tick = (now: number) => {
|
||
const t = Math.min((now - t0) / 220, 1);
|
||
glowCurrent = start + diff * (1 - (1 - t) ** 2);
|
||
apply(glowCurrent, pulseCurrent);
|
||
glowRaf = t < 1 ? window.requestAnimationFrame(tick) : undefined;
|
||
};
|
||
glowRaf = window.requestAnimationFrame(tick);
|
||
};
|
||
|
||
const animatePulseDecay = () => {
|
||
if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
|
||
const start = pulseCurrent;
|
||
const t0 = performance.now();
|
||
const tick = (now: number) => {
|
||
const t = Math.min((now - t0) / 300, 1);
|
||
pulseCurrent = start * (1 - t);
|
||
apply(glowCurrent, pulseCurrent);
|
||
pulseRaf = t < 1 ? window.requestAnimationFrame(tick) : undefined;
|
||
};
|
||
pulseRaf = window.requestAnimationFrame(tick);
|
||
};
|
||
|
||
const setTyping = (on: boolean) => {
|
||
if (on) glowRootEl.setAttribute('data-dac-typing', '');
|
||
else glowRootEl.removeAttribute('data-dac-typing');
|
||
};
|
||
|
||
const onFocus = () => animateGlow(1);
|
||
const onBlur = () => {
|
||
animateGlow(0);
|
||
setTyping(false);
|
||
if (typingTimer !== undefined) window.clearTimeout(typingTimer);
|
||
};
|
||
const onKeydown = () => {
|
||
if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
|
||
if (pulseDecayTimer !== undefined) window.clearTimeout(pulseDecayTimer);
|
||
pulseCurrent = 1;
|
||
apply(glowCurrent, 1);
|
||
pulseDecayTimer = window.setTimeout(animatePulseDecay, 100);
|
||
setTyping(true);
|
||
if (typingTimer !== undefined) window.clearTimeout(typingTimer);
|
||
typingTimer = window.setTimeout(() => setTyping(false), 650);
|
||
};
|
||
|
||
inputEl.addEventListener('focus', onFocus);
|
||
inputEl.addEventListener('blur', onBlur);
|
||
inputEl.addEventListener('keydown', onKeydown);
|
||
if (document.activeElement === inputEl) animateGlow(1);
|
||
|
||
return () => {
|
||
if (glowRaf !== undefined) window.cancelAnimationFrame(glowRaf);
|
||
if (pulseRaf !== undefined) window.cancelAnimationFrame(pulseRaf);
|
||
if (pulseDecayTimer !== undefined) window.clearTimeout(pulseDecayTimer);
|
||
if (typingTimer !== undefined) window.clearTimeout(typingTimer);
|
||
inputEl.removeEventListener('focus', onFocus);
|
||
inputEl.removeEventListener('blur', onBlur);
|
||
inputEl.removeEventListener('keydown', onKeydown);
|
||
apply(0, 0);
|
||
setTyping(false);
|
||
};
|
||
}
|
||
|
||
function WidthModeIcon({ mode }: { mode: '1000' | 'wide' }) {
|
||
if (mode === 'wide') {
|
||
return (
|
||
<svg viewBox="0 0 1024 1024" aria-hidden="true">
|
||
<path
|
||
d="M550.012 486.537a8.16 8.16 0 0 1 8.17-8.17h305.36l-111.88-111.89c-3.19-3.19-3.19-8.4 0-11.59l25.08-25.08c3.19-3.19 8.4-3.19 11.59 0l168.61 168.6c3.19 3.19 3.19 8.4 0 11.59l-164.47 168.67c-3.19 3.19-8.4 3.19-11.59 0l-25.61-25.61c-3.19-3.19-3.19-8.4 0-11.59l106.58-110.78-303.62 0.11c-4.52 0-8.23-3.71-8.23-8.23v-36.03z"
|
||
fill="currentColor"
|
||
transform="translate(-483.41 0)"
|
||
/>
|
||
<path
|
||
d="M473.532 524.327a8.16 8.16 0 0 1-8.17 8.17h-305.36l111.88 111.88c3.19 3.19 3.19 8.4 0 11.59l-25.09 25.09c-3.19 3.19-8.4 3.19-11.59 0l-168.6-168.61c-3.19-3.19-3.19-8.4 0-11.59l164.47-168.67c3.19-3.19 8.4-3.19 11.59 0l25.61 25.61c3.19 3.19 3.19 8.4 0 11.59l-106.59 110.78 303.62-0.11c4.52 0 8.23 3.71 8.23 8.23v36.04z"
|
||
fill="currentColor"
|
||
transform="translate(483.41 0)"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<svg viewBox="0 0 1024 1024" aria-hidden="true">
|
||
<path
|
||
d="M473.532 524.327a8.16 8.16 0 0 1-8.17 8.17h-305.36l111.88 111.88c3.19 3.19 3.19 8.4 0 11.59l-25.09 25.09c-3.19 3.19-8.4 3.19-11.59 0l-168.6-168.61c-3.19-3.19-3.19-8.4 0-11.59l164.47-168.67c3.19-3.19 8.4-3.19 11.59 0l25.61 25.61c3.19 3.19 3.19 8.4 0 11.59l-106.59 110.78 303.62-0.11c4.52 0 8.23 3.71 8.23 8.23v36.04zM550.012 486.537a8.16 8.16 0 0 1 8.17-8.17h305.36l-111.88-111.89c-3.19-3.19-3.19-8.4 0-11.59l25.08-25.08c3.19-3.19 8.4-3.19 11.59 0l168.61 168.6c3.19 3.19 3.19 8.4 0 11.59l-164.47 168.67c-3.19 3.19-8.4 3.19-11.59 0l-25.61-25.61c-3.19-3.19-3.19-8.4 0-11.59l106.58-110.78-303.62 0.11c-4.52 0-8.23-3.71-8.23-8.23v-36.03z"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ChevronDownIcon() {
|
||
return <span className={styles.chevronDown} aria-hidden="true" />;
|
||
}
|
||
|
||
function ModelIcon() {
|
||
return (
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path
|
||
d="M12 3.5 19.4 7.8v8.4L12 20.5l-7.4-4.3V7.8L12 3.5Z"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.8"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="m8.2 9.7 3.8 2.2 3.8-2.2M12 11.9v4.4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.8"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
interface DropdownItem {
|
||
id: string;
|
||
label: string;
|
||
description?: string;
|
||
icon?: ReactNode;
|
||
}
|
||
|
||
interface QuickActionItem {
|
||
id: string;
|
||
label: string;
|
||
action:
|
||
| {
|
||
type: 'run';
|
||
command: string;
|
||
}
|
||
| {
|
||
type: 'insert';
|
||
text: string;
|
||
}
|
||
| {
|
||
type: 'shell';
|
||
}
|
||
| {
|
||
type: 'key';
|
||
item: QuickKeyItem;
|
||
};
|
||
}
|
||
|
||
function getQuickActionCommandName(action: QuickActionItem): string | null {
|
||
const text =
|
||
action.action.type === 'run'
|
||
? action.action.command
|
||
: action.action.type === 'insert'
|
||
? action.action.text
|
||
: '';
|
||
const match = text.trimStart().match(/^\/([^\s]+)/);
|
||
return match?.[1] ?? null;
|
||
}
|
||
|
||
interface QuickKeyItem {
|
||
id: string;
|
||
label: string;
|
||
descriptionKey: string;
|
||
event: KeyboardEventInit & { key: string };
|
||
}
|
||
|
||
const QUICK_KEY_ITEMS: QuickKeyItem[] = [
|
||
{
|
||
id: 'tab',
|
||
label: 'Tab',
|
||
descriptionKey: 'quickKeys.tab',
|
||
event: { key: 'Tab', code: 'Tab' },
|
||
},
|
||
{
|
||
id: 'escape',
|
||
label: 'Esc',
|
||
descriptionKey: 'quickKeys.escape',
|
||
event: { key: 'Escape', code: 'Escape' },
|
||
},
|
||
{
|
||
id: 'arrow-up',
|
||
label: '↑',
|
||
descriptionKey: 'quickKeys.history',
|
||
event: { key: 'ArrowUp', code: 'ArrowUp' },
|
||
},
|
||
{
|
||
id: 'arrow-down',
|
||
label: '↓',
|
||
descriptionKey: 'quickKeys.history',
|
||
event: { key: 'ArrowDown', code: 'ArrowDown' },
|
||
},
|
||
{
|
||
id: 'arrow-left',
|
||
label: '←',
|
||
descriptionKey: 'quickKeys.cursor',
|
||
event: { key: 'ArrowLeft', code: 'ArrowLeft' },
|
||
},
|
||
{
|
||
id: 'arrow-right',
|
||
label: '→',
|
||
descriptionKey: 'quickKeys.cursor',
|
||
event: { key: 'ArrowRight', code: 'ArrowRight' },
|
||
},
|
||
];
|
||
|
||
function CheckIcon() {
|
||
return (
|
||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||
<path
|
||
d="m3 8.3 3.1 3.1L13 4.6"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.8"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function getModeLabel(modeId: string, t: (key: string) => string): string {
|
||
const labels: Record<string, string> = {
|
||
plan: t('mode.label.plan'),
|
||
default: t('mode.label.default'),
|
||
'auto-edit': t('mode.label.auto-edit'),
|
||
auto: t('mode.label.auto'),
|
||
yolo: t('mode.label.yolo'),
|
||
};
|
||
return labels[modeId] ?? modeId;
|
||
}
|
||
|
||
function getModeListLabel(modeId: string, t: (key: string) => string): string {
|
||
const labels: Record<string, string> = {
|
||
plan: t('mode.listLabel.plan'),
|
||
default: t('mode.listLabel.default'),
|
||
'auto-edit': t('mode.listLabel.auto-edit'),
|
||
auto: t('mode.listLabel.auto'),
|
||
yolo: t('mode.listLabel.yolo'),
|
||
};
|
||
return labels[modeId] ?? getModeLabel(modeId, t);
|
||
}
|
||
|
||
function ToolbarDropdown({
|
||
open,
|
||
items,
|
||
activeId,
|
||
onClose,
|
||
onSelect,
|
||
anchorRef,
|
||
showCheck = false,
|
||
maxHeight,
|
||
}: {
|
||
open: boolean;
|
||
items: DropdownItem[];
|
||
activeId: string;
|
||
onClose: () => void;
|
||
onSelect: (id: string) => void;
|
||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||
showCheck?: boolean;
|
||
maxHeight?: number;
|
||
}) {
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onPointerOutside = (event: Event) => {
|
||
if (event instanceof MouseEvent && event.button !== 0) return;
|
||
if (event.defaultPrevented) return;
|
||
const dropdown = dropdownRef.current;
|
||
const anchor = anchorRef.current;
|
||
const target = event.target;
|
||
if (
|
||
dropdown &&
|
||
target instanceof Node &&
|
||
!dropdown.contains(target) &&
|
||
anchor &&
|
||
!anchor.contains(target)
|
||
) {
|
||
onClose();
|
||
}
|
||
};
|
||
window.addEventListener('mousedown', onPointerOutside);
|
||
return () => window.removeEventListener('mousedown', onPointerOutside);
|
||
}, [open, onClose, anchorRef]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onEscape = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onClose();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onEscape);
|
||
return () => window.removeEventListener('keydown', onEscape);
|
||
}, [open, onClose]);
|
||
|
||
if (!open) return null;
|
||
|
||
const hasRichItems = items.some((item) => item.description || item.icon);
|
||
const hasCheckItems = hasRichItems || showCheck;
|
||
|
||
return (
|
||
<div
|
||
ref={dropdownRef}
|
||
className={`${styles.dropdown} ${
|
||
hasRichItems
|
||
? styles.dropdownRich
|
||
: showCheck
|
||
? styles.dropdownCheck
|
||
: ''
|
||
}`}
|
||
style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined}
|
||
>
|
||
{items.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`${styles.dropdownItem} ${
|
||
item.id === activeId ? styles.dropdownItemActive : ''
|
||
}`}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
onSelect(item.id);
|
||
}}
|
||
>
|
||
{hasCheckItems ? (
|
||
<>
|
||
{hasRichItems && (
|
||
<span className={styles.dropdownItemIcon}>{item.icon}</span>
|
||
)}
|
||
<span className={styles.dropdownItemContent}>
|
||
<span className={styles.dropdownItemLabel}>{item.label}</span>
|
||
{item.description && (
|
||
<span className={styles.dropdownItemDesc}>
|
||
{item.description}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className={styles.dropdownItemCheck}>
|
||
{item.id === activeId ? <CheckIcon /> : null}
|
||
</span>
|
||
</>
|
||
) : (
|
||
item.label
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SlashCommandPanel({
|
||
menu,
|
||
anchorRef,
|
||
panelRef,
|
||
onSelect,
|
||
onAccept,
|
||
}: {
|
||
menu: SlashMenuState;
|
||
anchorRef: RefObject<HTMLElement | null>;
|
||
panelRef: RefObject<HTMLDivElement | null>;
|
||
onSelect: (index: number) => boolean;
|
||
onAccept: (index?: number) => boolean;
|
||
}) {
|
||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||
const [anchorRect, setAnchorRect] = useState<{
|
||
left: number;
|
||
bottom: number;
|
||
width: number;
|
||
} | null>(null);
|
||
const [themeVars, setThemeVars] = useState<CSSProperties>({});
|
||
const [hoverDetail, setHoverDetail] = useState<{
|
||
label: string;
|
||
detail: string;
|
||
left: number;
|
||
top?: number;
|
||
bottom?: number;
|
||
maxHeight: number;
|
||
} | null>(null);
|
||
const detailRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
itemRefs.current[menu.selectedIndex]?.scrollIntoView({
|
||
block: 'nearest',
|
||
});
|
||
}, [menu.items, menu.selectedIndex]);
|
||
|
||
useLayoutEffect(() => {
|
||
const anchor = anchorRef.current;
|
||
if (!anchor) return undefined;
|
||
|
||
const update = () => {
|
||
const rect = anchor.getBoundingClientRect();
|
||
const computedStyle = getComputedStyle(anchor);
|
||
const nextThemeVars = Object.fromEntries(
|
||
SLASH_PANEL_THEME_VARS.map((name) => [
|
||
name,
|
||
computedStyle.getPropertyValue(name),
|
||
]),
|
||
) as CSSProperties;
|
||
setAnchorRect({
|
||
left: Math.max(12, Math.min(rect.left + 16, window.innerWidth - 252)),
|
||
bottom: window.innerHeight - rect.top + 8,
|
||
width: rect.width,
|
||
});
|
||
setThemeVars(nextThemeVars);
|
||
};
|
||
|
||
update();
|
||
const resizeObserver = new ResizeObserver(update);
|
||
resizeObserver.observe(anchor);
|
||
window.addEventListener('resize', update);
|
||
window.addEventListener('scroll', update, true);
|
||
return () => {
|
||
resizeObserver.disconnect();
|
||
window.removeEventListener('resize', update);
|
||
window.removeEventListener('scroll', update, true);
|
||
};
|
||
}, [anchorRef, menu.items]);
|
||
|
||
useEffect(() => {
|
||
setHoverDetail(null);
|
||
}, [menu.items]);
|
||
|
||
const measureText = (text: string) => Array.from(text).length;
|
||
const maxLabelLength = Math.max(
|
||
...menu.items.map((item) => measureText(item.label)),
|
||
0,
|
||
);
|
||
const maxDetailLength = Math.max(
|
||
...menu.items.map((item) => measureText(item.detail ?? '')),
|
||
0,
|
||
);
|
||
const hasDetailColumn = maxDetailLength > 0;
|
||
const panelStyle = {
|
||
'--slash-command-col': `${Math.min(
|
||
Math.max(maxLabelLength + 1, 10),
|
||
24,
|
||
)}ch`,
|
||
'--slash-desc-col': hasDetailColumn
|
||
? `${Math.min(Math.max(maxDetailLength + 1, 18), 36)}ch`
|
||
: '0px',
|
||
'--slash-column-gap': hasDetailColumn ? '2ch' : '0px',
|
||
} as CSSProperties;
|
||
|
||
if (!anchorRect) return null;
|
||
|
||
const rowPlans = planSlashSectionRows(menu.items, menu.kind);
|
||
|
||
const positionedPanelStyle = {
|
||
...panelStyle,
|
||
...themeVars,
|
||
left: anchorRect.left,
|
||
bottom: anchorRect.bottom,
|
||
'--slash-anchor-width': `${anchorRect.width}px`,
|
||
} as CSSProperties;
|
||
|
||
return createPortal(
|
||
<div ref={panelRef} className={styles.slashPortalLayer} style={themeVars}>
|
||
<div
|
||
className={styles.slashPanel}
|
||
style={positionedPanelStyle}
|
||
role="listbox"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onMouseLeave={(event) => {
|
||
const nextTarget = event.relatedTarget;
|
||
if (
|
||
nextTarget instanceof Node &&
|
||
detailRef.current?.contains(nextTarget)
|
||
) {
|
||
return;
|
||
}
|
||
setHoverDetail(null);
|
||
}}
|
||
>
|
||
<div className={styles.slashPanelBody}>
|
||
<div
|
||
className={styles.slashList}
|
||
onScroll={() => setHoverDetail(null)}
|
||
>
|
||
{menu.items.map((item, index) => {
|
||
const plan = rowPlans[index];
|
||
return (
|
||
<div key={`${item.id}:${index}`} className={styles.slashEntry}>
|
||
{plan.showHeader && (
|
||
<>
|
||
{plan.showDivider && (
|
||
<div className={styles.slashSection} />
|
||
)}
|
||
<div className={styles.slashSectionHeader}>
|
||
<span>{item.section}</span>
|
||
{plan.count > 0 ? (
|
||
<span className={styles.slashSectionCount}>
|
||
{plan.count}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
)}
|
||
<button
|
||
ref={(node) => {
|
||
itemRefs.current[index] = node;
|
||
}}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={index === menu.selectedIndex}
|
||
className={`${styles.slashItem} ${
|
||
index === menu.selectedIndex ? styles.slashItemActive : ''
|
||
}`}
|
||
onMouseEnter={(event) => {
|
||
onSelect(index);
|
||
if (!item.detail) {
|
||
setHoverDetail(null);
|
||
return;
|
||
}
|
||
const row = event.currentTarget;
|
||
const gap = 8;
|
||
const detailMaxHeight = 180;
|
||
const rowRect = row.getBoundingClientRect();
|
||
const detailWidth = 320;
|
||
const left = Math.min(
|
||
rowRect.left + Math.min(220, rowRect.width * 0.34),
|
||
window.innerWidth - detailWidth - 12,
|
||
);
|
||
const spaceBelow =
|
||
window.innerHeight - rowRect.bottom - gap - 12;
|
||
const spaceAbove = rowRect.top - gap - 12;
|
||
const showBelow =
|
||
spaceBelow >= 96 || spaceBelow >= spaceAbove;
|
||
const maxHeight = Math.max(
|
||
72,
|
||
Math.min(
|
||
detailMaxHeight,
|
||
showBelow ? spaceBelow : spaceAbove,
|
||
),
|
||
);
|
||
setHoverDetail({
|
||
label: item.label,
|
||
detail: item.detail,
|
||
left: Math.max(12, left),
|
||
...(showBelow
|
||
? { top: rowRect.bottom + gap }
|
||
: {
|
||
bottom: window.innerHeight - rowRect.top + gap,
|
||
}),
|
||
maxHeight,
|
||
});
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
onAccept(index);
|
||
}}
|
||
>
|
||
<span className={styles.slashCommand}>{item.label}</span>
|
||
{item.detail && (
|
||
<span className={styles.slashDescription}>
|
||
{item.detail}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{hoverDetail && (
|
||
<div
|
||
ref={detailRef}
|
||
className={styles.slashDetail}
|
||
style={{
|
||
...themeVars,
|
||
left: hoverDetail.left,
|
||
top: hoverDetail.top,
|
||
bottom: hoverDetail.bottom,
|
||
maxHeight: hoverDetail.maxHeight,
|
||
}}
|
||
>
|
||
<div className={styles.slashDetailCommand}>{hoverDetail.label}</div>
|
||
<div className={styles.slashDetailText}>{hoverDetail.detail}</div>
|
||
</div>
|
||
)}
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
function QuickActionsPanel({
|
||
actions,
|
||
onRun,
|
||
onPressKey,
|
||
}: {
|
||
actions: readonly QuickActionItem[];
|
||
onRun: (action: QuickActionItem) => void;
|
||
onPressKey: (item: QuickKeyItem) => void;
|
||
}) {
|
||
const { t } = useI18n();
|
||
|
||
return (
|
||
<div
|
||
className={styles.quickActionsPanel}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className={styles.quickActionsHeader}>{t('quickActions.title')}</div>
|
||
<div className={styles.quickActionsLayout}>
|
||
<div className={styles.quickActionsGrid}>
|
||
{actions.map((action) => (
|
||
<button
|
||
key={action.id}
|
||
type="button"
|
||
className={styles.quickAction}
|
||
onClick={() => onRun(action)}
|
||
>
|
||
<span className={styles.quickActionLabel}>{action.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className={styles.quickKeysGrid}>
|
||
{QUICK_KEY_ITEMS.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={styles.quickKey}
|
||
title={t(item.descriptionKey)}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={() => onPressKey(item)}
|
||
>
|
||
<span className={styles.quickKeyLabel}>{item.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const ChatEditor = memo(
|
||
forwardRef<EditorHandle, ChatEditorProps>(function ChatEditor(props, ref) {
|
||
const {
|
||
onSubmit,
|
||
onCycleMode,
|
||
onToggleShortcuts,
|
||
onCancel,
|
||
isRunning = false,
|
||
isPreparing = false,
|
||
cancelArmed = false,
|
||
disabled = false,
|
||
placeholderText = 'Type a message...',
|
||
commands,
|
||
skills = [],
|
||
slashCommandCategoryOrder,
|
||
queuedMessages = [],
|
||
onPopQueuedMessages,
|
||
currentMode = 'default',
|
||
currentModel = '',
|
||
chatWidthMode = '1000',
|
||
showChatWidthToggle = true,
|
||
chatWidthToggleMin,
|
||
visibleToolbarActions,
|
||
availableModels = [],
|
||
onSelectMode,
|
||
onSelectModel,
|
||
onChatWidthModeChange,
|
||
onFocusFooter,
|
||
dialogOpen = false,
|
||
followupState,
|
||
onAcceptFollowup,
|
||
onDismissFollowup,
|
||
sessionName,
|
||
composerInput,
|
||
composerInputVersion,
|
||
atProviders,
|
||
composerTagIcons,
|
||
} = props;
|
||
|
||
const core = useComposerCore({
|
||
onSubmit,
|
||
onCycleMode,
|
||
onToggleShortcuts,
|
||
disabled,
|
||
placeholderText,
|
||
commands,
|
||
skills,
|
||
slashCommandCategoryOrder,
|
||
queuedMessages,
|
||
onPopQueuedMessages,
|
||
currentMode,
|
||
onFocusFooter,
|
||
dialogOpen,
|
||
followupState,
|
||
onAcceptFollowup,
|
||
onDismissFollowup,
|
||
sessionName,
|
||
composerInput,
|
||
composerInputVersion,
|
||
atProviders,
|
||
composerTagIcons,
|
||
editorTheme: CHAT_EDITOR_THEME,
|
||
});
|
||
|
||
const { t } = useI18n();
|
||
const {
|
||
renderComposerToolbarStart: ToolbarStart,
|
||
renderComposerToolbarEnd: ToolbarEnd,
|
||
renderComposerToolbarRight: ToolbarRight,
|
||
} = useWebShellCustomization();
|
||
|
||
useImperativeHandle(ref, () => core.handle, [core.handle]);
|
||
|
||
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
|
||
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
||
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
|
||
const [showQuickActions, setShowQuickActions] = useState(isTouchLikeDevice);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const slashPanelRef = useRef<HTMLDivElement>(null);
|
||
const atPanelRef = useRef<HTMLDivElement>(null);
|
||
const modeBtnRef = useRef<HTMLButtonElement>(null);
|
||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||
const [widthToggleFits, setWidthToggleFits] = useState(false);
|
||
const slashMenu = core.slashMenu;
|
||
const closeSlashMenu = core.closeSlashMenu;
|
||
const atMenu = core.atMenu;
|
||
const closeAtMenu = core.closeAtMenu;
|
||
const hasSlashMenu = Boolean(slashMenu);
|
||
const hasAtMenu = Boolean(atMenu);
|
||
const editorViewRef = core.viewRef;
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||
const media = window.matchMedia('(hover: none), (pointer: coarse)');
|
||
const update = () => setShowQuickActions(isTouchLikeDevice());
|
||
update();
|
||
media.addEventListener('change', update);
|
||
return () => media.removeEventListener('change', update);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!showQuickActions) setQuickActionsOpen(false);
|
||
}, [showQuickActions]);
|
||
|
||
useEffect(() => {
|
||
if (!hasSlashMenu && !hasAtMenu) return;
|
||
const onPointerOutside = (event: Event) => {
|
||
const target = event.target;
|
||
const container = containerRef.current;
|
||
if (
|
||
target instanceof Node &&
|
||
container &&
|
||
!container.contains(target) &&
|
||
!slashPanelRef.current?.contains(target) &&
|
||
!atPanelRef.current?.contains(target)
|
||
) {
|
||
closeSlashMenu();
|
||
closeAtMenu();
|
||
}
|
||
};
|
||
window.addEventListener('mousedown', onPointerOutside);
|
||
window.addEventListener('touchstart', onPointerOutside);
|
||
return () => {
|
||
window.removeEventListener('mousedown', onPointerOutside);
|
||
window.removeEventListener('touchstart', onPointerOutside);
|
||
};
|
||
}, [hasAtMenu, hasSlashMenu, closeAtMenu, closeSlashMenu]);
|
||
|
||
useEffect(() => {
|
||
const glowRoot = containerRef.current;
|
||
const inputEl = editorViewRef.current?.contentDOM;
|
||
if (!glowRoot || !inputEl) return undefined;
|
||
return attachComposerGlow(glowRoot, inputEl);
|
||
}, [editorViewRef]);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
const minWidth = chatWidthToggleMin;
|
||
if (!container || minWidth === undefined) {
|
||
setWidthToggleFits(false);
|
||
return;
|
||
}
|
||
|
||
const update = () => {
|
||
setWidthToggleFits(
|
||
container.getBoundingClientRect().width >= minWidth - 50,
|
||
);
|
||
};
|
||
update();
|
||
|
||
const resizeObserver = new ResizeObserver(update);
|
||
resizeObserver.observe(container);
|
||
return () => resizeObserver.disconnect();
|
||
}, [chatWidthToggleMin]);
|
||
|
||
const modeItems = useMemo<DropdownItem[]>(
|
||
() =>
|
||
DAEMON_APPROVAL_MODES.map((id) => ({
|
||
id,
|
||
label: getModeListLabel(id, t),
|
||
description: t(`mode.desc.${id}`),
|
||
icon: <ModeIcon mode={id} />,
|
||
})),
|
||
[t],
|
||
);
|
||
const visibleActionSet = useMemo(() => {
|
||
if (!visibleToolbarActions) return null;
|
||
const activeActions = visibleToolbarActions.filter((action) =>
|
||
ACTIVE_TOOLBAR_ACTION_SET.has(action),
|
||
);
|
||
return new Set(activeActions);
|
||
}, [visibleToolbarActions]);
|
||
const showToolbarAction = (action: ComposerToolbarAction) => {
|
||
if (!visibleActionSet) return true;
|
||
return visibleActionSet.has(action);
|
||
};
|
||
const commandNames = useMemo(
|
||
() =>
|
||
new Set(commands.map((command) => command.name.replace(/^\/+/, ''))),
|
||
[commands],
|
||
);
|
||
const hasCommand = useCallback(
|
||
(name: string) => commandNames.has(name),
|
||
[commandNames],
|
||
);
|
||
const quickActions = useMemo(
|
||
() =>
|
||
(
|
||
[
|
||
{
|
||
id: 'new',
|
||
label: t('quickActions.new'),
|
||
action: { type: 'run', command: '/new' },
|
||
},
|
||
{
|
||
id: 'resume',
|
||
label: t('quickActions.resume'),
|
||
action: { type: 'run', command: '/resume' },
|
||
},
|
||
{
|
||
id: 'delete',
|
||
label: t('quickActions.delete'),
|
||
action: { type: 'run', command: '/delete' },
|
||
},
|
||
{
|
||
id: 'branch',
|
||
label: t('quickActions.branch'),
|
||
action: { type: 'run', command: '/branch' },
|
||
},
|
||
{
|
||
id: 'rewind',
|
||
label: t('quickActions.rewind'),
|
||
action: { type: 'run', command: '/rewind' },
|
||
},
|
||
{
|
||
id: 'history-search',
|
||
label: t('quickActions.historyQuestion'),
|
||
action: {
|
||
type: 'key',
|
||
item: {
|
||
id: 'ctrl-r',
|
||
label: 'Ctrl+R',
|
||
descriptionKey: 'quickKeys.searchHistory',
|
||
event: { key: 'r', code: 'KeyR', ctrlKey: true },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: 'recap',
|
||
label: t('quickActions.recap'),
|
||
action: { type: 'run', command: '/recap' },
|
||
},
|
||
{
|
||
id: 'stats',
|
||
label: t('quickActions.stats'),
|
||
action: { type: 'run', command: '/stats' },
|
||
},
|
||
{
|
||
id: 'context',
|
||
label: t('quickActions.context'),
|
||
action: { type: 'run', command: '/context' },
|
||
},
|
||
{
|
||
id: 'status',
|
||
label: t('quickActions.status'),
|
||
action: { type: 'run', command: '/status' },
|
||
},
|
||
{
|
||
id: 'skills',
|
||
label: t('quickActions.skills'),
|
||
action: { type: 'run', command: '/skills detail' },
|
||
},
|
||
{
|
||
id: 'tools',
|
||
label: t('quickActions.tools'),
|
||
action: { type: 'run', command: '/tools desc' },
|
||
},
|
||
{
|
||
id: 'agents',
|
||
label: t('quickActions.agents'),
|
||
action: { type: 'run', command: '/agents' },
|
||
},
|
||
{
|
||
id: 'mcp',
|
||
label: t('quickActions.mcp'),
|
||
action: { type: 'run', command: '/mcp' },
|
||
},
|
||
{
|
||
id: 'memory',
|
||
label: t('quickActions.memory'),
|
||
action: { type: 'run', command: '/memory' },
|
||
},
|
||
{
|
||
id: 'theme',
|
||
label: t('quickActions.theme'),
|
||
action: { type: 'run', command: '/theme' },
|
||
},
|
||
{
|
||
id: 'shell',
|
||
label: core.shellMode
|
||
? t('quickActions.exitShellMode')
|
||
: t('quickActions.shellMode'),
|
||
action: { type: 'shell' },
|
||
},
|
||
{
|
||
id: 'goal',
|
||
label: t('quickActions.setGoal'),
|
||
action: { type: 'insert', text: '/goal ' },
|
||
},
|
||
] satisfies QuickActionItem[]
|
||
).filter((action) => {
|
||
const commandName = getQuickActionCommandName(action);
|
||
return !commandName || hasCommand(commandName);
|
||
}),
|
||
[core.shellMode, hasCommand, t],
|
||
);
|
||
|
||
const modelItems = useMemo<DropdownItem[]>(
|
||
() =>
|
||
availableModels.map((m) => ({
|
||
id: m.id,
|
||
label: getModelDisplayName(m.label || m.id),
|
||
})),
|
||
[availableModels],
|
||
);
|
||
|
||
const handleModeSelect = useCallback(
|
||
(modeId: string) => {
|
||
onSelectMode?.(modeId);
|
||
setModeDropdownOpen(false);
|
||
core.focus();
|
||
},
|
||
[onSelectMode, core],
|
||
);
|
||
|
||
const handleModelSelect = useCallback(
|
||
(modelId: string) => {
|
||
onSelectModel?.(modelId);
|
||
setModelDropdownOpen(false);
|
||
core.focus();
|
||
},
|
||
[onSelectModel, core],
|
||
);
|
||
const dispatchComposerKey = useCallback(
|
||
(event: QuickKeyItem['event']) => {
|
||
const view = core.viewRef.current;
|
||
if (!view) return;
|
||
view.focus();
|
||
view.contentDOM.dispatchEvent(
|
||
new KeyboardEvent('keydown', {
|
||
...event,
|
||
bubbles: true,
|
||
cancelable: true,
|
||
}),
|
||
);
|
||
},
|
||
[core],
|
||
);
|
||
const runQuickAction = useCallback(
|
||
(action: QuickActionItem) => {
|
||
setQuickActionsOpen(false);
|
||
setModeDropdownOpen(false);
|
||
setModelDropdownOpen(false);
|
||
core.closeSlashMenu();
|
||
core.closeAtMenu();
|
||
if (action.action.type === 'insert') {
|
||
core.insertText(action.action.text, { mode: 'replace' });
|
||
return;
|
||
}
|
||
if (action.action.type === 'shell') {
|
||
core.toggleShellMode();
|
||
return;
|
||
}
|
||
if (action.action.type === 'key') {
|
||
dispatchComposerKey(action.action.item.event);
|
||
return;
|
||
}
|
||
onSubmit(action.action.command);
|
||
},
|
||
[core, dispatchComposerKey, onSubmit],
|
||
);
|
||
const pressQuickKey = useCallback(
|
||
(item: QuickKeyItem) => {
|
||
dispatchComposerKey(item.event);
|
||
if (item.id === 'ctrl-r') {
|
||
setQuickActionsOpen(false);
|
||
}
|
||
},
|
||
[dispatchComposerKey],
|
||
);
|
||
|
||
const {
|
||
searchMode,
|
||
searchQuery,
|
||
searchMatches,
|
||
searchActiveIndex,
|
||
searchInputRef,
|
||
searchUiRef,
|
||
closeSearch,
|
||
handleSearchKeyDown,
|
||
handleSearchInput,
|
||
handleSearchCompositionEnd,
|
||
} = core.searchState;
|
||
|
||
const renderComposerTagContent = (tag: WebShellComposerTag) => {
|
||
const rawTagLabel = getComposerTagLabel(tag);
|
||
const tagValue = getComposerTagValue(tag);
|
||
const tagLabel = tag.kind ? '' : rawTagLabel;
|
||
const iconUrl = getComposerTagIconUrl(tag.kind, composerTagIcons);
|
||
if (!tagLabel && !tagValue) {
|
||
return <span className={styles.tagLabel}>{tag.id}</span>;
|
||
}
|
||
return (
|
||
<>
|
||
{iconUrl && (
|
||
<span
|
||
className={styles.tagIcon}
|
||
style={cssUrlVar('--composer-tag-icon-url', iconUrl)}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
{tagLabel && <span className={styles.tagLabel}>{tagLabel}</span>}
|
||
{tagValue && <span className={styles.tagValue}>{tagValue}</span>}
|
||
</>
|
||
);
|
||
};
|
||
|
||
// Mode display label
|
||
const modeLabel = getModeLabel(currentMode, t);
|
||
|
||
// Model display label
|
||
const modelLabel = getModelDisplayName(currentModel);
|
||
const showCancelButton = isRunning && !core.hasContent;
|
||
|
||
return (
|
||
<div className={styles.editorShell} data-composer>
|
||
<div
|
||
ref={containerRef}
|
||
className={styles.container}
|
||
data-dac-glow
|
||
onClick={() => {
|
||
setModeDropdownOpen(false);
|
||
setModelDropdownOpen(false);
|
||
setQuickActionsOpen(false);
|
||
core.focus();
|
||
}}
|
||
>
|
||
<div className={styles.dacAura} aria-hidden="true" />
|
||
<div className={styles.dacHalo} aria-hidden="true" />
|
||
{searchMode && (
|
||
<div
|
||
ref={searchUiRef}
|
||
className={styles.searchPanel}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className={styles.searchBar}>
|
||
<span className={styles.searchLabel}>
|
||
{t('editor.searchLabel')}
|
||
</span>
|
||
<input
|
||
ref={searchInputRef}
|
||
className={styles.searchInput}
|
||
value={searchQuery}
|
||
onChange={handleSearchInput}
|
||
onCompositionEnd={handleSearchCompositionEnd}
|
||
onKeyDown={handleSearchKeyDown}
|
||
placeholder={t('editor.searchPlaceholder')}
|
||
/>
|
||
</div>
|
||
{searchMatches.length > 0 && (
|
||
<div className={styles.searchResults}>
|
||
{searchMatches.map((match, matchIndex) => {
|
||
return (
|
||
<button
|
||
key={`${match}-${matchIndex}`}
|
||
type="button"
|
||
className={`${styles.searchResult} ${
|
||
matchIndex === searchActiveIndex
|
||
? styles.searchResultActive
|
||
: ''
|
||
}`}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
core.replaceEditorText(match);
|
||
closeSearch(false);
|
||
}}
|
||
>
|
||
<span className={styles.searchResultMarker}>
|
||
{matchIndex === searchActiveIndex ? '›' : ''}
|
||
</span>
|
||
<span className={styles.searchResultText}>{match}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{searchMatches.length === 0 && (
|
||
<div className={styles.searchEmpty}>
|
||
{t('editor.noHistory')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className={styles.content}>
|
||
{core.composerTags.length > 0 && (
|
||
<div className={styles.tags}>
|
||
{core.composerTags.map((tag) => (
|
||
<span key={tag.id} className={styles.tag}>
|
||
{renderComposerTagContent(tag)}
|
||
{tag.removable !== false && (
|
||
<button
|
||
type="button"
|
||
className={styles.tagRemove}
|
||
aria-label={`Remove ${getComposerTagDisplay(tag)}`}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
core.removeTopTag(tag.id);
|
||
core.viewRef.current?.focus();
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (
|
||
event.key !== 'Backspace' &&
|
||
event.key !== 'Delete'
|
||
) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
core.removeTopTag(tag.id);
|
||
core.viewRef.current?.focus();
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{core.pastedImages.length > 0 && (
|
||
<div className={styles.images}>
|
||
{core.pastedImages.map((img, i) => (
|
||
<div key={i} className={styles.imageThumb}>
|
||
<img
|
||
src={`data:${img.media_type};base64,${img.data}`}
|
||
alt=""
|
||
/>
|
||
<button
|
||
className={styles.imageRemove}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
core.removeImage(i);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{core.slashMenu && (
|
||
<SlashCommandPanel
|
||
menu={core.slashMenu}
|
||
anchorRef={containerRef}
|
||
panelRef={slashPanelRef}
|
||
onSelect={core.selectSlashCompletion}
|
||
onAccept={core.acceptSlashCompletion}
|
||
/>
|
||
)}
|
||
{core.atMenu && (
|
||
<AtMentionPanel
|
||
menu={core.atMenu}
|
||
anchorRef={containerRef}
|
||
panelRef={atPanelRef}
|
||
onSelect={core.selectAtCompletion}
|
||
onAccept={core.acceptAtCompletion}
|
||
onBack={() => {
|
||
const result = core.backAtCategories();
|
||
if (result === 'categories') {
|
||
window.setTimeout(() => core.focus(), 0);
|
||
}
|
||
return Boolean(result);
|
||
}}
|
||
onSearch={core.updateAtSearch}
|
||
/>
|
||
)}
|
||
<div className={styles.editorArea}>
|
||
{core.shellMode && (
|
||
<span className={styles.shellPrefix} aria-hidden="true">
|
||
!
|
||
</span>
|
||
)}
|
||
<div ref={core.containerRef} />
|
||
</div>
|
||
<div className={styles.toolbar}>
|
||
<div className={styles.toolbarLeading}>
|
||
{ToolbarStart && (
|
||
<div className={styles.toolbarStart}>
|
||
<ToolbarStart
|
||
disabled={disabled}
|
||
isRunning={isRunning}
|
||
currentMode={currentMode}
|
||
currentModel={currentModel}
|
||
sessionName={sessionName}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className={styles.toolbarLeft}>
|
||
{showToolbarAction('approvalMode') && (
|
||
<div className={styles.dropdownWrapper}>
|
||
<ToolbarDropdown
|
||
open={modeDropdownOpen}
|
||
items={modeItems}
|
||
activeId={currentMode}
|
||
onClose={() => setModeDropdownOpen(false)}
|
||
onSelect={handleModeSelect}
|
||
anchorRef={modeBtnRef}
|
||
/>
|
||
<button
|
||
ref={modeBtnRef}
|
||
className={styles.toolBtn}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
core.closeSlashMenu();
|
||
core.closeAtMenu();
|
||
setQuickActionsOpen(false);
|
||
setModeDropdownOpen((v) => !v);
|
||
setModelDropdownOpen(false);
|
||
}}
|
||
aria-label={t('status.mode')}
|
||
>
|
||
<span className={styles.toolBtnModeIcon}>
|
||
<ModeIcon mode={currentMode} />
|
||
</span>
|
||
<span className={styles.toolBtnText}>{modeLabel}</span>
|
||
<span className={styles.toolBtnArrow}>
|
||
<ChevronDownIcon />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
{showToolbarAction('model') && (
|
||
<div className={styles.dropdownWrapper}>
|
||
<ToolbarDropdown
|
||
open={modelDropdownOpen}
|
||
items={modelItems}
|
||
activeId={currentModel}
|
||
onClose={() => setModelDropdownOpen(false)}
|
||
onSelect={handleModelSelect}
|
||
anchorRef={modelBtnRef}
|
||
showCheck
|
||
maxHeight={300}
|
||
/>
|
||
<button
|
||
ref={modelBtnRef}
|
||
className={styles.toolBtn}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
core.closeSlashMenu();
|
||
core.closeAtMenu();
|
||
setQuickActionsOpen(false);
|
||
setModelDropdownOpen((v) => !v);
|
||
setModeDropdownOpen(false);
|
||
}}
|
||
aria-label={t('model.select')}
|
||
>
|
||
<span className={styles.toolBtnModelIcon}>
|
||
<ModelIcon />
|
||
</span>
|
||
<span className={styles.toolBtnText}>{modelLabel}</span>
|
||
<span className={styles.toolBtnArrow}>
|
||
<ChevronDownIcon />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
{ToolbarEnd && (
|
||
<div className={styles.toolbarEnd}>
|
||
<ToolbarEnd
|
||
disabled={disabled}
|
||
isRunning={isRunning}
|
||
currentMode={currentMode}
|
||
currentModel={currentModel}
|
||
sessionName={sessionName}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className={styles.toolbarRight}>
|
||
{showQuickActions && quickActions.length > 0 && (
|
||
<button
|
||
className={`${styles.toolBtn} ${styles.quickActionsBtn}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
core.closeSlashMenu();
|
||
core.closeAtMenu();
|
||
setModeDropdownOpen(false);
|
||
setModelDropdownOpen(false);
|
||
setQuickActionsOpen((value) => !value);
|
||
}}
|
||
aria-expanded={quickActionsOpen}
|
||
aria-label={t('quickActions.open')}
|
||
title={t('quickActions.open')}
|
||
data-tooltip={t('quickActions.open')}
|
||
>
|
||
<span className={styles.toolBtnIcon}>
|
||
<QuickActionsIcon />
|
||
</span>
|
||
</button>
|
||
)}
|
||
{ToolbarRight && (
|
||
<div className={styles.toolbarRightCustom}>
|
||
<ToolbarRight
|
||
disabled={disabled}
|
||
isRunning={isRunning}
|
||
currentMode={currentMode}
|
||
currentModel={currentModel}
|
||
sessionName={sessionName}
|
||
/>
|
||
</div>
|
||
)}
|
||
{showChatWidthToggle &&
|
||
widthToggleFits &&
|
||
showToolbarAction('widthMode') && (
|
||
<button
|
||
className={`${styles.toolBtn} ${styles.widthModeBtn}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onChatWidthModeChange?.(
|
||
chatWidthMode === 'wide' ? '1000' : 'wide',
|
||
);
|
||
}}
|
||
disabled={!onChatWidthModeChange}
|
||
aria-label={
|
||
chatWidthMode === 'wide'
|
||
? t('settings.option.ui.chatWidth.1000')
|
||
: t('settings.option.ui.chatWidth.wide')
|
||
}
|
||
title={
|
||
chatWidthMode === 'wide'
|
||
? t('settings.option.ui.chatWidth.1000')
|
||
: t('settings.option.ui.chatWidth.wide')
|
||
}
|
||
data-tooltip={
|
||
chatWidthMode === 'wide'
|
||
? t('settings.option.ui.chatWidth.1000')
|
||
: t('settings.option.ui.chatWidth.wide')
|
||
}
|
||
>
|
||
<span className={styles.toolBtnIcon}>
|
||
<WidthModeIcon mode={chatWidthMode} />
|
||
</span>
|
||
</button>
|
||
)}
|
||
{showToolbarAction('voice') && (
|
||
<VoiceButton
|
||
disabled={disabled}
|
||
onInsert={(text) => {
|
||
const existing = core.getText();
|
||
const sep = existing && !/\s$/.test(existing) ? ' ' : '';
|
||
core.insertText(`${sep}${text} `);
|
||
core.focus();
|
||
}}
|
||
/>
|
||
)}
|
||
<button
|
||
className={
|
||
isPreparing || showCancelButton
|
||
? `${styles.sendBtn} ${styles.sendBtnRunning}${
|
||
cancelArmed ? ` ${styles.sendBtnArmed}` : ''
|
||
}`
|
||
: styles.sendBtn
|
||
}
|
||
disabled={
|
||
isPreparing
|
||
? true
|
||
: showCancelButton
|
||
? !onCancel
|
||
: core.disabled || !core.hasContent
|
||
}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (isPreparing) {
|
||
return;
|
||
}
|
||
if (showCancelButton) {
|
||
onCancel?.();
|
||
return;
|
||
}
|
||
core.submitText();
|
||
}}
|
||
aria-label={
|
||
isPreparing
|
||
? t('common.loading')
|
||
: showCancelButton
|
||
? cancelArmed
|
||
? t('stream.cancelArmed')
|
||
: t('stream.cancel')
|
||
: t('editor.send')
|
||
}
|
||
title={
|
||
isRunning && cancelArmed
|
||
? t('stream.cancelArmed')
|
||
: undefined
|
||
}
|
||
>
|
||
{isPreparing ? (
|
||
<LoadingIcon />
|
||
) : showCancelButton ? (
|
||
cancelArmed ? (
|
||
<span className={styles.escLabel} aria-hidden="true">
|
||
Esc
|
||
</span>
|
||
) : (
|
||
<StopIcon />
|
||
)
|
||
) : (
|
||
<SendIcon />
|
||
)}
|
||
</button>
|
||
<span
|
||
role="status"
|
||
aria-live="polite"
|
||
className={styles.srOnly}
|
||
>
|
||
{isRunning && cancelArmed ? t('stream.cancelArmed') : ''}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{showQuickActions && quickActionsOpen && quickActions.length > 0 && (
|
||
<QuickActionsPanel
|
||
actions={quickActions}
|
||
onRun={runQuickAction}
|
||
onPressKey={pressQuickKey}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}),
|
||
);
|