qwen-code/packages/web-shell/client/components/ChatEditor.tsx

2585 lines
86 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
forwardRef,
memo,
useImperativeHandle,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode, RefObject } from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
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 {
DaemonSessionGroupPresetColor,
DaemonWorkspaceGitStatus,
} from '@qwen-code/sdk/daemon';
import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay';
import type { SkillInfo } from '../completions/slashCompletion';
import { useI18n } from '../i18n';
import { useWebShellPortalRoot } from '../portalRoot';
import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion';
import { SpecularComposerEffect } from './SpecularComposerEffect';
import {
useWebShellCustomization,
type WebShellComposerInput,
type WebShellComposerTag,
type WebShellComposerTagIconMap,
type WebShellAtProvider,
type WebShellBuiltinAtProvidersConfig,
} from '../customization';
import {
useComposerCore,
type ComposerSubmitMetadata,
type EditorHandle,
type SlashMenuState,
getComposerTagDisplay,
getComposerTagLabel,
getComposerTagValue,
} from '../hooks/useComposerCore';
import { AtMentionPanel } from './AtMentionPanel';
import { cssUrlVar } from '../utils/cssUrlVar';
import {
getComposerTagIconUrl,
isBuiltinComposerTagIconUrl,
} from '../utils/composerTag';
import { isSafeImageSrc } from './messages/Markdown';
import { ModeIcon } from './ModeIcon';
import { planSlashSectionRows } from '../utils/slashSectionPlan';
import { getModelDisplayName } from '../utils/modelDisplay';
import { VoiceButton } from '../voice/VoiceButton';
import type {
VoiceStatusRevision,
VoiceWorkspaceTarget,
} from '../voice/voice-workspace-target';
import {
GitBranchChipContent,
GitBranchIndicator,
gitBranchAriaLabel,
} from './GitBranchIndicator';
import { GitModePopover, type SessionGitIntent } from './GitModePopover';
import { BranchPickerPopover } from './BranchPickerPopover';
import { WorkspaceIndicator } from './WorkspaceIndicator';
import { ChevronDownIcon, FolderClosedIcon } from 'lucide-react';
import { WorkspaceSelector } from './WorkspaceSelector';
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from './ui/popover';
import { Input } from './ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './ui/tooltip';
import {
filterToolbarDropdownItems,
getToolbarExpansionBudget,
getToolbarItemVisibilityWithHysteresis,
resolveToolbarModelLabel,
type ToolbarDropdownItem,
} from './toolbarDropdown';
import styles from './ChatEditor.module.css';
export type ComposerToolbarAction =
| 'approvalMode'
| 'gitBranch'
| 'model'
| 'commands'
| 'files'
| 'widthMode'
| 'voice'
| 'workspace';
const ACTIVE_TOOLBAR_ACTIONS = [
'approvalMode',
'gitBranch',
'model',
'widthMode',
'voice',
'workspace',
] 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,
metadata?: ComposerSubmitMetadata,
) => boolean | void;
onInputTextChange?: (text: string) => void;
onAttachmentsChange?: (hasAttachments: 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;
gitBranch?: string;
/** Whether the session is in a worktree (styles the git chip purple). */
gitWorktree?: boolean;
/** Git working directory for worktree sessions; targets git operations. */
gitCwd?: string;
/** Git mode intent for the empty-state composer chip (branch/worktree selection). */
gitModeIntent?: SessionGitIntent;
/** Callback when the user changes the git mode intent via the composer chip popover. */
onGitModeIntentChange?: (intent: SessionGitIntent) => void;
/** Enriched working-tree summary (dirty / ahead-behind / stash / operation). */
gitStatus?: DaemonWorkspaceGitStatus;
/** Opens the working-tree Changes dialog; makes the git chip clickable. */
onOpenGitDiff?: () => void;
/** Opens the commit dialog. */
onOpenCommit?: () => void;
/** Workspace name shown in the pane composer's `workspace` toolbar chip. */
workspaceName?: string;
/** Full workspace cwd, used as the chip's tooltip. */
workspaceTitle?: string;
/**
* Stable per-workspace accent color for the chip, so it stays distinguishable
* from other panes' chips even when it collapses to an icon on a narrow split.
*/
workspaceColor?: DaemonSessionGroupPresetColor;
chatWidthMode?: '1000' | 'wide';
showChatWidthToggle?: boolean;
chatWidthToggleMin?: number;
visibleToolbarActions?: readonly ComposerToolbarAction[];
availableModels?: Array<{ id: string; label?: string }>;
onSelectMode?: (mode: string) => void;
onSelectModel?: (model: string) => void;
workspaces?: Array<{
id: string;
cwd: string;
label: string;
primary: boolean;
trusted: boolean;
}>;
selectedWorkspaceCwd?: string;
workspaceSelectionDisabled?: boolean;
onSelectWorkspace?: (workspaceCwd: string | undefined) => void;
scratchWorkspaceSupported?: boolean;
existingFolderWorkspaceSupported?: boolean;
workspaceMutationBusy?: boolean;
onCreateScratchWorkspace?: () => void;
onOpenExistingWorkspace?: () => void;
atWorkspaceCwd?: string;
onChatWidthModeChange?: (mode: '1000' | 'wide') => void;
onFocusFooter?: () => boolean;
dialogOpen?: boolean;
followupState?: UseDaemonFollowupSuggestionReturn['followupState'];
onAcceptFollowup?: UseDaemonFollowupSuggestionReturn['onAcceptFollowup'];
onDismissFollowup?: UseDaemonFollowupSuggestionReturn['onDismissFollowup'];
sessionId?: string;
sessionName?: string;
composerInput?: WebShellComposerInput;
composerInputVersion?: number;
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
atProviders?: readonly WebShellAtProvider[];
composerTagIcons?: WebShellComposerTagIconMap;
voiceTarget?: VoiceWorkspaceTarget;
voiceStatusRevision?: VoiceStatusRevision;
}
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)
);
}
function TopComposerTag({
tag,
content,
tooltip,
onActivate,
onRemove,
}: {
tag: WebShellComposerTag;
content: ReactNode;
tooltip: ReactNode | null | undefined;
onActivate?: (anchorRect: DOMRectReadOnly) => void;
onRemove?: () => void;
}) {
const anchorRef = useRef<HTMLSpanElement>(null);
const portalRoot = useWebShellPortalRoot();
const hasTooltip = tooltip !== undefined && tooltip !== null;
const tagContent = (
<span
className={styles.tagContent}
data-web-shell-composer-tag-trigger
role={onActivate ? 'button' : undefined}
tabIndex={onActivate || hasTooltip ? 0 : undefined}
onClick={(event) => {
if (!onActivate) return;
event.stopPropagation();
onActivate(
anchorRef.current?.getBoundingClientRect() ??
event.currentTarget.getBoundingClientRect(),
);
}}
onKeyDown={(event) => {
if (!onActivate) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onActivate(
anchorRef.current?.getBoundingClientRect() ??
event.currentTarget.getBoundingClientRect(),
);
}}
>
{content}
</span>
);
const tagElement = (
<span ref={anchorRef} className={styles.tag} data-web-shell-composer-tag>
{hasTooltip ? (
<TooltipPrimitive.Trigger asChild>
{tagContent}
</TooltipPrimitive.Trigger>
) : (
tagContent
)}
{onRemove && (
<button
type="button"
className={styles.tagRemove}
aria-label={`Remove ${getComposerTagDisplay(tag)}`}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation();
return;
}
if (event.key !== 'Backspace' && event.key !== 'Delete') return;
event.preventDefault();
event.stopPropagation();
onRemove();
}}
>
×
</button>
)}
</span>
);
if (!hasTooltip) return tagElement;
return (
<TooltipPrimitive.Root disableHoverableContent={false}>
{tagElement}
<TooltipPrimitive.Portal container={portalRoot ?? undefined}>
<TooltipPrimitive.Content
className={styles.tagTooltip}
data-web-shell-composer-tag-tooltip
sideOffset={6}
collisionPadding={8}
avoidCollisions
>
{tooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
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 TypewriterPlaceholder({ text }: { text: string }) {
const totalRuns = 2;
const replayDelay = 3000;
const reducedMotion = usePrefersReducedMotion();
const [visibleText, setVisibleText] = useState(reducedMotion ? text : '');
const [finished, setFinished] = useState(false);
useEffect(() => {
if (reducedMotion) {
setVisibleText(text);
setFinished(false);
return undefined;
}
let run = 0;
let timer = 0;
const startRun = () => {
let index = 0;
setVisibleText('');
setFinished(false);
const typeNextCharacter = () => {
index += 1;
setVisibleText(text.slice(0, index));
if (index === text.length) {
run += 1;
setFinished(true);
if (run < totalRuns) {
timer = window.setTimeout(startRun, replayDelay);
}
return;
}
timer = window.setTimeout(typeNextCharacter, 45);
};
timer = window.setTimeout(typeNextCharacter, 45);
};
startRun();
return () => window.clearTimeout(timer);
}, [reducedMotion, text]);
return (
<span
className={styles.typewriterPlaceholder}
data-web-shell-composer-typewriter
aria-hidden="true"
>
{visibleText}
{!reducedMotion && (
<span
className={`${styles.typewriterCaret} ${
finished ? styles.typewriterCaretFinished : ''
}`}
>
_
</span>
)}
</span>
);
}
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 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 extends ToolbarDropdownItem {
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 ToolbarPopover({
open,
items,
activeId,
onOpenChange,
onSelect,
trigger,
tooltip,
showCheck = false,
searchable = false,
searchLabel,
noResultsLabel,
}: {
open: boolean;
items: DropdownItem[];
activeId: string;
onOpenChange: (open: boolean) => void;
onSelect: (id: string) => void;
trigger: ReactNode;
tooltip?: ReactNode;
showCheck?: boolean;
searchable?: boolean;
searchLabel?: string;
noResultsLabel?: (query: string) => string;
}) {
const [searchQuery, setSearchQuery] = useState('');
const [collisionBoundary, setCollisionBoundary] =
useState<HTMLElement | null>(null);
const selectionRef = useRef(false);
const handoffRef = useRef(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const hasRichItems = items.some((item) => item.description || item.icon);
const visibleItems = searchable
? filterToolbarDropdownItems(items, searchQuery)
: items;
useEffect(() => {
if (!open) {
setSearchQuery('');
}
}, [open]);
const hasCheckItems = hasRichItems || showCheck;
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
selectionRef.current = false;
handoffRef.current = false;
setCollisionBoundary(
triggerRef.current?.closest<HTMLElement>('[data-web-shell-root]') ??
null,
);
}
onOpenChange(nextOpen);
}}
>
{tooltip ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger ref={triggerRef} asChild>
{trigger}
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top">{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<PopoverTrigger ref={triggerRef} asChild>
{trigger}
</PopoverTrigger>
)}
<PopoverContent
side="top"
align="start"
collisionPadding={8}
collisionBoundary={collisionBoundary ?? undefined}
data-web-shell-toolbar-popover
onClick={(event) => event.stopPropagation()}
onPointerDownOutside={(event) => {
const target = event.target;
if (
target instanceof Element &&
target.closest('[data-web-shell-toolbar-popover-trigger]')
) {
handoffRef.current = true;
}
}}
onCloseAutoFocus={(event) => {
if (handoffRef.current) {
event.preventDefault();
handoffRef.current = false;
return;
}
if (
document.activeElement instanceof HTMLElement &&
document.activeElement.closest('[data-web-shell-toolbar-popover]')
) {
event.preventDefault();
return;
}
if (!selectionRef.current) return;
event.preventDefault();
selectionRef.current = false;
}}
>
{searchable && (
<Input
type="search"
value={searchQuery}
aria-label={searchLabel}
placeholder={searchLabel}
autoComplete="off"
onChange={(event) => setSearchQuery(event.target.value)}
/>
)}
<div
className={`${styles.dropdownList} ${
hasRichItems
? styles.dropdownRich
: showCheck
? styles.dropdownCheck
: ''
} ${searchable ? styles.dropdownListConstrained : ''}`}
>
{visibleItems.map((item) => (
<button
key={item.id}
type="button"
className={`${styles.dropdownItem} ${
item.id === activeId ? styles.dropdownItemActive : ''
}`}
onClick={() => {
selectionRef.current = true;
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>
))}
{visibleItems.length === 0 && noResultsLabel && (
<div className={styles.dropdownEmpty} role="status">
{noResultsLabel(searchQuery)}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
function SlashCommandPanel({
menu,
anchorRef,
panelRef,
detailRef,
onClose,
onSelect,
onAccept,
}: {
menu: SlashMenuState;
anchorRef: RefObject<HTMLElement | null>;
panelRef: RefObject<HTMLDivElement | null>;
detailRef: RefObject<HTMLDivElement | null>;
onClose: () => void;
onSelect: (index: number) => boolean;
onAccept: (index?: number) => boolean;
}) {
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const hoverAnchorRef = useRef<HTMLButtonElement>(null);
const [collisionBoundary, setCollisionBoundary] =
useState<HTMLElement | null>(null);
const [hoverDetail, setHoverDetail] = useState<{
label: string;
detail: string;
side: 'top' | 'right' | 'bottom' | 'left';
} | null>(null);
useEffect(() => {
itemRefs.current[menu.selectedIndex]?.scrollIntoView({
block: 'nearest',
});
}, [menu.items, menu.selectedIndex]);
useEffect(() => {
setHoverDetail(null);
}, [menu.items]);
useLayoutEffect(() => {
setCollisionBoundary(
anchorRef.current?.closest<HTMLElement>('[data-web-shell-root]') ?? null,
);
}, [anchorRef]);
useEffect(() => {
const preserveImeEscape = (event: KeyboardEvent) => {
if (
event.key !== 'Escape' ||
(!event.isComposing && event.keyCode !== 229)
) {
return;
}
Object.defineProperty(event, 'key', {
configurable: true,
value: 'Process',
});
window.addEventListener(
'keydown',
(currentEvent) => {
if (currentEvent === event) Reflect.deleteProperty(event, 'key');
},
{ once: true },
);
};
window.addEventListener('keydown', preserveImeEscape, { capture: true });
return () => {
window.removeEventListener('keydown', preserveImeEscape, {
capture: true,
});
};
}, []);
const rowPlans = planSlashSectionRows(menu.items, menu.kind);
return (
<>
<Popover
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<PopoverAnchor
virtualRef={
anchorRef as RefObject<{ getBoundingClientRect(): DOMRect }>
}
/>
<PopoverContent
ref={panelRef}
side="top"
align="start"
alignOffset={16}
sideOffset={8}
avoidCollisions={false}
collisionPadding={12}
collisionBoundary={collisionBoundary ?? undefined}
className="duration-0 data-open:animate-none data-closed:animate-none"
role="listbox"
data-web-shell-slash-menu
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
const target = event.target;
if (
target instanceof Node &&
(anchorRef.current?.contains(target) ||
detailRef.current?.contains(target))
) {
event.preventDefault();
}
}}
onMouseDown={(event) => event.preventDefault()}
onMouseLeave={(event) => {
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
detailRef.current?.contains(nextTarget)
) {
return;
}
setHoverDetail(null);
}}
>
<div className={styles.slashPanel}>
<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}
data-has-description={item.detail ? '' : undefined}
className={`${styles.slashItem} ${
index === menu.selectedIndex
? styles.slashItemActive
: ''
}`}
onMouseEnter={(event) => {
onSelect(index);
if (!item.detail) {
setHoverDetail(null);
return;
}
hoverAnchorRef.current = event.currentTarget;
const rowRect =
event.currentTarget.getBoundingClientRect();
const boundaryRect =
collisionBoundary?.getBoundingClientRect();
const left = boundaryRect?.left ?? 0;
const right =
boundaryRect?.right ?? window.innerWidth;
const top = boundaryRect?.top ?? 0;
const bottom =
boundaryRect?.bottom ?? window.innerHeight;
const detailWidth = Math.min(320, right - left - 24);
const side =
right - rowRect.right >= detailWidth + 8
? 'right'
: rowRect.left - left >= detailWidth + 8
? 'left'
: rowRect.top - top >= bottom - rowRect.bottom
? 'top'
: 'bottom';
setHoverDetail({
label: item.label,
detail: item.detail,
side,
});
}}
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>
</PopoverContent>
</Popover>
<Popover
open={Boolean(hoverDetail)}
onOpenChange={(open) => {
if (!open) setHoverDetail(null);
}}
>
<PopoverAnchor
virtualRef={
hoverAnchorRef as RefObject<{ getBoundingClientRect(): DOMRect }>
}
/>
{hoverDetail && (
<PopoverContent
ref={detailRef}
side={hoverDetail.side}
align="start"
sideOffset={8}
collisionPadding={12}
collisionBoundary={collisionBoundary ?? undefined}
className="duration-0 data-open:animate-none data-closed:animate-none"
data-web-shell-slash-detail
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onMouseLeave={(event) => {
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
panelRef.current?.contains(nextTarget)
) {
return;
}
setHoverDetail(null);
}}
>
<div className={styles.slashDetail}>
<div className={styles.slashDetailCommand}>
{hoverDetail.label}
</div>
<div className={styles.slashDetailText}>{hoverDetail.detail}</div>
</div>
</PopoverContent>
)}
</Popover>
</>
);
}
function QuickActionsPanel({
actions,
onRun,
onPressKey,
showKeyHints = true,
}: {
actions: readonly QuickActionItem[];
onRun: (action: QuickActionItem) => void;
onPressKey: (item: QuickKeyItem) => void;
// The keyboard shortcut grid is pointless without a hardware keyboard, so
// the mobile textarea backend hides it.
showKeyHints?: boolean;
}) {
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>
{showKeyHints && (
<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,
onInputTextChange,
onAttachmentsChange,
onCycleMode,
onToggleShortcuts,
onCancel,
isRunning = false,
isPreparing = false,
cancelArmed = false,
disabled = false,
placeholderText = 'Type a message...',
commands,
skills = [],
slashCommandCategoryOrder,
queuedMessages = [],
onPopQueuedMessages,
currentMode = 'default',
currentModel = '',
gitBranch,
gitWorktree,
gitCwd,
gitModeIntent,
onGitModeIntentChange,
gitStatus,
onOpenGitDiff,
onOpenCommit,
workspaceName,
workspaceTitle,
workspaceColor,
chatWidthMode = '1000',
showChatWidthToggle = true,
chatWidthToggleMin,
visibleToolbarActions,
availableModels = [],
onSelectMode,
onSelectModel,
workspaces,
selectedWorkspaceCwd,
workspaceSelectionDisabled = false,
onSelectWorkspace,
scratchWorkspaceSupported = false,
existingFolderWorkspaceSupported = false,
workspaceMutationBusy = false,
onCreateScratchWorkspace,
onOpenExistingWorkspace,
atWorkspaceCwd,
onChatWidthModeChange,
onFocusFooter,
dialogOpen = false,
followupState,
onAcceptFollowup,
onDismissFollowup,
sessionId,
sessionName,
composerInput,
composerInputVersion,
builtinAtProviders,
atProviders,
composerTagIcons,
voiceTarget,
voiceStatusRevision,
} = props;
const {
renderComposerToolbarStart: ToolbarStart,
renderComposerToolbarEnd: ToolbarEnd,
renderComposerToolbarRight: ToolbarRight,
renderComposerTag,
renderComposerTagTooltip,
onComposerTagClick,
parseUserMessageContent,
} = useWebShellCustomization();
const core = useComposerCore({
onSubmit,
onInputTextChange,
onCycleMode,
onToggleShortcuts,
disabled,
placeholderText,
commands,
skills,
slashCommandCategoryOrder,
queuedMessages,
onPopQueuedMessages,
currentMode,
onFocusFooter,
dialogOpen,
followupState,
onAcceptFollowup,
onDismissFollowup,
sessionId,
sessionName,
composerInput,
composerInputVersion,
builtinAtProviders,
atProviders,
atWorkspaceCwd,
composerTagIcons,
parseUserMessageContent,
renderComposerTag,
renderComposerTagTooltip,
onComposerTagClick,
editorTheme: CHAT_EDITOR_THEME,
});
const { t } = useI18n();
useImperativeHandle(ref, () => core.handle, [core.handle]);
useEffect(() => {
onAttachmentsChange?.(core.hasAttachments);
}, [core.hasAttachments, onAttachmentsChange]);
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
const [showQuickActions, setShowQuickActions] = useState(isTouchLikeDevice);
const [typewriterSuppressed, setTypewriterSuppressed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const slashPanelRef = useRef<HTMLDivElement>(null);
const slashDetailRef = useRef<HTMLDivElement>(null);
const atPanelRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const toolbarLeadingRef = useRef<HTMLDivElement>(null);
const toolbarRightRef = useRef<HTMLDivElement>(null);
const toolbarStartRef = useRef<HTMLDivElement>(null);
const toolbarEndRef = useRef<HTMLDivElement>(null);
const toolbarRightCustomRef = useRef<HTMLDivElement>(null);
const toolbarMeasurementsRef = useRef<HTMLDivElement>(null);
const [widthToggleFits, setWidthToggleFits] = useState(false);
const [voiceActive, setVoiceActive] = useState(false);
const [toolbarLabelVisibility, setToolbarLabelVisibility] = useState({
workspaceSelect: false,
workspace: false,
gitBranch: false,
mode: false,
model: false,
});
const [lastConfirmedModelLabel, setLastConfirmedModelLabel] = useState('');
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 showTypewriterPlaceholder =
!disabled &&
Boolean(placeholderText) &&
!core.hasInput() &&
!typewriterSuppressed &&
!core.shellMode &&
!followupState?.isVisible;
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) &&
!slashDetailRef.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 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 showModeAction = showToolbarAction('approvalMode');
const showModelAction = showToolbarAction('model');
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),
searchText: `${m.label ?? ''}\n${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']) => {
if (core.mobileComposer) {
// No CodeMirror to dispatch into. History search is the one key
// action with a non-keyboard equivalent; the rest are hidden on
// the textarea backend.
if (event.ctrlKey && event.key === 'r') {
core.searchState.openHistorySearch();
}
return;
}
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,
restoreSearchMatch,
handleSearchKeyDown,
handleSearchInput,
handleSearchCompositionEnd,
} = core.searchState;
const renderComposerTagContent = (tag: WebShellComposerTag) => {
const custom = renderComposerTag?.({
tag,
placement: 'composer',
readonly: false,
});
if (custom !== undefined && custom !== null) {
return custom;
}
const rawTagLabel = getComposerTagLabel(tag);
const tagValue = getComposerTagValue(tag);
const tagLabel = tag.kind ? '' : rawTagLabel;
const iconUrl =
tag.icon ?? getComposerTagIconUrl(tag.kind, composerTagIcons);
const safeIconUrl =
iconUrl &&
(isBuiltinComposerTagIconUrl(iconUrl) || isSafeImageSrc(iconUrl))
? iconUrl
: undefined;
if (!tagLabel && !tagValue) {
return <span className={styles.tagLabel}>{tag.id}</span>;
}
return (
<>
{safeIconUrl && (
<span
className={styles.tagIcon}
style={cssUrlVar('--composer-tag-icon-url', safeIconUrl)}
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);
const currentModelLabel = currentModel
? (availableModels.find((model) => model.id === currentModel)?.label ??
(currentModel.startsWith('qwen-route:')
? ''
: getModelDisplayName(currentModel)))
: '';
const { modelLabel, modelLabelReady } = resolveToolbarModelLabel({
currentModelLabel,
lastConfirmedModelLabel,
});
const selectedWorkspace = workspaces?.find((entry) =>
selectedWorkspaceCwd ? entry.cwd === selectedWorkspaceCwd : entry.primary,
);
const selectedWorkspaceLabel = selectedWorkspace?.label ?? '';
const workspaceSelectVisible = Boolean(
workspaces &&
onSelectWorkspace &&
(workspaces.length > 1 ||
scratchWorkspaceSupported ||
existingFolderWorkspaceSupported),
);
const workspaceIndicatorVisible = Boolean(
workspaceName && showToolbarAction('workspace'),
);
const gitBranchVisible = Boolean(
gitBranch && showToolbarAction('gitBranch'),
);
useLayoutEffect(() => {
if (currentModelLabel && currentModelLabel !== lastConfirmedModelLabel) {
setLastConfirmedModelLabel(currentModelLabel);
}
}, [currentModelLabel, lastConfirmedModelLabel]);
const showWorkspaceSelectLabel = toolbarLabelVisibility.workspaceSelect;
const showWorkspaceLabel = toolbarLabelVisibility.workspace;
const showGitBranchLabel = toolbarLabelVisibility.gitBranch;
const showModeLabel = toolbarLabelVisibility.mode;
const showModelLabel = toolbarLabelVisibility.model;
const showCancelButton = isRunning && !core.hasContent;
const mobileVoiceActive = showQuickActions && voiceActive;
useEffect(() => {
if (mobileVoiceActive) setQuickActionsOpen(false);
}, [mobileVoiceActive]);
useLayoutEffect(() => {
const toolbar = toolbarRef.current;
const toolbarLeading = toolbarLeadingRef.current;
const toolbarRight = toolbarRightRef.current;
const measurements = toolbarMeasurementsRef.current;
if (!toolbar || !toolbarLeading || !toolbarRight || !measurements) {
return undefined;
}
const update = () => {
const expansionWidth = (id: string) => {
const collapsed = measurements.querySelector<HTMLElement>(
`[data-toolbar-measure="${id}:collapsed"]`,
);
const expanded = measurements.querySelector<HTMLElement>(
`[data-toolbar-measure="${id}:expanded"]`,
);
return Math.max(
0,
Math.ceil(expanded?.getBoundingClientRect().width ?? 0) -
Math.ceil(collapsed?.getBoundingClientRect().width ?? 0),
);
};
const items = [
...(workspaceSelectVisible
? [
{
id: 'workspaceSelect',
expansionWidth: expansionWidth('workspaceSelect'),
},
]
: []),
...(workspaceIndicatorVisible
? [
{
id: 'workspace',
expansionWidth: expansionWidth('workspace'),
},
]
: []),
...(gitBranchVisible
? [
{
id: 'gitBranch',
expansionWidth: expansionWidth('gitBranch'),
},
]
: []),
...(showModeAction
? [
{
id: 'mode',
expansionWidth: expansionWidth('mode'),
},
]
: []),
...(showModelAction
? [
{
id: 'model',
expansionWidth: expansionWidth('model'),
ready: modelLabelReady,
},
]
: []),
];
const currentExpansionWidth = items.reduce(
(total, item) =>
total +
(toolbarLabelVisibility[
item.id as keyof typeof toolbarLabelVisibility
]
? item.expansionWidth
: 0),
0,
);
const currentLeadingWidth = toolbarLeading.scrollWidth;
const gap = Math.ceil(
Number.parseFloat(getComputedStyle(toolbar).columnGap) || 0,
);
const availableWidth = getToolbarExpansionBudget({
toolbarWidth: Math.floor(toolbar.getBoundingClientRect().width),
leadingWidth: currentLeadingWidth,
rightWidth: Math.ceil(toolbarRight.getBoundingClientRect().width),
currentExpansionWidth,
gap,
});
const itemVisibility = getToolbarItemVisibilityWithHysteresis({
availableWidth,
items,
currentVisibility: toolbarLabelVisibility,
// Aggregate scrollWidth can differ from the sum of individually
// rounded replicas by one pixel per item. Apply that slack only when
// expanding so a collapsed/expanded pair cannot form a two-cycle.
expansionMargin: items.length,
});
const next = {
workspaceSelect: itemVisibility.workspaceSelect ?? false,
workspace: itemVisibility.workspace ?? false,
gitBranch: itemVisibility.gitBranch ?? false,
mode: itemVisibility.mode ?? false,
model: itemVisibility.model ?? false,
};
setToolbarLabelVisibility((current) => {
const unchanged = Object.keys(next).every(
(key) =>
current[key as keyof typeof current] ===
next[key as keyof typeof next],
);
return unchanged ? current : next;
});
};
update();
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(toolbar);
resizeObserver.observe(toolbarLeading);
resizeObserver.observe(toolbarRight);
for (const child of measurements.children) {
resizeObserver.observe(child);
}
const customToolbarRoots = [
toolbarStartRef.current,
toolbarEndRef.current,
toolbarRightCustomRef.current,
].filter((element): element is HTMLDivElement => element !== null);
const observeCustomToolbarContent = () => {
for (const root of customToolbarRoots) {
resizeObserver.observe(root);
for (const child of root.children) {
resizeObserver.observe(child);
}
}
};
observeCustomToolbarContent();
const mutationObserver = new MutationObserver(() => {
observeCustomToolbarContent();
update();
});
for (const root of customToolbarRoots) {
mutationObserver.observe(root, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
}
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [
ToolbarEnd,
ToolbarRight,
ToolbarStart,
disabled,
gitBranch,
gitBranchVisible,
isRunning,
modelLabel,
modelLabelReady,
modeLabel,
sessionName,
showModelAction,
showModeAction,
toolbarLabelVisibility,
workspaceIndicatorVisible,
workspaceName,
workspaceSelectVisible,
selectedWorkspaceLabel,
]);
return (
<div
className={`${styles.editorShell} ${
modeDropdownOpen || modelDropdownOpen
? styles.editorShellDropdownOpen
: ''
}`}
data-composer
data-web-shell-composer
>
<div
ref={containerRef}
className={styles.container}
data-web-shell-composer-surface
data-typewriter-visible={showTypewriterPlaceholder || undefined}
onClick={() => {
setModeDropdownOpen(false);
setModelDropdownOpen(false);
setQuickActionsOpen(false);
core.focus();
}}
>
<SpecularComposerEffect targetRef={containerRef} />
{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();
if (restoreSearchMatch) {
restoreSearchMatch(match);
} else {
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 || core.pastedImages.length > 0) && (
<div
className={styles.attachments}
data-web-shell-composer-attachments
>
{core.composerTags.length > 0 && (
<TooltipPrimitive.Provider
delayDuration={0}
disableHoverableContent={false}
>
<div className={styles.tags}>
{core.composerTags.map((tag) => {
const tagInfo = {
tag,
placement: 'composer' as const,
readonly: false,
};
let tooltip: ReactNode | null | undefined;
try {
tooltip = renderComposerTagTooltip?.(tagInfo);
} catch (error) {
console.warn(
'[WebShell] composer tag tooltip render failed',
error,
);
}
return (
<TopComposerTag
key={tag.id}
tag={tag}
content={renderComposerTagContent(tag)}
tooltip={tooltip}
onActivate={
onComposerTagClick
? (anchorRect) =>
onComposerTagClick({
...tagInfo,
anchorRect,
})
: undefined
}
onRemove={
tag.removable !== false
? () => {
core.removeTopTag(tag.id);
core.viewRef.current?.focus();
}
: undefined
}
/>
);
})}
</div>
</TooltipPrimitive.Provider>
)}
{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>
)}
</div>
)}
{core.slashMenu && (
<SlashCommandPanel
menu={core.slashMenu}
anchorRef={containerRef}
panelRef={slashPanelRef}
detailRef={slashDetailRef}
onClose={core.closeSlashMenu}
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}
onSelectTab={core.selectAtTab}
/>
)}
<div
className={styles.editorArea}
onPointerDownCapture={() => setTypewriterSuppressed(true)}
onKeyDownCapture={() => setTypewriterSuppressed(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setTypewriterSuppressed(false);
}
}}
>
{showTypewriterPlaceholder && (
<TypewriterPlaceholder text={placeholderText} />
)}
{core.shellMode && (
<span className={styles.shellPrefix} aria-hidden="true">
!
</span>
)}
{core.mobileComposer ? (
// Touch devices get a plain textarea instead of CodeMirror:
// mobile virtual keyboards and IMEs interact poorly with the
// contenteditable editor (#5958). Enter inserts a newline
// natively; submission goes through the Send button.
<textarea
ref={core.mobileComposer.textareaRef}
className={styles.mobileTextarea}
value={core.mobileComposer.value}
onChange={core.mobileComposer.onChange}
onBlur={core.mobileComposer.onBlur}
onPaste={core.mobileComposer.onPaste}
placeholder={core.mobileComposer.placeholder}
disabled={core.disabled}
rows={1}
enterKeyHint="enter"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
data-web-shell-composer-editor
/>
) : (
<div ref={core.containerRef} data-web-shell-composer-editor />
)}
</div>
<div
ref={toolbarRef}
className={styles.toolbar}
data-mobile-voice-active={mobileVoiceActive || undefined}
>
<div
ref={toolbarLeadingRef}
className={styles.toolbarLeading}
data-web-shell-toolbar-leading
>
{ToolbarStart && (
<div ref={toolbarStartRef} className={styles.toolbarStart}>
<ToolbarStart
disabled={disabled}
isRunning={isRunning}
currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionName}
/>
</div>
)}
<div className={styles.toolbarLeft}>
{workspaceSelectVisible &&
workspaces &&
onSelectWorkspace && (
<WorkspaceSelector
workspaces={workspaces}
selectedWorkspaceCwd={selectedWorkspaceCwd}
disabled={workspaceSelectionDisabled}
busy={workspaceMutationBusy}
scratchSupported={scratchWorkspaceSupported}
existingFolderSupported={
existingFolderWorkspaceSupported
}
className={`${styles.toolBtn} ${styles.workspaceSelectTrigger} ${
showWorkspaceSelectLabel
? ''
: styles.workspaceSelectTriggerCompact
}`}
onSelectWorkspace={onSelectWorkspace}
onCreateScratch={onCreateScratchWorkspace ?? (() => {})}
onOpenExistingFolder={
onOpenExistingWorkspace ?? (() => {})
}
/>
)}
{workspaceIndicatorVisible && workspaceName && (
<WorkspaceIndicator
name={workspaceName}
title={workspaceTitle ?? workspaceName}
color={workspaceColor}
compact={!showWorkspaceLabel}
ariaLabel={t('workspace.paneLabel', {
name: workspaceName,
})}
/>
)}
{gitBranchVisible &&
gitBranch &&
(gitModeIntent && onGitModeIntentChange ? (
<GitModePopover
branch={gitBranch}
compact={!showGitBranchLabel}
intent={gitModeIntent}
onIntentChange={onGitModeIntentChange}
/>
) : (
<BranchPickerPopover
open={branchPickerOpen}
onOpenChange={setBranchPickerOpen}
workspaceCwd={selectedWorkspace?.cwd ?? ''}
gitCwd={gitCwd}
onOpenDiff={onOpenGitDiff}
onOpenCommit={onOpenCommit}
>
<button
type="button"
className={styles.gitBranchChipButton}
aria-label={gitBranchAriaLabel(
gitBranch,
gitStatus,
t,
)}
>
<GitBranchIndicator
branch={gitBranch}
status={gitStatus}
compact={!showGitBranchLabel}
worktree={gitWorktree}
/>
</button>
</BranchPickerPopover>
))}
{showModeAction && (
<div
className={`${styles.dropdownWrapper} ${
showModeLabel ? '' : styles.dropdownWrapperCompact
}`}
>
<ToolbarPopover
open={modeDropdownOpen}
items={modeItems}
activeId={currentMode}
onOpenChange={(open) => {
setModeDropdownOpen(open);
if (open) setModelDropdownOpen(false);
}}
onSelect={handleModeSelect}
tooltip={modeLabel}
trigger={
<button
className={`${styles.toolBtn} ${styles.modeToolBtn} ${
showModeLabel ? '' : styles.toolBtnCompact
}`}
data-web-shell-mode-button
data-web-shell-toolbar-popover-trigger
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
core.closeAtMenu();
setQuickActionsOpen(false);
}}
aria-label={t('status.mode')}
>
<span className={styles.toolBtnModeIcon}>
<ModeIcon mode={currentMode} />
</span>
{showModeLabel && (
<span className={styles.toolBtnText}>
{modeLabel}
</span>
)}
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</button>
}
/>
</div>
)}
{showModelAction && (
<div
className={`${styles.dropdownWrapper} ${
showModelLabel ? '' : styles.dropdownWrapperCompact
}`}
>
<ToolbarPopover
open={modelDropdownOpen}
items={modelItems}
activeId={currentModel}
onOpenChange={(open) => {
setModelDropdownOpen(open);
if (open) setModeDropdownOpen(false);
}}
onSelect={handleModelSelect}
tooltip={modelLabel}
showCheck
searchable
searchLabel={t('common.search')}
noResultsLabel={(query) =>
t('model.noMatch', { query })
}
trigger={
<button
className={`${styles.toolBtn} ${styles.modelToolBtn} ${
showModelLabel ? '' : styles.toolBtnCompact
}`}
data-web-shell-model-button
data-web-shell-toolbar-popover-trigger
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
core.closeAtMenu();
setQuickActionsOpen(false);
}}
aria-label={t('model.select')}
>
<span className={styles.toolBtnModelIcon}>
<ModelIcon />
</span>
{showModelLabel && (
<span className={styles.toolBtnText}>
{modelLabel}
</span>
)}
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</button>
}
/>
</div>
)}
{ToolbarEnd && (
<div ref={toolbarEndRef} className={styles.toolbarEnd}>
<ToolbarEnd
disabled={disabled}
isRunning={isRunning}
currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionName}
/>
</div>
)}
</div>
</div>
<div ref={toolbarRightRef} className={styles.toolbarRight}>
{showQuickActions && quickActions.length > 0 && (
<button
className={`${styles.toolBtn} ${styles.quickActionsBtn}`}
data-hide-during-mobile-voice
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
ref={toolbarRightCustomRef}
className={styles.toolbarRightCustom}
data-hide-during-mobile-voice
>
<ToolbarRight
disabled={disabled}
isRunning={isRunning}
currentMode={currentMode}
currentModel={currentModel}
sessionName={sessionName}
/>
</div>
)}
{showChatWidthToggle &&
widthToggleFits &&
showToolbarAction('widthMode') && (
<button
className={`${styles.toolBtn} ${styles.widthModeBtn}`}
data-hide-during-mobile-voice
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}
onActiveChange={setVoiceActive}
target={voiceTarget}
statusRevision={voiceStatusRevision}
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
}
data-web-shell-composer-submit
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
ref={toolbarMeasurementsRef}
className={styles.toolbarMeasurements}
aria-hidden="true"
>
{workspaceSelectVisible && selectedWorkspace && (
<>
<span
data-toolbar-measure="workspaceSelect:collapsed"
className={`${styles.toolBtn} ${styles.workspaceSelectTrigger} ${styles.workspaceSelectTriggerCompact}`}
>
<FolderClosedIcon size={16} strokeWidth={1.2} />
<span className={styles.toolBtnText}>
{selectedWorkspaceLabel}
</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</span>
<span
data-toolbar-measure="workspaceSelect:expanded"
className={`${styles.toolBtn} ${styles.workspaceSelectTrigger}`}
>
<FolderClosedIcon size={16} strokeWidth={1.2} />
<span className={styles.toolBtnText}>
{selectedWorkspaceLabel}
</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</span>
</>
)}
{workspaceIndicatorVisible && workspaceName && (
<>
<span
data-toolbar-measure="workspace:collapsed"
className={`${styles.workspaceChip} ${styles.workspaceChipCompact}`}
>
<span className={styles.workspaceChipIcon} />
<span className={styles.workspaceChipText}>
{workspaceName}
</span>
</span>
<span
data-toolbar-measure="workspace:expanded"
className={styles.workspaceChip}
>
<span className={styles.workspaceChipIcon} />
<span className={styles.workspaceChipText}>
{workspaceName}
</span>
</span>
</>
)}
{gitBranchVisible && gitBranch && (
<>
<span
data-toolbar-measure="gitBranch:collapsed"
className={`${styles.gitBranchChip} ${styles.gitBranchChipCompact}`}
>
<GitBranchChipContent
branch={gitBranch}
status={gitStatus}
compact
worktree={gitWorktree}
/>
</span>
<span
data-toolbar-measure="gitBranch:expanded"
className={styles.gitBranchChip}
>
<GitBranchChipContent
branch={gitBranch}
status={gitStatus}
compact={false}
worktree={gitWorktree}
/>
</span>
</>
)}
<span
data-toolbar-measure="mode:collapsed"
className={`${styles.toolBtn} ${styles.modeToolBtn} ${styles.toolBtnCompact}`}
>
<span className={styles.toolBtnModeIcon}>
<ModeIcon mode={currentMode} />
</span>
<span className={styles.toolBtnText}>{modeLabel}</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</span>
<span
data-toolbar-measure="mode:expanded"
className={`${styles.toolBtn} ${styles.modeToolBtn}`}
>
<span className={styles.toolBtnModeIcon}>
<ModeIcon mode={currentMode} />
</span>
<span className={styles.toolBtnText}>{modeLabel}</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</span>
<span
data-toolbar-measure="model:collapsed"
className={`${styles.toolBtn} ${styles.modelToolBtn} ${styles.toolBtnCompact}`}
>
<span className={styles.toolBtnModelIcon}>
<ModelIcon />
</span>
<span className={styles.toolBtnText}>{modelLabel}</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</span>
<span
data-toolbar-measure="model:expanded"
className={`${styles.toolBtn} ${styles.modelToolBtn}`}
>
<span className={styles.toolBtnModelIcon}>
<ModelIcon />
</span>
<span className={styles.toolBtnText}>{modelLabel}</span>
<span className={styles.toolBtnArrow}>
<ChevronDownIcon />
</span>
</span>
</div>
</div>
</div>
{showQuickActions && quickActionsOpen && quickActions.length > 0 && (
<QuickActionsPanel
actions={quickActions}
onRun={runQuickAction}
onPressKey={pressQuickKey}
showKeyHints={!core.mobileComposer}
/>
)}
</div>
);
}),
);