mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-02 21:04:32 +00:00
4071 lines
128 KiB
TypeScript
4071 lines
128 KiB
TypeScript
import {
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useRef,
|
||
useState,
|
||
useCallback,
|
||
useMemo,
|
||
type ReactNode,
|
||
} from 'react';
|
||
import { createRoot, type Root } from 'react-dom/client';
|
||
import {
|
||
Decoration,
|
||
EditorView,
|
||
ViewPlugin,
|
||
WidgetType,
|
||
keymap,
|
||
placeholder,
|
||
tooltips,
|
||
type DecorationSet,
|
||
} from '@codemirror/view';
|
||
import {
|
||
EditorState,
|
||
Compartment,
|
||
Prec,
|
||
StateEffect,
|
||
StateField,
|
||
} from '@codemirror/state';
|
||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||
import {
|
||
acceptCompletion,
|
||
autocompletion,
|
||
closeCompletion,
|
||
completionStatus,
|
||
moveCompletionSelection,
|
||
startCompletion,
|
||
type Completion,
|
||
} from '@codemirror/autocomplete';
|
||
import { minimalSetup } from 'codemirror';
|
||
import {
|
||
isCoarsePointerDevice,
|
||
useIsTouchComposer,
|
||
} from './useIsTouchComposer';
|
||
import type { CommandInfo } from '../adapters/types';
|
||
import type { PromptImage } from '../adapters/promptTypes';
|
||
import {
|
||
useOptionalWorkspace,
|
||
type UseDaemonFollowupSuggestionReturn,
|
||
} from '@qwen-code/webui/daemon-react-sdk';
|
||
import {
|
||
getImplicitTabCompletion,
|
||
getMissingSlashPrefixCompletion,
|
||
getSlashCommandCompletionResult,
|
||
type SkillInfo,
|
||
type SlashCommandCompletionResult,
|
||
} from '../completions/slashCompletion';
|
||
import {
|
||
DEFAULT_COMMAND_CATEGORY_ORDER,
|
||
type CommandDisplayCategoryOrder,
|
||
} from '../utils/commandDisplay';
|
||
import {
|
||
getPromptHistoryStorageKey,
|
||
pushInputHistoryEntry,
|
||
useInputHistory,
|
||
} from '../hooks/useInputHistory';
|
||
import {
|
||
useAtMentionMenu,
|
||
type AtMentionMenuState,
|
||
type AtMentionWorkspaceActions,
|
||
} from './useAtMentionMenu';
|
||
import { useI18n } from '../i18n';
|
||
import {
|
||
inputHighlight,
|
||
inputHighlightTheme,
|
||
} from '../extensions/inputHighlight';
|
||
import { isEditableTarget } from '../utils/dom';
|
||
import { cssUrlValue } from '../utils/cssUrlVar';
|
||
import {
|
||
createInputAnnotationsFromComposerTags,
|
||
getComposerTagIconUrl,
|
||
getComposerTagSerialized,
|
||
isBuiltinComposerTagIconUrl,
|
||
parseUserMessageContentSafely,
|
||
} from '../utils/composerTag';
|
||
import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon';
|
||
import { isSafeImageSrc } from '../components/messages/Markdown';
|
||
import type {
|
||
ComposerTagClickHandler,
|
||
ComposerTagRenderer,
|
||
UserMessageContentParser,
|
||
WebShellComposerApi,
|
||
WebShellComposerInput,
|
||
WebShellComposerTag,
|
||
WebShellComposerTagIconMap,
|
||
WebShellComposerTagOptions,
|
||
WebShellComposerTextOptions,
|
||
WebShellBuiltinAtProvidersConfig,
|
||
WebShellAtProvider,
|
||
} from '../customization';
|
||
import { useWebShellPortalRoot } from '../portalRoot';
|
||
|
||
// ---- Large paste handling (shared utilities) ----
|
||
|
||
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
|
||
const LARGE_PASTE_LINE_THRESHOLD = 10;
|
||
const TOOLTIP_STYLE_ID = 'web-shell-tooltip-styles';
|
||
const TOOLTIP_STYLES = `
|
||
[data-web-shell-tooltip-portal] {
|
||
pointer-events: none;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip {
|
||
z-index: var(--web-shell-tooltip-z-index, 1000);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete {
|
||
--web-shell-completion-label-width: 20ch;
|
||
--web-shell-completion-column-gap: 2ch;
|
||
--web-shell-completion-detail-start: calc(
|
||
var(--web-shell-completion-label-width) +
|
||
var(--web-shell-completion-column-gap)
|
||
);
|
||
min-width: 500px !important;
|
||
max-width: 700px !important;
|
||
max-height: 400px !important;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
|
||
background: var(--background, #0d0d0d) !important;
|
||
border: 1px solid var(--border, #2a2a2a) !important;
|
||
border-radius: 6px !important;
|
||
overflow: visible;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete > ul {
|
||
max-height: 380px !important;
|
||
overflow: auto;
|
||
border-radius: 6px;
|
||
font-family: var(--font-mono, monospace);
|
||
font-size: 13px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--border) transparent;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete > ul::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete > ul::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li {
|
||
display: flex !important;
|
||
align-items: center;
|
||
min-width: 0;
|
||
padding: 4px 8px !important;
|
||
color: var(--foreground, #e4e4e4) !important;
|
||
overflow: hidden;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete .cm-at-ref-completion-icon {
|
||
display: block;
|
||
width: 14px;
|
||
height: 14px;
|
||
flex: 0 0 auto;
|
||
margin-right: 10px;
|
||
background: currentColor;
|
||
mask: var(--composer-tag-icon-url) center / contain no-repeat;
|
||
-webkit-mask: var(--composer-tag-icon-url) center / contain no-repeat;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li:hover {
|
||
background: var(--secondary, #1e1e1e) !important;
|
||
color: var(--foreground, #e4e4e4) !important;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li[aria-selected] {
|
||
background: var(--secondary, #1e1e1e) !important;
|
||
color: var(--foreground, #e4e4e4) !important;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li:is(:hover, [aria-selected]) .cm-completionLabel {
|
||
color: var(--agent-blue-500, #4a9eff);
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete completion-section {
|
||
display: block !important;
|
||
height: auto;
|
||
margin: 6px 10px 4px;
|
||
padding: 2px 0 4px !important;
|
||
line-height: 1.2;
|
||
color: var(--muted-foreground, #a1a1aa) !important;
|
||
border-bottom: 1px solid var(--border) !important;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete completion-section:first-of-type {
|
||
margin-top: 6px;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete .cm-completionLabel {
|
||
font-family: var(--font-mono, monospace);
|
||
flex-shrink: 0;
|
||
width: var(--web-shell-completion-label-width);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li.cm-at-ref-completion-extension .cm-completionLabel,
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li.cm-at-ref-completion-mcp .cm-completionLabel,
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li.cm-at-ref-completion-file .cm-completionLabel {
|
||
width: calc(var(--web-shell-completion-label-width) - 20px);
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete .cm-completionDetail {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
font-style: normal;
|
||
color: var(--muted-foreground);
|
||
font-size: 13px;
|
||
margin-left: var(--web-shell-completion-column-gap);
|
||
opacity: 0.8;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li.cm-file-completion .cm-completionLabel {
|
||
flex: 1 1 auto;
|
||
width: auto;
|
||
min-width: 0;
|
||
max-width: none;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li.cm-command-info-completion {
|
||
display: grid !important;
|
||
grid-template-columns: var(--web-shell-completion-label-width) minmax(0, 1fr);
|
||
column-gap: var(--web-shell-completion-column-gap);
|
||
align-items: baseline !important;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li.cm-command-info-completion .cm-completionLabel {
|
||
min-width: 0;
|
||
max-width: none;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip-autocomplete ul li.cm-command-info-completion .cm-completionDetail {
|
||
margin-left: 0;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip.cm-completionInfo {
|
||
z-index: calc(var(--web-shell-tooltip-z-index, 1000) + 1);
|
||
width: min(320px, calc(100vw - 32px));
|
||
max-width: min(320px, calc(100vw - 32px)) !important;
|
||
max-height: min(280px, calc(100vh - 32px));
|
||
padding: 8px 10px;
|
||
overflow: auto;
|
||
border: 1px solid var(--border, #2a2a2a);
|
||
border-radius: 6px;
|
||
background: var(--muted, #161616);
|
||
color: var(--foreground, #e4e4e4);
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||
font-family: var(--font-sans, system-ui, sans-serif);
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
white-space: pre-line;
|
||
overflow-wrap: anywhere;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--border) transparent;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-completionInfo-hover {
|
||
pointer-events: auto;
|
||
z-index: calc(var(--web-shell-tooltip-z-index, 1000) + 1);
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip.cm-completionInfo::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip.cm-completionInfo::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-tooltip.cm-completionInfo::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-completionInfo.cm-completionInfo-right {
|
||
margin-left: var(--web-shell-completion-column-gap);
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-completionInfo.cm-completionInfo-right-narrow {
|
||
left: var(--web-shell-completion-detail-start);
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-completionInfo.cm-completionInfo-left {
|
||
margin-right: var(--web-shell-completion-column-gap);
|
||
}
|
||
|
||
[data-web-shell-tooltip-portal] .cm-completionInfo.cm-completionInfo-left-narrow {
|
||
right: var(--web-shell-completion-detail-start);
|
||
}
|
||
`;
|
||
|
||
function ensureTooltipStyles(root: Document | ShadowRoot) {
|
||
if (root.getElementById(TOOLTIP_STYLE_ID)) return;
|
||
const ownerDocument = root instanceof Document ? root : root.ownerDocument;
|
||
const style = ownerDocument.createElement('style');
|
||
style.id = TOOLTIP_STYLE_ID;
|
||
style.textContent = TOOLTIP_STYLES;
|
||
if (root instanceof Document) {
|
||
root.head.appendChild(style);
|
||
} else {
|
||
root.appendChild(style);
|
||
}
|
||
}
|
||
|
||
function getTooltipStyleRoot(parent: HTMLElement): Document | ShadowRoot {
|
||
const root = parent.getRootNode();
|
||
return root instanceof ShadowRoot ? root : parent.ownerDocument;
|
||
}
|
||
|
||
/**
|
||
* Compute the next selected index for an open, composer-owned slash-command
|
||
* menu. History-recalled slash commands suppress the menu before this runs, so
|
||
* arrow keys can keep walking input history in that path.
|
||
* Returns null when there is nothing to select.
|
||
*/
|
||
function nextSlashSelectionIndex(
|
||
selectedIndex: number,
|
||
count: number,
|
||
direction: 'up' | 'down',
|
||
): number | null {
|
||
if (count <= 0) return null;
|
||
const delta = direction === 'up' ? -1 : 1;
|
||
return (((selectedIndex + delta) % count) + count) % count;
|
||
}
|
||
|
||
function isSlashCommandCompletion(completion: Completion): boolean {
|
||
return (
|
||
typeof completion.apply === 'string' &&
|
||
completion.apply.trim().startsWith('/')
|
||
);
|
||
}
|
||
|
||
function hasCommandHoverInfo(completion: Completion): boolean {
|
||
return isSlashCommandCompletion(completion);
|
||
}
|
||
|
||
function getCompletionInfoTitle(completion: Completion): string {
|
||
if (typeof completion.apply === 'string') {
|
||
return completion.apply.trim();
|
||
}
|
||
return completion.displayLabel?.trim() || completion.label;
|
||
}
|
||
|
||
function clearCompletionHoverInfo(portal: Element) {
|
||
portal.querySelectorAll('.cm-completionInfo-hover').forEach((node) => {
|
||
node.remove();
|
||
});
|
||
}
|
||
|
||
function showCompletionHoverInfo(
|
||
anchor: HTMLElement,
|
||
completion: Completion,
|
||
event: MouseEvent,
|
||
) {
|
||
if (!completion.detail || !hasCommandHoverInfo(completion)) return;
|
||
const portal = anchor.closest('[data-web-shell-tooltip-portal]');
|
||
if (!portal) return;
|
||
|
||
let info = portal.querySelector<HTMLElement>('.cm-completionInfo-hover');
|
||
if (!info) {
|
||
info = document.createElement('div');
|
||
info.className =
|
||
'cm-tooltip cm-completionInfo cm-completionInfo-hover cm-completionInfo-right-narrow';
|
||
portal.appendChild(info);
|
||
}
|
||
info.textContent = `${getCompletionInfoTitle(completion)}\n\n${completion.detail}`;
|
||
const hideTimerId = info.dataset['hideTimerId'];
|
||
if (hideTimerId) {
|
||
window.clearTimeout(Number(hideTimerId));
|
||
delete info.dataset['hideTimerId'];
|
||
}
|
||
|
||
const infoRect = info.getBoundingClientRect();
|
||
const offsetX = 18;
|
||
const offsetY = 12;
|
||
const padding = 12;
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
const preferredLeft = event.clientX + offsetX;
|
||
const left =
|
||
preferredLeft + infoRect.width + padding > viewportWidth
|
||
? Math.max(padding, event.clientX - infoRect.width - offsetX)
|
||
: preferredLeft;
|
||
const top = Math.min(
|
||
Math.max(padding, event.clientY + offsetY),
|
||
Math.max(padding, viewportHeight - infoRect.height - padding),
|
||
);
|
||
|
||
info.style.position = 'fixed';
|
||
info.style.left = `${left}px`;
|
||
info.style.top = `${top}px`;
|
||
info.style.right = 'auto';
|
||
info.style.bottom = 'auto';
|
||
}
|
||
|
||
function scheduleClearCompletionHoverInfo(portal: Element) {
|
||
const info = portal.querySelector<HTMLElement>('.cm-completionInfo-hover');
|
||
if (!info) return;
|
||
const timerId = window.setTimeout(() => {
|
||
info.remove();
|
||
}, 180);
|
||
info.dataset['hideTimerId'] = String(timerId);
|
||
info.addEventListener(
|
||
'mouseenter',
|
||
() => {
|
||
window.clearTimeout(timerId);
|
||
delete info.dataset['hideTimerId'];
|
||
},
|
||
{ once: true },
|
||
);
|
||
info.addEventListener(
|
||
'mouseleave',
|
||
() => {
|
||
info.remove();
|
||
},
|
||
{ once: true },
|
||
);
|
||
}
|
||
|
||
function renderCompletionHoverInfo(completion: Completion): HTMLElement | null {
|
||
if (!completion.detail || !hasCommandHoverInfo(completion)) return null;
|
||
const anchor = document.createElement('span');
|
||
anchor.className = 'cm-command-hover-info-anchor';
|
||
anchor.setAttribute('aria-hidden', 'true');
|
||
queueMicrotask(() => {
|
||
const option = anchor.closest('li');
|
||
if (!option || option.hasAttribute('data-web-shell-hover-info')) return;
|
||
option.setAttribute('data-web-shell-hover-info', 'true');
|
||
option.addEventListener('mouseenter', (event) => {
|
||
showCompletionHoverInfo(anchor, completion, event);
|
||
});
|
||
option.addEventListener('mouseleave', () => {
|
||
const portal = anchor.closest('[data-web-shell-tooltip-portal]');
|
||
if (portal) scheduleClearCompletionHoverInfo(portal);
|
||
});
|
||
});
|
||
return anchor;
|
||
}
|
||
|
||
export function normalizePastedText(text: string): string {
|
||
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||
}
|
||
|
||
export function isLargePaste(text: string): boolean {
|
||
return (
|
||
[...text].length > LARGE_PASTE_CHAR_THRESHOLD ||
|
||
text.split('\n').length > LARGE_PASTE_LINE_THRESHOLD
|
||
);
|
||
}
|
||
|
||
function escapeRegExp(text: string): string {
|
||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
export interface LargePastePlaceholderResult {
|
||
placeholderText: string;
|
||
nextPasteId: number;
|
||
}
|
||
|
||
export function createLargePastePlaceholder(
|
||
pendingPastes: Map<string, string>,
|
||
nextPasteId: number,
|
||
pasted: string,
|
||
): LargePastePlaceholderResult {
|
||
const charCount = [...pasted].length;
|
||
const base = `[Pasted Content ${charCount} chars]`;
|
||
const placeholderText = nextPasteId === 1 ? base : `${base} #${nextPasteId}`;
|
||
pendingPastes.set(placeholderText, pasted);
|
||
return { placeholderText, nextPasteId: nextPasteId + 1 };
|
||
}
|
||
|
||
export function prunePendingPastes(
|
||
pendingPastes: Map<string, string>,
|
||
docText: string,
|
||
): number | null {
|
||
if (pendingPastes.size === 0) return null;
|
||
const placeholders = [...pendingPastes.keys()].sort(
|
||
(a, b) => b.length - a.length,
|
||
);
|
||
const pattern = new RegExp(placeholders.map(escapeRegExp).join('|'), 'g');
|
||
const found = new Set<string>();
|
||
for (const match of docText.matchAll(pattern)) {
|
||
found.add(match[0]);
|
||
}
|
||
for (const key of pendingPastes.keys()) {
|
||
if (!found.has(key)) pendingPastes.delete(key);
|
||
}
|
||
return pendingPastes.size === 0 ? 1 : null;
|
||
}
|
||
|
||
export function expandLargePastePlaceholders(
|
||
pendingPastes: Map<string, string>,
|
||
text: string,
|
||
): string {
|
||
if (pendingPastes.size === 0) return text;
|
||
const placeholders = [...pendingPastes.keys()].sort(
|
||
(a, b) => b.length - a.length,
|
||
);
|
||
const pattern = new RegExp(placeholders.map(escapeRegExp).join('|'), 'g');
|
||
return text.replace(
|
||
pattern,
|
||
(placeholderText) => pendingPastes.get(placeholderText) ?? placeholderText,
|
||
);
|
||
}
|
||
|
||
// ---- Tag serialization (shared) ----
|
||
|
||
export function serializeComposerTag(tag: WebShellComposerTag): string {
|
||
return getComposerTagSerialized(tag);
|
||
}
|
||
|
||
function serializeComposerTags(tags: readonly WebShellComposerTag[]): string {
|
||
return tags.map(serializeComposerTag).join('\n');
|
||
}
|
||
|
||
export function getComposerTagLabel(tag: WebShellComposerTag): string {
|
||
return tag.label?.trim() ?? '';
|
||
}
|
||
|
||
export function getComposerTagValue(tag: WebShellComposerTag): string {
|
||
return tag.value?.trim() ?? '';
|
||
}
|
||
|
||
export function getComposerTagDisplay(tag: WebShellComposerTag): string {
|
||
return getComposerTagValue(tag) || getComposerTagLabel(tag) || tag.id;
|
||
}
|
||
|
||
export function buildComposerPrompt(
|
||
text: string,
|
||
tags: readonly WebShellComposerTag[],
|
||
): string {
|
||
const tagText = serializeComposerTags(tags);
|
||
if (!tagText) return text;
|
||
if (!text) return tagText;
|
||
return `${tagText}\n\n${text}`;
|
||
}
|
||
|
||
export interface InlineTagPlacement {
|
||
start: number;
|
||
end: number;
|
||
tag: WebShellComposerTag;
|
||
}
|
||
|
||
export function buildComposerPromptWithInlineTagPlacements(
|
||
text: string,
|
||
topTags: readonly WebShellComposerTag[],
|
||
inlineTags: readonly InlineTagPlacement[],
|
||
): string {
|
||
return buildComposerPrompt(
|
||
replaceInlineTagPlacements(text, inlineTags),
|
||
topTags,
|
||
);
|
||
}
|
||
|
||
export function replaceInlineTagPlacements(
|
||
text: string,
|
||
inlineTags: readonly InlineTagPlacement[],
|
||
): string {
|
||
const placements = inlineTags
|
||
.filter(
|
||
(placement) =>
|
||
placement.start >= 0 &&
|
||
placement.end > placement.start &&
|
||
placement.end <= text.length,
|
||
)
|
||
.slice()
|
||
.sort((left, right) => left.start - right.start);
|
||
if (placements.length === 0) return text;
|
||
|
||
let cursor = 0;
|
||
const parts: string[] = [];
|
||
for (const placement of placements) {
|
||
if (placement.start < cursor) continue;
|
||
parts.push(text.slice(cursor, placement.start));
|
||
parts.push(serializeComposerTag(placement.tag));
|
||
cursor = placement.end;
|
||
}
|
||
parts.push(text.slice(cursor));
|
||
return parts.join('');
|
||
}
|
||
|
||
// ---- Inline tag CodeMirror extension (shared) ----
|
||
|
||
interface InlineTagRange {
|
||
from: number;
|
||
to: number;
|
||
tag: InlineComposerTag;
|
||
}
|
||
|
||
interface InlineTagDecorationSpec {
|
||
tag: InlineComposerTag;
|
||
}
|
||
|
||
type InlineComposerTag = WebShellComposerTag & {
|
||
iconUrl?: string;
|
||
renderContent?: ComposerTagRenderer;
|
||
tooltip?: ReactNode;
|
||
tooltipText?: string;
|
||
onClick?: ComposerTagClickHandler;
|
||
};
|
||
|
||
function toPublicComposerTag(tag: InlineComposerTag): WebShellComposerTag {
|
||
const publicTag = { ...tag };
|
||
delete publicTag.iconUrl;
|
||
delete publicTag.renderContent;
|
||
delete publicTag.tooltip;
|
||
delete publicTag.tooltipText;
|
||
delete publicTag.onClick;
|
||
return publicTag;
|
||
}
|
||
|
||
export const addInlineTagEffect = StateEffect.define<InlineTagRange>({
|
||
map: (value) => value,
|
||
});
|
||
export const removeInlineTagEffect = StateEffect.define<{
|
||
predicate?: (tag: WebShellComposerTag) => boolean;
|
||
}>();
|
||
export const clearInlineTagsEffect = StateEffect.define<void>();
|
||
|
||
let nextComposerTagTooltipId = 0;
|
||
|
||
class ComposerTagWidget extends WidgetType {
|
||
private contentRoot: Root | null = null;
|
||
private tooltipRoot: Root | null = null;
|
||
|
||
constructor(private readonly tag: InlineComposerTag) {
|
||
super();
|
||
}
|
||
|
||
eq(other: ComposerTagWidget): boolean {
|
||
return (
|
||
this.tag.id === other.tag.id &&
|
||
this.tag.label === other.tag.label &&
|
||
this.tag.value === other.tag.value &&
|
||
this.tag.kind === other.tag.kind &&
|
||
this.tag.icon === other.tag.icon &&
|
||
this.tag.serialized === other.tag.serialized &&
|
||
this.tag.removable === other.tag.removable &&
|
||
this.tag.iconUrl === other.tag.iconUrl &&
|
||
this.tag.renderContent === other.tag.renderContent &&
|
||
this.tag.tooltip === other.tag.tooltip &&
|
||
this.tag.tooltipText === other.tag.tooltipText &&
|
||
this.tag.onClick === other.tag.onClick
|
||
);
|
||
}
|
||
|
||
toDOM(view: EditorView): HTMLElement {
|
||
const chip = document.createElement('span');
|
||
const publicTag = toPublicComposerTag(this.tag);
|
||
chip.style.cssText =
|
||
'position:relative;display:inline-flex;align-items:center;max-width:min(44ch,100%);min-height:20px;margin:0 0.25ch;border:1px solid var(--border);border-radius:4px;background:var(--secondary);color:var(--foreground);font-family:var(--font-mono,monospace);font-size:12px;line-height:1.2;vertical-align:baseline;';
|
||
if (this.tag.onClick) {
|
||
chip.setAttribute('role', 'button');
|
||
chip.tabIndex = 0;
|
||
chip.style.cursor = 'pointer';
|
||
chip.addEventListener('pointerdown', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
});
|
||
chip.addEventListener('mousedown', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
});
|
||
chip.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
this.tag.onClick?.({
|
||
tag: publicTag,
|
||
placement: 'composer',
|
||
readonly: false,
|
||
anchorRect: chip.getBoundingClientRect(),
|
||
});
|
||
});
|
||
chip.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
event.preventDefault();
|
||
this.tag.onClick?.({
|
||
tag: publicTag,
|
||
placement: 'composer',
|
||
readonly: false,
|
||
anchorRect: chip.getBoundingClientRect(),
|
||
});
|
||
});
|
||
}
|
||
const rawTagLabel = getComposerTagLabel(this.tag);
|
||
const tagValue = getComposerTagValue(this.tag);
|
||
const tagLabel = this.tag.kind ? '' : rawTagLabel;
|
||
const iconUrl = this.tag.iconUrl ?? getComposerTagIconUrl(this.tag.kind);
|
||
const safeIconUrl =
|
||
iconUrl &&
|
||
(isBuiltinComposerTagIconUrl(iconUrl) || isSafeImageSrc(iconUrl))
|
||
? iconUrl
|
||
: undefined;
|
||
let customContent: ReactNode | null | undefined;
|
||
try {
|
||
customContent = this.tag.renderContent?.({
|
||
tag: publicTag,
|
||
placement: 'composer',
|
||
readonly: false,
|
||
});
|
||
} catch (error) {
|
||
console.warn('[WebShell] inline tag renderContent failed', error);
|
||
}
|
||
|
||
let renderedCustomContent = false;
|
||
if (customContent !== undefined && customContent !== null) {
|
||
const content = document.createElement('span');
|
||
content.style.cssText =
|
||
'display:inline-flex;align-items:center;min-width:0;max-width:100%;';
|
||
try {
|
||
this.contentRoot = createRoot(content);
|
||
this.contentRoot.render(customContent);
|
||
chip.appendChild(content);
|
||
renderedCustomContent = true;
|
||
} catch (error) {
|
||
this.contentRoot?.unmount();
|
||
this.contentRoot = null;
|
||
console.warn('[WebShell] inline tag renderContent failed', error);
|
||
}
|
||
}
|
||
|
||
if (!renderedCustomContent && safeIconUrl) {
|
||
const icon = document.createElement('span');
|
||
icon.style.cssText =
|
||
'display:block;width:12px;height:12px;flex:0 0 auto;margin-left:7px;background:currentColor;mask:var(--composer-tag-icon-url) center / contain no-repeat;-webkit-mask:var(--composer-tag-icon-url) center / contain no-repeat;';
|
||
icon.style.setProperty(
|
||
'--composer-tag-icon-url',
|
||
cssUrlValue(safeIconUrl),
|
||
);
|
||
chip.appendChild(icon);
|
||
}
|
||
|
||
if (!renderedCustomContent) {
|
||
this.appendDefaultContent(chip, tagLabel, tagValue);
|
||
}
|
||
|
||
if (this.tag.tooltip !== undefined && this.tag.tooltip !== null) {
|
||
this.appendTooltip(chip, this.tag.tooltip);
|
||
}
|
||
|
||
if (this.tag.removable !== false) {
|
||
this.appendRemoveButton(chip, view);
|
||
}
|
||
|
||
return chip;
|
||
}
|
||
|
||
private appendDefaultContent(
|
||
chip: HTMLElement,
|
||
tagLabel: string,
|
||
tagValue: string,
|
||
) {
|
||
if (tagLabel) {
|
||
const label = document.createElement('span');
|
||
label.style.cssText =
|
||
'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:3px 0 3px 7px;color:var(--agent-blue-500);';
|
||
label.textContent = tagLabel;
|
||
chip.appendChild(label);
|
||
}
|
||
|
||
if (tagValue) {
|
||
const value = document.createElement('span');
|
||
value.style.cssText =
|
||
'max-width:32ch;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:3px 0 3px 0.5ch;color:var(--foreground, #e4e4e4);';
|
||
value.textContent = tagValue;
|
||
chip.appendChild(value);
|
||
} else if (!tagLabel) {
|
||
const fallback = document.createElement('span');
|
||
fallback.style.cssText =
|
||
'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:3px 0 3px 7px;color:var(--agent-blue-500);';
|
||
fallback.textContent = this.tag.id;
|
||
chip.appendChild(fallback);
|
||
}
|
||
}
|
||
|
||
private appendTooltip(chip: HTMLElement, tooltip: ReactNode) {
|
||
const tooltipElement = document.createElement('span');
|
||
tooltipElement.setAttribute('role', 'tooltip');
|
||
tooltipElement.style.cssText =
|
||
'position:absolute;z-index:calc(var(--web-shell-tooltip-z-index,1000) + 1);top:calc(100% + 6px);left:0;display:none;min-width:160px;max-width:min(320px,80vw);padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--background);box-shadow:0 8px 24px rgba(0,0,0,0.18);color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:12px;line-height:1.5;white-space:normal;';
|
||
try {
|
||
this.tooltipRoot = createRoot(tooltipElement);
|
||
this.tooltipRoot.render(tooltip);
|
||
chip.appendChild(tooltipElement);
|
||
tooltipElement.id = `composer-tag-tooltip-${++nextComposerTagTooltipId}`;
|
||
chip.setAttribute('aria-describedby', tooltipElement.id);
|
||
} catch (error) {
|
||
this.tooltipRoot?.unmount();
|
||
this.tooltipRoot = null;
|
||
if (this.tag.tooltipText) {
|
||
chip.title = this.tag.tooltipText;
|
||
}
|
||
console.warn('[WebShell] inline tag tooltip render failed', error);
|
||
return;
|
||
}
|
||
const show = () => {
|
||
tooltipElement.style.display = 'block';
|
||
};
|
||
const hide = () => {
|
||
tooltipElement.style.display = 'none';
|
||
};
|
||
chip.addEventListener('mouseenter', show);
|
||
chip.addEventListener('mouseleave', hide);
|
||
chip.addEventListener('focusin', show);
|
||
chip.addEventListener('focusout', hide);
|
||
}
|
||
|
||
private appendRemoveButton(chip: HTMLElement, view: EditorView) {
|
||
const remove = document.createElement('button');
|
||
remove.type = 'button';
|
||
remove.setAttribute(
|
||
'aria-label',
|
||
`Remove ${getComposerTagDisplay(this.tag)}`,
|
||
);
|
||
remove.style.cssText =
|
||
'flex:0 0 auto;width:22px;height:22px;padding:0;border:0;background:transparent;color:var(--muted-foreground);font:inherit;line-height:22px;cursor:pointer;';
|
||
remove.textContent = '×';
|
||
remove.addEventListener('mousedown', (event) => event.preventDefault());
|
||
remove.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.stopPropagation();
|
||
return;
|
||
}
|
||
if (event.key !== 'Backspace' && event.key !== 'Delete') return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
remove.click();
|
||
});
|
||
remove.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
const changes: Array<{ from: number; to: number; insert: string }> = [];
|
||
view.state
|
||
.field(inlineComposerTagField)
|
||
.between(0, view.state.doc.length, (from, to, value) => {
|
||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||
if (tag?.id === this.tag.id && tag.removable !== false) {
|
||
changes.push({ from, to, insert: '' });
|
||
}
|
||
});
|
||
if (changes.length === 0) return;
|
||
view.dispatch({
|
||
changes,
|
||
effects: removeInlineTagEffect.of({
|
||
predicate: (tag) => tag.id === this.tag.id,
|
||
}),
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
});
|
||
remove.addEventListener('mouseenter', () => {
|
||
remove.style.color = 'var(--error-color)';
|
||
});
|
||
remove.addEventListener('mouseleave', () => {
|
||
remove.style.color = 'var(--muted-foreground)';
|
||
});
|
||
chip.appendChild(remove);
|
||
}
|
||
|
||
destroy() {
|
||
this.contentRoot?.unmount();
|
||
this.tooltipRoot?.unmount();
|
||
this.contentRoot = null;
|
||
this.tooltipRoot = null;
|
||
}
|
||
|
||
ignoreEvent(): boolean {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function createInlineTagDecoration(range: InlineTagRange) {
|
||
const spec = {
|
||
widget: new ComposerTagWidget(range.tag),
|
||
inclusive: false,
|
||
tag: range.tag,
|
||
};
|
||
return Decoration.replace(spec).range(range.from, range.to);
|
||
}
|
||
|
||
const inlineComposerTagField = StateField.define<DecorationSet>({
|
||
create() {
|
||
return Decoration.none;
|
||
},
|
||
update(tags, tr) {
|
||
let next = tags.map(tr.changes);
|
||
for (const effect of tr.effects) {
|
||
if (effect.is(addInlineTagEffect)) {
|
||
next = next.update({ add: [createInlineTagDecoration(effect.value)] });
|
||
} else if (effect.is(removeInlineTagEffect)) {
|
||
next = next.update({
|
||
filter: (_from, _to, value) => {
|
||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||
if (!tag) return true;
|
||
return effect.value.predicate ? !effect.value.predicate(tag) : true;
|
||
},
|
||
});
|
||
} else if (effect.is(clearInlineTagsEffect)) {
|
||
next = Decoration.none;
|
||
}
|
||
}
|
||
return next;
|
||
},
|
||
provide: (field) => [
|
||
EditorView.decorations.from(field),
|
||
EditorView.atomicRanges.of((view) => view.state.field(field)),
|
||
],
|
||
});
|
||
|
||
export function getInlineComposerTags(view: EditorView): WebShellComposerTag[] {
|
||
const tags: WebShellComposerTag[] = [];
|
||
const inlineTags = view.state.field(inlineComposerTagField, false);
|
||
inlineTags?.between(0, view.state.doc.length, (_from, _to, value) => {
|
||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||
if (tag) tags.push(toPublicComposerTag(tag));
|
||
});
|
||
return tags;
|
||
}
|
||
|
||
function hasInlineComposerTags(view: EditorView): boolean {
|
||
const tags = view.state.field(inlineComposerTagField, false);
|
||
if (!tags) return false;
|
||
let hasTags = false;
|
||
tags.between(0, view.state.doc.length, () => {
|
||
hasTags = true;
|
||
return false;
|
||
});
|
||
return hasTags;
|
||
}
|
||
|
||
function getInlineComposerTagPlacements(
|
||
view: EditorView,
|
||
): InlineTagPlacement[] {
|
||
const placements: InlineTagPlacement[] = [];
|
||
view.state
|
||
.field(inlineComposerTagField)
|
||
.between(0, view.state.doc.length, (from, to, value) => {
|
||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||
if (tag) {
|
||
placements.push({
|
||
start: from,
|
||
end: to,
|
||
tag: toPublicComposerTag(tag),
|
||
});
|
||
}
|
||
});
|
||
return placements;
|
||
}
|
||
|
||
// ---- EditorHandle type (shared) ----
|
||
|
||
export interface EditorHandle extends WebShellComposerApi {
|
||
clearText(): void;
|
||
focus(): void;
|
||
getText(): string;
|
||
hasAttachments(): boolean;
|
||
hasInput(): boolean;
|
||
retryLast(): void;
|
||
restoreImages(images: readonly PromptImage[]): void;
|
||
}
|
||
|
||
// ---- Compartments (shared) ----
|
||
|
||
export const editableCompartment = new Compartment();
|
||
export const placeholderCompartment = new Compartment();
|
||
export const followupGhostCompartment = new Compartment();
|
||
|
||
export function getFollowupCompletion(
|
||
text: string,
|
||
suggestion: string | null | undefined,
|
||
): string | null {
|
||
if (!suggestion) return null;
|
||
if (text.length === 0) return suggestion;
|
||
return suggestion.startsWith(text) ? suggestion : null;
|
||
}
|
||
|
||
function getFollowupRemainder(
|
||
text: string,
|
||
suggestion: string | null | undefined,
|
||
): string | null {
|
||
const completion = getFollowupCompletion(text, suggestion);
|
||
if (!completion || text.length === 0) return null;
|
||
const remainder = completion.slice(text.length);
|
||
return remainder.length > 0 ? remainder : null;
|
||
}
|
||
|
||
class FollowupGhostWidget extends WidgetType {
|
||
constructor(private readonly text: string) {
|
||
super();
|
||
}
|
||
|
||
eq(other: FollowupGhostWidget): boolean {
|
||
return this.text === other.text;
|
||
}
|
||
|
||
toDOM(): HTMLElement {
|
||
const ghost = document.createElement('span');
|
||
ghost.className = 'cm-followup-ghost';
|
||
ghost.textContent = this.text;
|
||
return ghost;
|
||
}
|
||
|
||
ignoreEvent(): boolean {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function createFollowupGhostExtension(suggestion: string | null) {
|
||
return ViewPlugin.fromClass(
|
||
class {
|
||
decorations: DecorationSet;
|
||
|
||
constructor(view: EditorView) {
|
||
this.decorations = this.buildDecorations(view);
|
||
}
|
||
|
||
update(update: {
|
||
view: EditorView;
|
||
docChanged: boolean;
|
||
selectionSet: boolean;
|
||
}) {
|
||
if (update.docChanged || update.selectionSet) {
|
||
this.decorations = this.buildDecorations(update.view);
|
||
}
|
||
}
|
||
|
||
private buildDecorations(view: EditorView): DecorationSet {
|
||
if (!suggestion) return Decoration.none;
|
||
const selection = view.state.selection.main;
|
||
const text = view.state.doc.toString();
|
||
const remainder = getFollowupRemainder(text, suggestion);
|
||
if (!remainder || !selection.empty || selection.head !== text.length) {
|
||
return Decoration.none;
|
||
}
|
||
return Decoration.set([
|
||
Decoration.widget({
|
||
widget: new FollowupGhostWidget(remainder),
|
||
side: 1,
|
||
}).range(text.length),
|
||
]);
|
||
}
|
||
},
|
||
{
|
||
decorations: (plugin) => plugin.decorations,
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---- Hook options ----
|
||
|
||
export type ComposerSubmitCommit = () => void;
|
||
|
||
export interface ComposerSubmitMetadata {
|
||
inputAnnotations?: DaemonInputAnnotation[];
|
||
}
|
||
|
||
export interface UseComposerCoreOptions {
|
||
onSubmit: (
|
||
text: string,
|
||
images?: PromptImage[],
|
||
commitAccepted?: ComposerSubmitCommit,
|
||
metadata?: ComposerSubmitMetadata,
|
||
) => boolean | void;
|
||
onInputTextChange?: (text: string) => void;
|
||
onCycleMode?: () => void;
|
||
onToggleShortcuts?: () => void;
|
||
disabled?: boolean;
|
||
placeholderText?: string;
|
||
commands: CommandInfo[];
|
||
skills?: SkillInfo[];
|
||
slashCommandCategoryOrder?: CommandDisplayCategoryOrder;
|
||
queuedMessages?: string[];
|
||
onPopQueuedMessages?: () => boolean;
|
||
onClearQueuedMessages?: () => boolean;
|
||
currentMode?: string;
|
||
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[];
|
||
atWorkspaceCwd?: string;
|
||
composerTagIcons?: WebShellComposerTagIconMap;
|
||
parseUserMessageContent?: UserMessageContentParser;
|
||
renderComposerTag?: ComposerTagRenderer;
|
||
renderComposerTagTooltip?: ComposerTagRenderer;
|
||
onComposerTagClick?: ComposerTagClickHandler;
|
||
/** CodeMirror theme extension for the editor view. Each variant provides its own. */
|
||
editorTheme: Parameters<typeof EditorView.theme>[0];
|
||
}
|
||
|
||
const SESSION_DRAFT_STORAGE_PREFIX = 'qwen-web-shell-session-draft:';
|
||
const PENDING_TASK_DRAFT_STORAGE_PREFIX = 'qwen-web-shell-pending-task-draft:';
|
||
const COMPOSER_DRAFT_SAVE_DELAY_MS = 2000;
|
||
|
||
function getComposerDraftStorageKey(
|
||
sessionId: string | undefined,
|
||
workspaceCwd: string | undefined,
|
||
): string | undefined {
|
||
if (sessionId) {
|
||
return `${SESSION_DRAFT_STORAGE_PREFIX}${encodeURIComponent(sessionId)}`;
|
||
}
|
||
if (workspaceCwd) {
|
||
return `${PENDING_TASK_DRAFT_STORAGE_PREFIX}${encodeURIComponent(workspaceCwd)}`;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function loadComposerDraft(storageKey: string | undefined): string | null {
|
||
if (!storageKey) return null;
|
||
try {
|
||
return localStorage.getItem(storageKey);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function saveComposerDraft(storageKey: string | undefined, text: string): void {
|
||
if (!storageKey) return;
|
||
try {
|
||
if (text) {
|
||
localStorage.setItem(storageKey, text);
|
||
} else {
|
||
localStorage.removeItem(storageKey);
|
||
}
|
||
} catch {
|
||
// Ignore storage failures in private browsing or restricted contexts.
|
||
}
|
||
}
|
||
|
||
function clearComposerDraftIfMatches(
|
||
storageKey: string | undefined,
|
||
text: string,
|
||
): void {
|
||
if (!storageKey) return;
|
||
if (loadComposerDraft(storageKey) === text) {
|
||
saveComposerDraft(storageKey, '');
|
||
}
|
||
}
|
||
|
||
export interface SearchState {
|
||
searchMode: boolean;
|
||
searchQuery: string;
|
||
searchMatches: string[];
|
||
searchActiveIndex: number;
|
||
searchInputRef: React.RefObject<HTMLInputElement | null>;
|
||
searchUiRef: React.RefObject<HTMLDivElement | null>;
|
||
openHistorySearch: () => void;
|
||
closeSearch: (restoreDraft: boolean, keepFocus?: boolean) => void;
|
||
submitSearchMatch: (match: string) => void;
|
||
restoreSearchMatch?: (match: string) => void;
|
||
handleSearchKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||
handleSearchInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||
handleSearchCompositionEnd: (
|
||
e: React.CompositionEvent<HTMLInputElement>,
|
||
) => void;
|
||
}
|
||
|
||
export interface SlashMenuState extends SlashCommandCompletionResult {
|
||
selectedIndex: number;
|
||
}
|
||
|
||
function shallowEqualSlashMenu(
|
||
current: SlashMenuState | null,
|
||
next: SlashMenuState | null,
|
||
): boolean {
|
||
if (current === next) return true;
|
||
if (!current || !next) return false;
|
||
const keys = Object.keys(current) as Array<keyof SlashMenuState>;
|
||
return (
|
||
keys.length === Object.keys(next).length &&
|
||
keys.every((key) => {
|
||
if (key !== 'items') return Object.is(current[key], next[key]);
|
||
return (
|
||
current.items.length === next.items.length &&
|
||
current.items.every((item, index) => {
|
||
const nextItem = next.items[index];
|
||
if (!nextItem) return false;
|
||
const itemKeys = Object.keys(item) as Array<keyof typeof item>;
|
||
return (
|
||
itemKeys.length === Object.keys(nextItem).length &&
|
||
itemKeys.every((itemKey) =>
|
||
Object.is(item[itemKey], nextItem[itemKey]),
|
||
)
|
||
);
|
||
})
|
||
);
|
||
})
|
||
);
|
||
}
|
||
|
||
type MultilineHistoryBoundary = 'editor' | 'handled' | 'history';
|
||
|
||
function handleMultilineHistoryBoundary(
|
||
view: EditorView,
|
||
direction: 'up' | 'down',
|
||
): MultilineHistoryBoundary {
|
||
const doc = view.state.doc;
|
||
if (doc.lines <= 1) return 'history';
|
||
|
||
const selection = view.state.selection.main;
|
||
if (!selection.empty) return 'editor';
|
||
|
||
const head = selection.head;
|
||
const line = doc.lineAt(head);
|
||
|
||
// Let CodeMirror handle normal multi-line cursor movement first. Once the
|
||
// cursor is on the edge line, one more arrow key snaps to the true edge;
|
||
// the next press can browse prompt history instead of feeling stuck.
|
||
if (direction === 'up') {
|
||
if (line.number > 1) return 'editor';
|
||
if (head > line.from) {
|
||
view.dispatch({
|
||
selection: { anchor: line.from },
|
||
scrollIntoView: true,
|
||
});
|
||
return 'handled';
|
||
}
|
||
return 'history';
|
||
}
|
||
|
||
if (line.number < doc.lines) return 'editor';
|
||
if (head < line.to) {
|
||
view.dispatch({
|
||
selection: { anchor: line.to },
|
||
scrollIntoView: true,
|
||
});
|
||
return 'handled';
|
||
}
|
||
return 'history';
|
||
}
|
||
|
||
/**
|
||
* Extracts pasteable images from a clipboard, shared by the CodeMirror paste
|
||
* handler and the mobile textarea backend. Returns whether any image was
|
||
* found; matched files are decoded asynchronously and reported via `addImage`.
|
||
*/
|
||
function collectClipboardImages(
|
||
items: DataTransferItemList,
|
||
addImage: (image: PromptImage) => void,
|
||
): boolean {
|
||
let hasImage = false;
|
||
for (const item of items) {
|
||
if (
|
||
item.type.startsWith('image/') &&
|
||
/^image\/(png|jpeg|gif|webp)$/i.test(item.type)
|
||
) {
|
||
hasImage = true;
|
||
const file = item.getAsFile();
|
||
if (!file) continue;
|
||
const mediaType = item.type;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const base64 = (reader.result as string).split(',')[1];
|
||
addImage({ data: base64, media_type: mediaType });
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
}
|
||
return hasImage;
|
||
}
|
||
|
||
/**
|
||
* Editor backend for touch devices: instead of a CodeMirror EditorView, the
|
||
* composer renders a plain controlled `<textarea>` wired to these fields.
|
||
* Mobile virtual keyboards and IMEs interact poorly with CodeMirror's
|
||
* contenteditable (see #5958), while a native textarea gets the platform's
|
||
* full input stack for free. Null on desktop.
|
||
*/
|
||
export interface MobileComposerBackend {
|
||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||
value: string;
|
||
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||
onBlur: () => void;
|
||
onPaste: (event: React.ClipboardEvent<HTMLTextAreaElement>) => void;
|
||
placeholder: string;
|
||
}
|
||
|
||
export interface UseComposerCoreReturn {
|
||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||
viewRef: React.RefObject<EditorView | null>;
|
||
mobileComposer: MobileComposerBackend | null;
|
||
focus: () => void;
|
||
submitText: () => void;
|
||
clearText: () => void;
|
||
getText: () => string;
|
||
hasInput: () => boolean;
|
||
hasAttachments: boolean;
|
||
hasContent: boolean;
|
||
handle: EditorHandle;
|
||
pastedImages: PromptImage[];
|
||
removeImage: (index: number) => void;
|
||
composerTags: WebShellComposerTag[];
|
||
removeTopTag: (id: string) => void;
|
||
addTags: (
|
||
tags: readonly WebShellComposerTag[],
|
||
options?: WebShellComposerTagOptions,
|
||
) => void;
|
||
removeInlineTags: (predicate?: (tag: WebShellComposerTag) => boolean) => void;
|
||
insertText: (text: string, options?: WebShellComposerTextOptions) => void;
|
||
setText: (text: string) => void;
|
||
submit: (input?: WebShellComposerInput) => void;
|
||
clear: (options?: { text?: boolean; tags?: boolean }) => void;
|
||
retryLast: () => void;
|
||
replaceEditorText: (text: string) => void;
|
||
shellMode: boolean;
|
||
setShellMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||
toggleShellMode: () => void;
|
||
currentMode: string;
|
||
sessionName: string | undefined;
|
||
searchState: SearchState;
|
||
navigatePrevHistory: () => void;
|
||
navigateNextHistory: () => void;
|
||
showShortcutHints: boolean;
|
||
followupState: UseDaemonFollowupSuggestionReturn['followupState'];
|
||
disabled: boolean;
|
||
onAcceptFollowup: UseDaemonFollowupSuggestionReturn['onAcceptFollowup'];
|
||
onDismissFollowup: UseDaemonFollowupSuggestionReturn['onDismissFollowup'];
|
||
slashMenu: SlashMenuState | null;
|
||
closeSlashMenu: () => void;
|
||
selectSlashCompletion: (index: number) => boolean;
|
||
acceptSlashCompletion: (index?: number) => boolean;
|
||
atMenu: AtMentionMenuState | null;
|
||
closeAtMenu: () => void;
|
||
selectAtCompletion: (index: number) => boolean;
|
||
acceptAtCompletion: (index?: number) => boolean;
|
||
enterAtCategory: (index?: number) => boolean;
|
||
backAtCategories: () => false | 'items' | 'categories';
|
||
updateAtSearch: (query: string) => boolean;
|
||
selectAtTab: (tabId: string) => boolean;
|
||
}
|
||
|
||
export function useComposerCore(
|
||
options: UseComposerCoreOptions,
|
||
): UseComposerCoreReturn {
|
||
const {
|
||
onSubmit,
|
||
onInputTextChange,
|
||
onCycleMode,
|
||
onToggleShortcuts,
|
||
disabled = false,
|
||
placeholderText = 'Type a message...',
|
||
commands,
|
||
skills = [],
|
||
slashCommandCategoryOrder,
|
||
queuedMessages = [],
|
||
onPopQueuedMessages,
|
||
currentMode = 'default',
|
||
onFocusFooter,
|
||
dialogOpen = false,
|
||
followupState,
|
||
onAcceptFollowup,
|
||
onDismissFollowup,
|
||
sessionId,
|
||
sessionName,
|
||
composerInput,
|
||
composerInputVersion,
|
||
builtinAtProviders,
|
||
atProviders,
|
||
atWorkspaceCwd,
|
||
composerTagIcons,
|
||
parseUserMessageContent,
|
||
renderComposerTag,
|
||
renderComposerTagTooltip,
|
||
onComposerTagClick,
|
||
editorTheme,
|
||
} = options;
|
||
|
||
const workspace = useOptionalWorkspace();
|
||
const { language, t } = useI18n();
|
||
const portalRoot = useWebShellPortalRoot();
|
||
const promptHistoryStorageKey = getPromptHistoryStorageKey(atWorkspaceCwd);
|
||
const legacyPromptHistoryStorageKey = getPromptHistoryStorageKey();
|
||
const promptHistoryFallbackStorageKey =
|
||
promptHistoryStorageKey === legacyPromptHistoryStorageKey
|
||
? undefined
|
||
: legacyPromptHistoryStorageKey;
|
||
const composerDraftStorageKey = getComposerDraftStorageKey(
|
||
sessionId,
|
||
atWorkspaceCwd,
|
||
);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const viewRef = useRef<EditorView | null>(null);
|
||
// Mobile textarea backend (#5958). When active, no EditorView is ever
|
||
// created: ChatEditor renders a plain controlled <textarea> instead, and
|
||
// the imperative methods below branch on `isTouchComposer`. The draft is
|
||
// mirrored into a ref so submit/getText read synchronously.
|
||
const isTouchComposer = useIsTouchComposer();
|
||
const mobileTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const mobileMaxHeightRef = useRef<number | null>(null);
|
||
const [mobileText, setMobileTextState] = useState(() =>
|
||
isTouchComposer ? (loadComposerDraft(composerDraftStorageKey) ?? '') : '',
|
||
);
|
||
const mobileTextRef = useRef(mobileText);
|
||
const mobileTextVersionRef = useRef(0);
|
||
const draftIdentityRef = useRef({
|
||
sessionId,
|
||
workspaceCwd: atWorkspaceCwd,
|
||
storageKey: composerDraftStorageKey,
|
||
});
|
||
const unscopedDraftEditedRef = useRef(false);
|
||
const saveCurrentDraftRef = useRef<() => void>(() => undefined);
|
||
const scheduleDraftSaveRef = useRef<() => void>(() => undefined);
|
||
const composerIdentityRef = useRef({
|
||
sessionId,
|
||
promptHistoryStorageKey,
|
||
promptHistoryFallbackStorageKey,
|
||
draftStorageKey: composerDraftStorageKey,
|
||
});
|
||
composerIdentityRef.current = {
|
||
sessionId,
|
||
promptHistoryStorageKey,
|
||
promptHistoryFallbackStorageKey,
|
||
draftStorageKey: composerDraftStorageKey,
|
||
};
|
||
const tooltipPortalRef = useRef<HTMLDivElement | null>(null);
|
||
const onSubmitRef = useRef(onSubmit);
|
||
onSubmitRef.current = onSubmit;
|
||
const onInputTextChangeRef = useRef(onInputTextChange);
|
||
onInputTextChangeRef.current = onInputTextChange;
|
||
// Mirrors the CodeMirror updateListener contract: every draft change —
|
||
// typing or programmatic (setText, clear, history restore, post-submit
|
||
// clear) — notifies onInputTextChange, so parent trackers never go stale.
|
||
const setMobileText = useCallback((text: string) => {
|
||
mobileTextVersionRef.current += 1;
|
||
mobileTextRef.current = text;
|
||
setMobileTextState(text);
|
||
if (draftIdentityRef.current.storageKey === undefined) {
|
||
unscopedDraftEditedRef.current = true;
|
||
}
|
||
if (!historyBrowseActiveRef.current && !searchModeRef.current) {
|
||
scheduleDraftSaveRef.current();
|
||
}
|
||
onInputTextChangeRef.current?.(text);
|
||
}, []);
|
||
useEffect(() => {
|
||
if (isTouchComposer && mobileTextRef.current) {
|
||
onInputTextChangeRef.current?.(mobileTextRef.current);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
const onCycleModeRef = useRef(onCycleMode);
|
||
onCycleModeRef.current = onCycleMode;
|
||
const onToggleShortcutsRef = useRef(onToggleShortcuts);
|
||
onToggleShortcutsRef.current = onToggleShortcuts;
|
||
const disabledRef = useRef(disabled);
|
||
disabledRef.current = disabled;
|
||
const commandsRef = useRef(commands);
|
||
commandsRef.current = commands;
|
||
const skillsRef = useRef(skills);
|
||
skillsRef.current = skills;
|
||
const slashCommandCategoryOrderRef = useRef(slashCommandCategoryOrder);
|
||
slashCommandCategoryOrderRef.current = slashCommandCategoryOrder;
|
||
const tRef = useRef(t);
|
||
tRef.current = t;
|
||
const queuedMessagesRef = useRef(queuedMessages);
|
||
queuedMessagesRef.current = queuedMessages;
|
||
const onPopQueuedMessagesRef = useRef(onPopQueuedMessages);
|
||
onPopQueuedMessagesRef.current = onPopQueuedMessages;
|
||
const followupStateRef = useRef(followupState);
|
||
followupStateRef.current = followupState;
|
||
const onAcceptFollowupRef = useRef(onAcceptFollowup);
|
||
onAcceptFollowupRef.current = onAcceptFollowup;
|
||
const onDismissFollowupRef = useRef(onDismissFollowup);
|
||
onDismissFollowupRef.current = onDismissFollowup;
|
||
const onFocusFooterRef = useRef(onFocusFooter);
|
||
onFocusFooterRef.current = onFocusFooter;
|
||
const languageRef = useRef(language);
|
||
languageRef.current = language;
|
||
const workspaceActionsRef = useRef<AtMentionWorkspaceActions | undefined>(
|
||
undefined,
|
||
);
|
||
if (workspace && atWorkspaceCwd) {
|
||
const client = workspace.client.workspaceByCwd(atWorkspaceCwd);
|
||
workspaceActionsRef.current = {
|
||
...workspace.actions,
|
||
async globWorkspace(pattern, options) {
|
||
options?.signal?.throwIfAborted();
|
||
const result = (await client.glob(pattern, {
|
||
maxResults: options?.maxResults,
|
||
signal: options?.signal,
|
||
})) as { matches?: unknown[] };
|
||
options?.signal?.throwIfAborted();
|
||
const matches = Array.isArray(result.matches)
|
||
? result.matches.filter(
|
||
(match): match is string => typeof match === 'string',
|
||
)
|
||
: [];
|
||
return { matches };
|
||
},
|
||
async listDirectory(dirPath, options) {
|
||
if (options?.signal?.aborted) {
|
||
return { kind: 'list', path: dirPath, entries: [], truncated: false };
|
||
}
|
||
const result = (await client.dirList(dirPath)) as {
|
||
kind: 'list';
|
||
path: string;
|
||
entries: Array<{
|
||
name: string;
|
||
kind: 'file' | 'directory' | 'symlink' | 'other';
|
||
ignored: boolean;
|
||
}>;
|
||
truncated: boolean;
|
||
};
|
||
if (options?.signal?.aborted) {
|
||
return { kind: 'list', path: dirPath, entries: [], truncated: false };
|
||
}
|
||
return result;
|
||
},
|
||
};
|
||
} else {
|
||
workspaceActionsRef.current = workspace?.actions;
|
||
}
|
||
const composerTagIconsRef = useRef(composerTagIcons);
|
||
composerTagIconsRef.current = composerTagIcons;
|
||
const parseUserMessageContentRef = useRef(parseUserMessageContent);
|
||
parseUserMessageContentRef.current = parseUserMessageContent;
|
||
const renderComposerTagRef = useRef(renderComposerTag);
|
||
renderComposerTagRef.current = renderComposerTag;
|
||
const renderComposerTagTooltipRef = useRef(renderComposerTagTooltip);
|
||
renderComposerTagTooltipRef.current = renderComposerTagTooltip;
|
||
const onComposerTagClickRef = useRef(onComposerTagClick);
|
||
onComposerTagClickRef.current = onComposerTagClick;
|
||
const resolveComposerTagIcon = useCallback(
|
||
(tag: WebShellComposerTag): InlineComposerTag => {
|
||
const iconUrl =
|
||
tag.icon ??
|
||
getComposerTagIconUrl(tag.kind, composerTagIconsRef.current);
|
||
const info = {
|
||
tag,
|
||
placement: 'composer' as const,
|
||
readonly: false,
|
||
};
|
||
let tooltip: ReactNode | null | undefined;
|
||
try {
|
||
tooltip = renderComposerTagTooltipRef.current?.(info);
|
||
} catch (error) {
|
||
console.warn('[WebShell] inline tag tooltip render failed', error);
|
||
}
|
||
const tooltipText =
|
||
typeof tooltip === 'string' || typeof tooltip === 'number'
|
||
? String(tooltip)
|
||
: undefined;
|
||
return {
|
||
...tag,
|
||
...(iconUrl ? { iconUrl } : {}),
|
||
...(renderComposerTagRef.current
|
||
? { renderContent: renderComposerTagRef.current }
|
||
: {}),
|
||
...(tooltip !== undefined && tooltip !== null ? { tooltip } : {}),
|
||
...(tooltipText ? { tooltipText } : {}),
|
||
...(onComposerTagClickRef.current
|
||
? { onClick: onComposerTagClickRef.current }
|
||
: {}),
|
||
};
|
||
},
|
||
[],
|
||
);
|
||
const [shellMode, setShellMode] = useState(false);
|
||
const shellModeRef = useRef(shellMode);
|
||
shellModeRef.current = shellMode;
|
||
const atMenu = useAtMentionMenu({
|
||
viewRef,
|
||
disabledRef,
|
||
shellModeRef,
|
||
workspaceActionsRef,
|
||
workspaceKey: atWorkspaceCwd,
|
||
builtinProviders: builtinAtProviders,
|
||
providers: atProviders,
|
||
createInlineTagEffect: (range) =>
|
||
addInlineTagEffect.of({
|
||
...range,
|
||
tag: resolveComposerTagIcon(range.tag),
|
||
}),
|
||
});
|
||
const closeAtMenuState = atMenu.close;
|
||
const refreshAtMenuForView = atMenu.refreshForView;
|
||
|
||
useEffect(() => {
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
const effects: StateEffect<unknown>[] = [clearInlineTagsEffect.of()];
|
||
view.state
|
||
.field(inlineComposerTagField)
|
||
.between(0, view.state.doc.length, (from, to, value) => {
|
||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||
if (!tag) return;
|
||
effects.push(
|
||
addInlineTagEffect.of({
|
||
from,
|
||
to,
|
||
tag: resolveComposerTagIcon(toPublicComposerTag(tag)),
|
||
}),
|
||
);
|
||
});
|
||
if (effects.length === 1) return;
|
||
view.dispatch({ effects });
|
||
}, [
|
||
composerTagIcons,
|
||
onComposerTagClick,
|
||
renderComposerTag,
|
||
renderComposerTagTooltip,
|
||
resolveComposerTagIcon,
|
||
]);
|
||
|
||
const toggleShellMode = useCallback(() => {
|
||
if (followupStateRef.current?.isVisible) {
|
||
onDismissFollowupRef.current?.();
|
||
}
|
||
setShellMode((value) => !value);
|
||
viewRef.current?.focus();
|
||
}, []);
|
||
const [searchMode, setSearchMode] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchMatches, setSearchMatches] = useState<string[]>([]);
|
||
const [searchActiveIndex, setSearchActiveIndex] = useState(0);
|
||
const searchModeRef = useRef(searchMode);
|
||
searchModeRef.current = searchMode;
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
const searchUiRef = useRef<HTMLDivElement>(null);
|
||
const searchDraftRef = useRef('');
|
||
const [pastedImages, setPastedImages] = useState<PromptImage[]>([]);
|
||
const pastedImagesRef = useRef<PromptImage[]>([]);
|
||
const pendingPastesRef = useRef<Map<string, string>>(new Map());
|
||
const nextPasteIdRef = useRef(1);
|
||
const [composerTags, setComposerTags] = useState<WebShellComposerTag[]>([]);
|
||
const composerTagsRef = useRef<WebShellComposerTag[]>([]);
|
||
composerTagsRef.current = composerTags;
|
||
const [hasInlineTags, setHasInlineTags] = useState(false);
|
||
const hasInlineTagsRef = useRef(false);
|
||
const historyDraftComposerTagsRef = useRef<WebShellComposerTag[] | null>(
|
||
null,
|
||
);
|
||
const rememberPromptHistoryDraftTags = useCallback(() => {
|
||
if (historyDraftComposerTagsRef.current !== null) return;
|
||
historyDraftComposerTagsRef.current = [...composerTagsRef.current];
|
||
}, []);
|
||
const restorePromptHistoryDraftTags = useCallback(() => {
|
||
const tags = historyDraftComposerTagsRef.current;
|
||
if (tags === null) return;
|
||
historyDraftComposerTagsRef.current = null;
|
||
const restoredTags = [...tags];
|
||
composerTagsRef.current = restoredTags;
|
||
setComposerTags(restoredTags);
|
||
}, []);
|
||
const clearPromptHistoryDraftTags = useCallback(() => {
|
||
historyDraftComposerTagsRef.current = null;
|
||
}, []);
|
||
const clearRestoredComposerTags = useCallback(() => {
|
||
composerTagsRef.current = [];
|
||
setComposerTags([]);
|
||
}, []);
|
||
const restoreRawHistoryEntry = useCallback(
|
||
(view: EditorView, text: string) => {
|
||
clearRestoredComposerTags();
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: text },
|
||
effects: clearInlineTagsEffect.of(),
|
||
selection: { anchor: text.length },
|
||
});
|
||
},
|
||
[clearRestoredComposerTags],
|
||
);
|
||
const restorePromptHistoryEntry = useCallback(
|
||
(view: EditorView, text: string) => {
|
||
const parts = parseUserMessageContentSafely(
|
||
text,
|
||
parseUserMessageContentRef.current,
|
||
'[WebShell] failed to parse composer history content',
|
||
{ requireSourcePreservation: true },
|
||
);
|
||
const hasTags = parts?.some((part) => part.type === 'tag') ?? false;
|
||
if (!parts || !hasTags) {
|
||
restoreRawHistoryEntry(view, text);
|
||
return;
|
||
}
|
||
|
||
const effects: StateEffect<unknown>[] = [clearInlineTagsEffect.of()];
|
||
let restoredText = '';
|
||
for (const part of parts) {
|
||
if (part.type === 'text') {
|
||
restoredText += part.text;
|
||
continue;
|
||
}
|
||
const serialized = getComposerTagSerialized(part.tag);
|
||
const from = restoredText.length;
|
||
restoredText += serialized;
|
||
effects.push(
|
||
addInlineTagEffect.of({
|
||
from,
|
||
to: restoredText.length,
|
||
tag: resolveComposerTagIcon(part.tag),
|
||
}),
|
||
);
|
||
}
|
||
clearRestoredComposerTags();
|
||
view.dispatch({
|
||
changes: {
|
||
from: 0,
|
||
to: view.state.doc.length,
|
||
insert: restoredText,
|
||
},
|
||
effects,
|
||
selection: { anchor: restoredText.length },
|
||
});
|
||
},
|
||
[clearRestoredComposerTags, resolveComposerTagIcon, restoreRawHistoryEntry],
|
||
);
|
||
const restoreHistoryEntry = useCallback(
|
||
(view: EditorView, text: string) => {
|
||
if (shellModeRef.current) {
|
||
restoreRawHistoryEntry(view, text);
|
||
return;
|
||
}
|
||
restorePromptHistoryEntry(view, text);
|
||
},
|
||
[restorePromptHistoryEntry, restoreRawHistoryEntry],
|
||
);
|
||
const restoreSelectedHistoryMatch = useCallback(
|
||
(text: string) => {
|
||
const view = viewRef.current;
|
||
if (!view) {
|
||
if (isTouchComposer) {
|
||
// Plain-text restore: inline tag chips are not recreated on the
|
||
// textarea backend.
|
||
setMobileText(text);
|
||
}
|
||
return;
|
||
}
|
||
restoreHistoryEntry(view, text);
|
||
},
|
||
[isTouchComposer, restoreHistoryEntry, setMobileText],
|
||
);
|
||
const composerInputRef = useRef(composerInput);
|
||
composerInputRef.current = composerInput;
|
||
const submitTextRef = useRef<
|
||
(
|
||
view: EditorView | null,
|
||
textOverride?: string,
|
||
tagsOverride?: readonly WebShellComposerTag[],
|
||
suppressFollowupCompletion?: boolean,
|
||
) => boolean
|
||
>(() => true);
|
||
const autoTriggerRef = useRef<{ text: string; from: number } | null>(null);
|
||
const [slashMenu, setSlashMenuState] = useState<SlashMenuState | null>(null);
|
||
const slashMenuRef = useRef<SlashMenuState | null>(null);
|
||
|
||
// True while the user is paging through input history with the arrow keys
|
||
// and has not typed since. Unlike history.isNavigating() (which stays set
|
||
// until submit), this resets the moment the user edits the text, so a
|
||
// recalled slash command like "/theme" keeps the slash menu closed while a
|
||
// freshly typed "/" lets arrows drive the menu. See the ArrowUp/ArrowDown
|
||
// keymap handlers.
|
||
const historyBrowseActiveRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
let draftSaveTimer: number | undefined;
|
||
let draftSaveDeadline = 0;
|
||
let draftDirty = false;
|
||
const clearDraftSaveTimer = () => {
|
||
if (draftSaveTimer !== undefined) {
|
||
window.clearTimeout(draftSaveTimer);
|
||
draftSaveTimer = undefined;
|
||
}
|
||
};
|
||
const saveCurrentDraft = (): boolean => {
|
||
if (historyBrowseActiveRef.current || searchModeRef.current) {
|
||
return false;
|
||
}
|
||
const currentView = viewRef.current;
|
||
const text = currentView
|
||
? expandLargePastePlaceholders(
|
||
pendingPastesRef.current,
|
||
currentView.state.doc.toString(),
|
||
)
|
||
: mobileTextRef.current;
|
||
saveComposerDraft(draftIdentityRef.current.storageKey, text);
|
||
return true;
|
||
};
|
||
const flushCurrentDraft = () => {
|
||
clearDraftSaveTimer();
|
||
draftSaveDeadline = 0;
|
||
if (draftDirty && saveCurrentDraft()) {
|
||
draftDirty = false;
|
||
}
|
||
};
|
||
const flushDraftAfterIdle = () => {
|
||
const remaining = draftSaveDeadline - Date.now();
|
||
if (remaining > 0) {
|
||
draftSaveTimer = window.setTimeout(flushDraftAfterIdle, remaining);
|
||
return;
|
||
}
|
||
draftSaveTimer = undefined;
|
||
flushCurrentDraft();
|
||
};
|
||
const scheduleDraftSave = () => {
|
||
draftDirty = true;
|
||
draftSaveDeadline = Date.now() + COMPOSER_DRAFT_SAVE_DELAY_MS;
|
||
if (draftSaveTimer === undefined) {
|
||
draftSaveTimer = window.setTimeout(
|
||
flushDraftAfterIdle,
|
||
COMPOSER_DRAFT_SAVE_DELAY_MS,
|
||
);
|
||
}
|
||
};
|
||
saveCurrentDraftRef.current = flushCurrentDraft;
|
||
scheduleDraftSaveRef.current = scheduleDraftSave;
|
||
const handleVisibilityChange = () => {
|
||
if (document.visibilityState === 'hidden') flushCurrentDraft();
|
||
};
|
||
const handlePageHide = () => flushCurrentDraft();
|
||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||
window.addEventListener('pagehide', handlePageHide);
|
||
|
||
return () => {
|
||
window.removeEventListener('pagehide', handlePageHide);
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||
flushCurrentDraft();
|
||
saveCurrentDraftRef.current = () => undefined;
|
||
scheduleDraftSaveRef.current = () => undefined;
|
||
};
|
||
}, []);
|
||
|
||
const setSlashMenu = useCallback((next: SlashMenuState | null) => {
|
||
if (shallowEqualSlashMenu(slashMenuRef.current, next)) return;
|
||
slashMenuRef.current = next;
|
||
setSlashMenuState(next);
|
||
}, []);
|
||
|
||
const clearAutoAtTriggerIfIntact = useCallback(() => {
|
||
const trigger = autoTriggerRef.current;
|
||
const view = viewRef.current;
|
||
if (!trigger || !view) return;
|
||
const doc = view.state.doc;
|
||
const to = trigger.from + trigger.text.length;
|
||
if (
|
||
doc.length === to &&
|
||
doc.sliceString(trigger.from, to) === trigger.text
|
||
) {
|
||
view.dispatch({
|
||
changes: {
|
||
from: trigger.from,
|
||
to,
|
||
insert: '',
|
||
},
|
||
});
|
||
}
|
||
autoTriggerRef.current = null;
|
||
}, []);
|
||
|
||
const closeAtMenu = useCallback(() => {
|
||
clearAutoAtTriggerIfIntact();
|
||
closeAtMenuState();
|
||
}, [clearAutoAtTriggerIfIntact, closeAtMenuState]);
|
||
|
||
const closeAtMenuIfOpenFn = atMenu.closeIfOpen;
|
||
const closeAtMenuIfOpen = useCallback(() => {
|
||
const result = closeAtMenuIfOpenFn();
|
||
if (!result) return false;
|
||
if (result === 'closed') {
|
||
clearAutoAtTriggerIfIntact();
|
||
}
|
||
return true;
|
||
}, [clearAutoAtTriggerIfIntact, closeAtMenuIfOpenFn]);
|
||
|
||
const refreshSlashMenuForView = useCallback(
|
||
(view: EditorView | null, preferredIndex?: number) => {
|
||
if (!view || disabledRef.current || shellModeRef.current) {
|
||
setSlashMenu(null);
|
||
return;
|
||
}
|
||
// While browsing history, a recalled slash command (e.g. "/theme")
|
||
// should not pop its argument menu — the user is browsing, not composing.
|
||
// Editing the line re-arms it (historyBrowseActiveRef clears on edit).
|
||
if (historyBrowseActiveRef.current) {
|
||
setSlashMenu(null);
|
||
return;
|
||
}
|
||
const selection = view.state.selection.main;
|
||
if (!selection.empty) {
|
||
setSlashMenu(null);
|
||
return;
|
||
}
|
||
const line = view.state.doc.lineAt(selection.head);
|
||
if (!line.text.startsWith('/')) {
|
||
setSlashMenu(null);
|
||
return;
|
||
}
|
||
const relativeResult = getSlashCommandCompletionResult(
|
||
line.text,
|
||
selection.head - line.from,
|
||
commandsRef.current,
|
||
skillsRef.current,
|
||
languageRef.current,
|
||
(key) => tRef.current(key),
|
||
slashCommandCategoryOrderRef.current ?? DEFAULT_COMMAND_CATEGORY_ORDER,
|
||
);
|
||
if (!relativeResult) {
|
||
setSlashMenu(null);
|
||
return;
|
||
}
|
||
const result = {
|
||
...relativeResult,
|
||
from: line.from + relativeResult.from,
|
||
to: line.from + relativeResult.to,
|
||
};
|
||
closeAtMenu();
|
||
const currentIndex =
|
||
preferredIndex ?? slashMenuRef.current?.selectedIndex ?? 0;
|
||
const selectedIndex = Math.max(
|
||
0,
|
||
Math.min(currentIndex, result.items.length - 1),
|
||
);
|
||
setSlashMenu({ ...result, selectedIndex });
|
||
},
|
||
[closeAtMenu, setSlashMenu],
|
||
);
|
||
|
||
const closeSlashMenu = useCallback(() => {
|
||
setSlashMenu(null);
|
||
}, [setSlashMenu]);
|
||
|
||
const selectSlashCompletion = useCallback(
|
||
(index: number) => {
|
||
const current = slashMenuRef.current;
|
||
if (!current || index < 0 || index >= current.items.length) {
|
||
return false;
|
||
}
|
||
if (current.selectedIndex === index) return true;
|
||
setSlashMenu({ ...current, selectedIndex: index });
|
||
return true;
|
||
},
|
||
[setSlashMenu],
|
||
);
|
||
|
||
const moveSlashCompletionSelection = useCallback(
|
||
(direction: 'up' | 'down') => {
|
||
const current = slashMenuRef.current;
|
||
if (!current) return false;
|
||
const nextIndex = nextSlashSelectionIndex(
|
||
current.selectedIndex,
|
||
current.items.length,
|
||
direction,
|
||
);
|
||
if (nextIndex === null) return false;
|
||
setSlashMenu({ ...current, selectedIndex: nextIndex });
|
||
return true;
|
||
},
|
||
[setSlashMenu],
|
||
);
|
||
|
||
const acceptSlashCompletion = useCallback((index?: number) => {
|
||
const view = viewRef.current;
|
||
const current = slashMenuRef.current;
|
||
if (!view || !current) return false;
|
||
const item = current.items[index ?? current.selectedIndex];
|
||
if (!item) return false;
|
||
view.dispatch({
|
||
changes: { from: current.from, to: current.to, insert: item.apply },
|
||
selection: { anchor: current.from + item.apply.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
return true;
|
||
}, []);
|
||
|
||
// Track whether editor has content for send button state
|
||
const [hasContent, setHasContent] = useState(false);
|
||
const hasContentRef = useRef(false);
|
||
const updateHasContent = useCallback((next: boolean) => {
|
||
if (hasContentRef.current === next) return;
|
||
hasContentRef.current = next;
|
||
setHasContent(next);
|
||
}, []);
|
||
|
||
// Update hasContent when tags or images change
|
||
useEffect(() => {
|
||
const view = viewRef.current;
|
||
const text = view ? view.state.doc.toString() : mobileText;
|
||
const followupCompletion = getFollowupCompletion(
|
||
text,
|
||
followupState?.isVisible ? followupState.suggestion : null,
|
||
);
|
||
updateHasContent(
|
||
text.trim().length > 0 ||
|
||
!!followupCompletion ||
|
||
composerTags.length > 0 ||
|
||
pastedImages.length > 0,
|
||
);
|
||
}, [
|
||
composerTags,
|
||
pastedImages,
|
||
mobileText,
|
||
followupState?.isVisible,
|
||
followupState?.suggestion,
|
||
updateHasContent,
|
||
]);
|
||
|
||
const promptHistory = useInputHistory(
|
||
promptHistoryStorageKey,
|
||
promptHistoryFallbackStorageKey,
|
||
);
|
||
const shellHistory = useInputHistory('qwen-web-shell-command-history');
|
||
|
||
const {
|
||
push,
|
||
navigateUp,
|
||
navigateDown,
|
||
isNavigating,
|
||
reset,
|
||
getReverseMatches,
|
||
getLastEntry,
|
||
resetSearch,
|
||
} = promptHistory;
|
||
const historyActionsRef = useRef({
|
||
push,
|
||
navigateUp,
|
||
navigateDown,
|
||
isNavigating,
|
||
reset,
|
||
getReverseMatches,
|
||
getLastEntry,
|
||
resetSearch,
|
||
});
|
||
historyActionsRef.current = {
|
||
push,
|
||
navigateUp,
|
||
navigateDown,
|
||
isNavigating,
|
||
reset,
|
||
getReverseMatches,
|
||
getLastEntry,
|
||
resetSearch,
|
||
};
|
||
const shellHistoryActionsRef = useRef(shellHistory);
|
||
shellHistoryActionsRef.current = shellHistory;
|
||
pastedImagesRef.current = pastedImages;
|
||
|
||
const getSearchMatches = useCallback((query: string) => {
|
||
const isShellMode = shellModeRef.current;
|
||
const history = isShellMode
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
const matches = history.getReverseMatches(query);
|
||
return isShellMode
|
||
? matches
|
||
: matches.filter((item) => !item.trimStart().startsWith('/'));
|
||
}, []);
|
||
|
||
const openHistorySearch = useCallback(() => {
|
||
if (disabledRef.current) return;
|
||
const view = viewRef.current;
|
||
if (!view && !isTouchComposer) return;
|
||
saveCurrentDraftRef.current();
|
||
closeSlashMenu();
|
||
closeAtMenu();
|
||
const query = view ? view.state.doc.toString() : mobileTextRef.current;
|
||
searchDraftRef.current = query;
|
||
setSearchMode(true);
|
||
setSearchQuery('');
|
||
const history = shellModeRef.current
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
setSearchMatches(getSearchMatches(''));
|
||
setSearchActiveIndex(0);
|
||
history.resetSearch();
|
||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||
}, [closeAtMenu, closeSlashMenu, getSearchMatches, isTouchComposer]);
|
||
const openHistorySearchRef = useRef(openHistorySearch);
|
||
openHistorySearchRef.current = openHistorySearch;
|
||
|
||
const navigatePrevHistory = useCallback(() => {
|
||
if (disabledRef.current) return;
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
if (completionStatus(view.state) === 'active') {
|
||
moveCompletionSelection(false)(view);
|
||
view.focus();
|
||
return;
|
||
}
|
||
if (view.state.doc.lines > 1) {
|
||
view.focus();
|
||
return;
|
||
}
|
||
const history = shellModeRef.current
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
const current = view.state.doc.toString();
|
||
if (!history.isNavigating()) {
|
||
saveCurrentDraftRef.current();
|
||
}
|
||
const prev = history.navigateUp(current);
|
||
if (prev !== null) {
|
||
historyBrowseActiveRef.current = true;
|
||
if (!shellModeRef.current) {
|
||
rememberPromptHistoryDraftTags();
|
||
}
|
||
restoreHistoryEntry(view, prev);
|
||
}
|
||
view.focus();
|
||
}, [rememberPromptHistoryDraftTags, restoreHistoryEntry]);
|
||
|
||
const navigateNextHistory = useCallback(() => {
|
||
if (disabledRef.current) return;
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
if (completionStatus(view.state) === 'active') {
|
||
moveCompletionSelection(true)(view);
|
||
view.focus();
|
||
return;
|
||
}
|
||
if (view.state.doc.lines > 1) {
|
||
view.focus();
|
||
return;
|
||
}
|
||
const history = shellModeRef.current
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
const next = history.navigateDown();
|
||
if (next !== null) {
|
||
const returningToPromptDraft =
|
||
!shellModeRef.current && !history.isNavigating();
|
||
historyBrowseActiveRef.current = history.isNavigating();
|
||
restoreHistoryEntry(view, next);
|
||
if (returningToPromptDraft) {
|
||
restorePromptHistoryDraftTags();
|
||
}
|
||
}
|
||
view.focus();
|
||
}, [restoreHistoryEntry, restorePromptHistoryDraftTags]);
|
||
|
||
const handleMobileChange = useCallback(
|
||
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
setMobileText(event.target.value);
|
||
},
|
||
[setMobileText],
|
||
);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!isTouchComposer) return;
|
||
const el = mobileTextareaRef.current;
|
||
if (!el) return;
|
||
const cap = parseFloat(getComputedStyle(el).maxHeight);
|
||
mobileMaxHeightRef.current =
|
||
Number.isFinite(cap) && cap > 0 ? cap : Number.POSITIVE_INFINITY;
|
||
}, [isTouchComposer]);
|
||
|
||
// Auto-grow the mobile textarea with its content, capped by the CSS
|
||
// max-height (the cap is read from the computed style so a deployment
|
||
// overriding --chat-editor-input-max-height stays authoritative). Without
|
||
// this the rows={1} textarea would show ~1.5 lines with inner scrolling.
|
||
useEffect(() => {
|
||
if (!isTouchComposer) return;
|
||
const el = mobileTextareaRef.current;
|
||
if (!el) return;
|
||
if (
|
||
typeof CSS !== 'undefined' &&
|
||
CSS.supports?.('field-sizing', 'content')
|
||
) {
|
||
el.style.height = '';
|
||
return;
|
||
}
|
||
el.style.height = 'auto';
|
||
if (el.scrollHeight > 0) {
|
||
const cap = mobileMaxHeightRef.current ?? Number.POSITIVE_INFINITY;
|
||
const next = Math.min(el.scrollHeight, cap);
|
||
el.style.height = `${next}px`;
|
||
}
|
||
}, [isTouchComposer, mobileText]);
|
||
|
||
const handleMobilePaste = useCallback(
|
||
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const items = event.clipboardData?.items;
|
||
if (!items) return;
|
||
const hasImage = collectClipboardImages(items, (image) =>
|
||
setPastedImages((prev) => [...prev, image]),
|
||
);
|
||
// Plain text falls through to the native paste.
|
||
if (hasImage) {
|
||
event.preventDefault();
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// Lives in the render scope (not the editor-creation effect) so the mobile
|
||
// textarea backend, which never instantiates an EditorView, can reuse the
|
||
// exact same submit pipeline with `view === null`.
|
||
const submitComposerText = (
|
||
view: EditorView | null,
|
||
textOverride?: string,
|
||
tagsOverride?: readonly WebShellComposerTag[],
|
||
suppressFollowupCompletion = false,
|
||
) => {
|
||
const inlineTags =
|
||
tagsOverride === undefined && view
|
||
? getInlineComposerTagPlacements(view)
|
||
: [];
|
||
const editorText = view ? view.state.doc.toString() : mobileTextRef.current;
|
||
const followup = followupStateRef.current;
|
||
const followupCompletion =
|
||
textOverride === undefined &&
|
||
!suppressFollowupCompletion &&
|
||
inlineTags.length === 0 &&
|
||
followup?.isVisible
|
||
? getFollowupCompletion(editorText, followup.suggestion)
|
||
: null;
|
||
const sourceText = textOverride ?? followupCompletion ?? editorText;
|
||
const leadingTrimLength = sourceText.length - sourceText.trimStart().length;
|
||
const rawText = sourceText.trim();
|
||
const normalizedInlineTags =
|
||
textOverride === undefined && followupCompletion === null
|
||
? inlineTags
|
||
.map((placement) => ({
|
||
...placement,
|
||
start: placement.start - leadingTrimLength,
|
||
end: placement.end - leadingTrimLength,
|
||
}))
|
||
.filter((placement) => placement.end > 0)
|
||
.map((placement) => ({
|
||
...placement,
|
||
start: Math.max(0, placement.start),
|
||
}))
|
||
: [];
|
||
const tags = tagsOverride ?? composerTagsRef.current;
|
||
if (!rawText && tags.length === 0 && inlineTags.length === 0) return true;
|
||
const textWithInlineTags =
|
||
tagsOverride === undefined
|
||
? replaceInlineTagPlacements(rawText, normalizedInlineTags)
|
||
: rawText;
|
||
const text = expandLargePastePlaceholders(
|
||
pendingPastesRef.current,
|
||
textWithInlineTags,
|
||
);
|
||
const prompt = buildComposerPrompt(text, tags);
|
||
const images = pastedImagesRef.current;
|
||
const isShellMode = shellModeRef.current;
|
||
const promptText = isShellMode ? `!${prompt}` : prompt;
|
||
const inputAnnotations = createInputAnnotationsFromComposerTags(
|
||
promptText,
|
||
[...tags, ...normalizedInlineTags.map((placement) => placement.tag)],
|
||
);
|
||
const submissionIdentity = { ...composerIdentityRef.current };
|
||
const draftTextAtSubmit = expandLargePastePlaceholders(
|
||
pendingPastesRef.current,
|
||
editorText,
|
||
);
|
||
const editorDocAtSubmit = view?.state.doc;
|
||
const mobileTextVersionAtSubmit = mobileTextVersionRef.current;
|
||
const composerTagsAtSubmit = composerTagsRef.current;
|
||
const pastedImagesAtSubmit = pastedImagesRef.current;
|
||
const shellModeAtSubmit = shellModeRef.current;
|
||
let committed = false;
|
||
const commitAccepted = () => {
|
||
if (committed) return;
|
||
committed = true;
|
||
const currentIdentity = composerIdentityRef.current;
|
||
const sourceChanged =
|
||
viewRef.current !== view ||
|
||
currentIdentity.sessionId !== submissionIdentity.sessionId ||
|
||
currentIdentity.promptHistoryStorageKey !==
|
||
submissionIdentity.promptHistoryStorageKey;
|
||
if (sourceChanged) {
|
||
if (isShellMode) {
|
||
shellHistoryActionsRef.current.push(text);
|
||
} else if (
|
||
currentIdentity.promptHistoryStorageKey ===
|
||
submissionIdentity.promptHistoryStorageKey
|
||
) {
|
||
historyActionsRef.current.push(text);
|
||
} else {
|
||
pushInputHistoryEntry(
|
||
submissionIdentity.promptHistoryStorageKey,
|
||
text,
|
||
submissionIdentity.promptHistoryFallbackStorageKey,
|
||
);
|
||
}
|
||
clearComposerDraftIfMatches(
|
||
submissionIdentity.draftStorageKey,
|
||
draftTextAtSubmit,
|
||
);
|
||
return;
|
||
}
|
||
onDismissFollowupRef.current?.();
|
||
if (isShellMode) {
|
||
shellHistoryActionsRef.current.push(text);
|
||
shellHistoryActionsRef.current.reset();
|
||
} else {
|
||
historyActionsRef.current.push(text);
|
||
historyActionsRef.current.reset();
|
||
}
|
||
const composerUnchanged =
|
||
(view
|
||
? view.state.doc === editorDocAtSubmit
|
||
: mobileTextVersionRef.current === mobileTextVersionAtSubmit) &&
|
||
composerTagsRef.current === composerTagsAtSubmit &&
|
||
pastedImagesRef.current === pastedImagesAtSubmit &&
|
||
shellModeRef.current === shellModeAtSubmit;
|
||
historyBrowseActiveRef.current = false;
|
||
if (!composerUnchanged) return;
|
||
|
||
saveComposerDraft(submissionIdentity.draftStorageKey, '');
|
||
setSlashMenu(null);
|
||
if (followupCompletion) {
|
||
onAcceptFollowupRef.current?.('enter', { skipOnAccept: true });
|
||
}
|
||
onDismissFollowupRef.current?.();
|
||
pendingPastesRef.current.clear();
|
||
nextPasteIdRef.current = 1;
|
||
clearPromptHistoryDraftTags();
|
||
setComposerTags([]);
|
||
setPastedImages([]);
|
||
if (view) {
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: '' },
|
||
effects: clearInlineTagsEffect.of(),
|
||
});
|
||
} else {
|
||
setMobileText('');
|
||
}
|
||
};
|
||
const accepted = onSubmitRef.current(
|
||
promptText,
|
||
images.length > 0 ? [...images] : undefined,
|
||
commitAccepted,
|
||
inputAnnotations.length > 0 ? { inputAnnotations } : undefined,
|
||
);
|
||
if (accepted === false) return true;
|
||
commitAccepted();
|
||
return true;
|
||
};
|
||
submitTextRef.current = submitComposerText;
|
||
|
||
// ---- Create CodeMirror EditorView ----
|
||
useEffect(() => {
|
||
if (!containerRef.current) return;
|
||
|
||
const tooltipParent = portalRoot ?? document.body;
|
||
ensureTooltipStyles(getTooltipStyleRoot(tooltipParent));
|
||
const tooltipPortal = document.createElement('div');
|
||
tooltipPortalRef.current = tooltipPortal;
|
||
tooltipPortal.setAttribute('data-web-shell-tooltip-portal', '');
|
||
tooltipPortal.style.position = 'fixed';
|
||
tooltipPortal.style.inset = '0';
|
||
tooltipPortal.style.zIndex = 'var(--web-shell-tooltip-z-index)';
|
||
tooltipPortal.style.pointerEvents = 'none';
|
||
const THEME_RE = /\b\S*theme(?:Dark|Light)\S*/gi;
|
||
const syncTheme = () => {
|
||
let el: Element | null = containerRef.current;
|
||
let themeClass: string | null = null;
|
||
if (containerRef.current) {
|
||
const computedStyle = getComputedStyle(containerRef.current);
|
||
for (let i = 0; i < computedStyle.length; i += 1) {
|
||
const name = computedStyle[i];
|
||
if (name.startsWith('--')) {
|
||
tooltipPortal.style.setProperty(
|
||
name,
|
||
computedStyle.getPropertyValue(name),
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
!computedStyle.getPropertyValue('--web-shell-tooltip-z-index').trim()
|
||
) {
|
||
tooltipPortal.style.setProperty(
|
||
'--web-shell-tooltip-z-index',
|
||
'1000',
|
||
);
|
||
}
|
||
}
|
||
while (el) {
|
||
const match = el.className?.match?.(THEME_RE);
|
||
if (match) {
|
||
themeClass = match[0];
|
||
break;
|
||
}
|
||
el = el.parentElement;
|
||
}
|
||
if (themeClass) {
|
||
tooltipPortal.className = themeClass;
|
||
}
|
||
};
|
||
syncTheme();
|
||
tooltipParent.appendChild(tooltipPortal);
|
||
|
||
const observer = new MutationObserver(syncTheme);
|
||
let el: Element | null = containerRef.current;
|
||
while (el) {
|
||
observer.observe(el, {
|
||
attributes: true,
|
||
attributeFilter: ['class', 'style'],
|
||
});
|
||
if (el.className?.match?.(THEME_RE)) break;
|
||
el = el.parentElement;
|
||
}
|
||
|
||
const submitText = (
|
||
view: EditorView,
|
||
textOverride?: string,
|
||
tagsOverride?: readonly WebShellComposerTag[],
|
||
suppressFollowupCompletion = false,
|
||
) =>
|
||
submitTextRef.current(
|
||
view,
|
||
textOverride,
|
||
tagsOverride,
|
||
suppressFollowupCompletion,
|
||
);
|
||
|
||
const insertNewline = (view: EditorView) => {
|
||
view.dispatch(view.state.replaceSelection('\n'));
|
||
return true;
|
||
};
|
||
|
||
const acceptFollowupIntoEditor = (
|
||
view: EditorView,
|
||
method: 'tab' | 'right',
|
||
): boolean => {
|
||
const followup = followupStateRef.current;
|
||
const suggestion = followup?.suggestion;
|
||
const completion = getFollowupCompletion(
|
||
view.state.doc.toString(),
|
||
suggestion,
|
||
);
|
||
if (!followup?.isVisible || !completion) {
|
||
return false;
|
||
}
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: completion },
|
||
selection: { anchor: completion.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
onAcceptFollowupRef.current?.(method, { skipOnAccept: true });
|
||
return true;
|
||
};
|
||
|
||
const submitKeymap = keymap.of([
|
||
{
|
||
key: 'Backspace',
|
||
run: (view) => {
|
||
const selection = view.state.selection.main;
|
||
if (!selection.empty || selection.from !== 0) return false;
|
||
let hasInlineTagAtStart = false;
|
||
view.state.field(inlineComposerTagField).between(0, 1, (from) => {
|
||
if (from === 0) hasInlineTagAtStart = true;
|
||
});
|
||
if (hasInlineTagAtStart) return false;
|
||
let removableIndex = -1;
|
||
for (let i = composerTagsRef.current.length - 1; i >= 0; i -= 1) {
|
||
if (composerTagsRef.current[i]?.removable !== false) {
|
||
removableIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
if (removableIndex < 0) return false;
|
||
setComposerTags((current) =>
|
||
current.filter((_, index) => index !== removableIndex),
|
||
);
|
||
return true;
|
||
},
|
||
},
|
||
{
|
||
key: 'Delete',
|
||
run: (view) => {
|
||
const selection = view.state.selection.main;
|
||
if (!selection.empty || selection.from !== 0) return false;
|
||
let hasInlineTagAtStart = false;
|
||
view.state.field(inlineComposerTagField).between(0, 1, (from) => {
|
||
if (from === 0) hasInlineTagAtStart = true;
|
||
});
|
||
if (hasInlineTagAtStart) return false;
|
||
const removableIndex = composerTagsRef.current.findIndex(
|
||
(tag) => tag.removable !== false,
|
||
);
|
||
if (removableIndex < 0) return false;
|
||
setComposerTags((current) =>
|
||
current.filter((_, index) => index !== removableIndex),
|
||
);
|
||
return true;
|
||
},
|
||
},
|
||
{
|
||
key: 'Enter',
|
||
run: (view) => {
|
||
if (atMenu.accept()) {
|
||
return true;
|
||
}
|
||
if (slashMenuRef.current) {
|
||
return acceptSlashCompletion();
|
||
}
|
||
if (completionStatus(view.state) === 'active') return false;
|
||
const followup = followupStateRef.current;
|
||
const hasInlineTags = hasInlineComposerTags(view);
|
||
const followupCompletion = hasInlineTags
|
||
? null
|
||
: getFollowupCompletion(
|
||
view.state.doc.toString(),
|
||
followup?.suggestion,
|
||
);
|
||
if (followup?.isVisible && followupCompletion) {
|
||
onAcceptFollowupRef.current?.('enter', { skipOnAccept: true });
|
||
return submitText(view, followupCompletion);
|
||
}
|
||
return submitText(view);
|
||
},
|
||
},
|
||
{
|
||
key: 'Shift-Enter',
|
||
run: insertNewline,
|
||
},
|
||
{
|
||
key: 'Ctrl-j',
|
||
run: insertNewline,
|
||
},
|
||
{
|
||
key: 'Mod-Enter',
|
||
run: insertNewline,
|
||
},
|
||
{
|
||
key: 'Alt-Enter',
|
||
run: insertNewline,
|
||
},
|
||
{
|
||
key: 'Escape',
|
||
run: () => {
|
||
if (closeAtMenuIfOpen()) {
|
||
return true;
|
||
}
|
||
if (slashMenuRef.current) {
|
||
closeSlashMenu();
|
||
return true;
|
||
}
|
||
if (shellModeRef.current) {
|
||
setShellMode(false);
|
||
return true;
|
||
}
|
||
// Don't clear the queue on Escape — let it fall through to the
|
||
// window handler, where Escape cancels the in-flight turn (queued
|
||
// prompts are preserved and drain once it settles).
|
||
return false;
|
||
},
|
||
},
|
||
{
|
||
key: 'Ctrl-o',
|
||
run: () => true,
|
||
},
|
||
{
|
||
key: 'Ctrl-l',
|
||
run: () => true,
|
||
},
|
||
{
|
||
key: 'Ctrl-y',
|
||
run: () => true,
|
||
},
|
||
{
|
||
key: 'ArrowUp',
|
||
run: (view) => {
|
||
const history = shellModeRef.current
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
const isBrowsingHistory = historyBrowseActiveRef.current;
|
||
// Not browsing history → arrows drive the slash menu / native
|
||
// completion. While browsing → arrows keep walking history and any
|
||
// auto-opened menu is closed. (Gate uses historyBrowseActiveRef, not
|
||
// the sticky history.isNavigating — see its declaration.)
|
||
if (!isBrowsingHistory) {
|
||
if (atMenu.moveSelection('up')) return true;
|
||
if (moveSlashCompletionSelection('up')) return true;
|
||
if (completionStatus(view.state) === 'active') {
|
||
return moveCompletionSelection(false)(view);
|
||
}
|
||
} else {
|
||
closeCompletion(view);
|
||
closeSlashMenu();
|
||
closeAtMenu();
|
||
}
|
||
const multilineBoundary = handleMultilineHistoryBoundary(view, 'up');
|
||
if (multilineBoundary === 'handled') return true;
|
||
if (multilineBoundary === 'editor') return false;
|
||
if (shellModeRef.current) {
|
||
const current = view.state.doc.toString();
|
||
if (!isBrowsingHistory) saveCurrentDraftRef.current();
|
||
const prev = history.navigateUp(current);
|
||
if (prev === null) return true;
|
||
historyBrowseActiveRef.current = true;
|
||
restoreHistoryEntry(view, prev);
|
||
return true;
|
||
}
|
||
if (queuedMessagesRef.current.length > 0) {
|
||
if (onPopQueuedMessagesRef.current?.()) {
|
||
return true;
|
||
}
|
||
}
|
||
const current = view.state.doc.toString();
|
||
if (!isBrowsingHistory) {
|
||
saveCurrentDraftRef.current();
|
||
}
|
||
const prev = history.navigateUp(current);
|
||
if (prev === null) return false;
|
||
rememberPromptHistoryDraftTags();
|
||
historyBrowseActiveRef.current = true;
|
||
restoreHistoryEntry(view, prev);
|
||
return true;
|
||
},
|
||
},
|
||
{
|
||
key: 'ArrowDown',
|
||
run: (view) => {
|
||
const history = shellModeRef.current
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
const isBrowsingHistory = historyBrowseActiveRef.current;
|
||
// Symmetric with ArrowUp: history navigation wins while browsing;
|
||
// the slash menu and native completion only capture arrows once the
|
||
// user is no longer paging through history.
|
||
if (!isBrowsingHistory) {
|
||
if (atMenu.moveSelection('down')) return true;
|
||
if (moveSlashCompletionSelection('down')) return true;
|
||
if (completionStatus(view.state) === 'active') {
|
||
return moveCompletionSelection(true)(view);
|
||
}
|
||
} else {
|
||
closeCompletion(view);
|
||
closeSlashMenu();
|
||
closeAtMenu();
|
||
}
|
||
const multilineBoundary = handleMultilineHistoryBoundary(
|
||
view,
|
||
'down',
|
||
);
|
||
if (multilineBoundary === 'handled') return true;
|
||
if (multilineBoundary === 'editor') return false;
|
||
if (shellModeRef.current) {
|
||
const next = history.navigateDown();
|
||
if (next === null) return true;
|
||
historyBrowseActiveRef.current = history.isNavigating();
|
||
restoreHistoryEntry(view, next);
|
||
return true;
|
||
}
|
||
const next = history.navigateDown();
|
||
if (next === null) {
|
||
return onFocusFooterRef.current?.() ?? false;
|
||
}
|
||
const returningToPromptDraft = !history.isNavigating();
|
||
historyBrowseActiveRef.current = !returningToPromptDraft;
|
||
restoreHistoryEntry(view, next);
|
||
if (returningToPromptDraft) {
|
||
restorePromptHistoryDraftTags();
|
||
}
|
||
return true;
|
||
},
|
||
},
|
||
{
|
||
key: 'Ctrl-r',
|
||
run: () => {
|
||
openHistorySearchRef.current();
|
||
return true;
|
||
},
|
||
},
|
||
{
|
||
key: 'Tab',
|
||
run: (view) => {
|
||
if (atMenu.accept()) {
|
||
return true;
|
||
}
|
||
if (acceptFollowupIntoEditor(view, 'tab')) {
|
||
return true;
|
||
}
|
||
if (slashMenuRef.current) {
|
||
return acceptSlashCompletion();
|
||
}
|
||
if (completionStatus(view.state) === 'active') {
|
||
return acceptCompletion(view);
|
||
}
|
||
const text = view.state.doc.toString();
|
||
const implicitResult = getImplicitTabCompletion(
|
||
text,
|
||
commandsRef.current,
|
||
languageRef.current,
|
||
);
|
||
if (implicitResult) {
|
||
view.dispatch({
|
||
changes: {
|
||
from: 0,
|
||
to: view.state.doc.length,
|
||
insert: implicitResult,
|
||
},
|
||
selection: { anchor: implicitResult.length },
|
||
});
|
||
return true;
|
||
}
|
||
const missingSlash = getMissingSlashPrefixCompletion(
|
||
text,
|
||
commandsRef.current,
|
||
);
|
||
if (missingSlash) {
|
||
view.dispatch({
|
||
changes: {
|
||
from: 0,
|
||
to: view.state.doc.length,
|
||
insert: missingSlash,
|
||
},
|
||
selection: { anchor: missingSlash.length },
|
||
});
|
||
return true;
|
||
}
|
||
return true;
|
||
},
|
||
},
|
||
{
|
||
key: 'ArrowRight',
|
||
run: (view) => {
|
||
if (
|
||
completionStatus(view.state) !== 'active' &&
|
||
acceptFollowupIntoEditor(view, 'right')
|
||
) {
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
},
|
||
{
|
||
key: 'Shift-Tab',
|
||
run: () => {
|
||
onCycleModeRef.current?.();
|
||
return true;
|
||
},
|
||
},
|
||
]);
|
||
|
||
let cachedDoc: EditorState['doc'] | null = null;
|
||
let cachedDocText = '';
|
||
const getDocText = (state: EditorState) => {
|
||
if (cachedDoc !== state.doc) {
|
||
cachedDoc = state.doc;
|
||
cachedDocText = state.doc.toString();
|
||
}
|
||
return cachedDocText;
|
||
};
|
||
|
||
const composerUpdateListener = EditorView.updateListener.of((update) => {
|
||
// A genuine edit (typing/deleting/pasting) ends history-browse mode, so
|
||
// arrows go back to driving any open menu. Programmatic history recall
|
||
// dispatches carry no user event, so they do not clear the flag.
|
||
const userEdited = update.transactions.some(
|
||
(tr) => tr.isUserEvent('input') || tr.isUserEvent('delete'),
|
||
);
|
||
// Prune only on input events (not delete): deleting a placeholder
|
||
// removes the mapping, but Ctrl+Z restores the text without restoring
|
||
// the React ref, so the mapping would be permanently lost.
|
||
const userInput = update.transactions.some((tr) =>
|
||
tr.isUserEvent('input'),
|
||
);
|
||
if (update.docChanged && userInput && pendingPastesRef.current.size > 0) {
|
||
const nextPasteId = prunePendingPastes(
|
||
pendingPastesRef.current,
|
||
getDocText(update.state),
|
||
);
|
||
if (nextPasteId !== null) {
|
||
nextPasteIdRef.current = nextPasteId;
|
||
}
|
||
}
|
||
if (userEdited) {
|
||
historyBrowseActiveRef.current = false;
|
||
}
|
||
if (update.docChanged || update.selectionSet) {
|
||
refreshSlashMenuForView(update.view);
|
||
// Match slash command behavior: history-recalled text like "@foo"
|
||
// should stay as plain recalled input until the user edits it.
|
||
if (historyBrowseActiveRef.current) {
|
||
closeAtMenu();
|
||
} else {
|
||
if (refreshAtMenuForView(update.view)) {
|
||
closeSlashMenu();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
let prevCompletionActive = false;
|
||
const triggerCleanupListener = EditorView.updateListener.of((update) => {
|
||
const trigger = autoTriggerRef.current;
|
||
const nowActive = completionStatus(update.state) === 'active';
|
||
if (trigger) {
|
||
const doc = update.state.doc;
|
||
const intact =
|
||
doc.length === trigger.from + trigger.text.length &&
|
||
doc.sliceString(trigger.from) === trigger.text;
|
||
if (!intact) {
|
||
autoTriggerRef.current = null;
|
||
} else if (prevCompletionActive && !nowActive) {
|
||
autoTriggerRef.current = null;
|
||
const { view } = update;
|
||
const { from } = trigger;
|
||
window.setTimeout(() => {
|
||
if (viewRef.current !== view) return;
|
||
const d = view.state.doc;
|
||
if (
|
||
d.length === from + trigger.text.length &&
|
||
d.sliceString(from) === trigger.text
|
||
) {
|
||
view.dispatch({
|
||
changes: {
|
||
from,
|
||
to: from + trigger.text.length,
|
||
insert: '',
|
||
},
|
||
});
|
||
}
|
||
}, 0);
|
||
}
|
||
}
|
||
prevCompletionActive = nowActive;
|
||
if (!nowActive) {
|
||
clearCompletionHoverInfo(tooltipPortal);
|
||
}
|
||
});
|
||
|
||
const state = EditorState.create({
|
||
doc: loadComposerDraft(draftIdentityRef.current.storageKey) ?? '',
|
||
extensions: [
|
||
Prec.highest(submitKeymap),
|
||
minimalSetup,
|
||
history(),
|
||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||
autocompletion({
|
||
override: [],
|
||
activateOnTyping: true,
|
||
icons: false,
|
||
optionClass: (completion) => {
|
||
const classes: string[] = [];
|
||
if (completion.type === 'file') classes.push('cm-file-completion');
|
||
if (hasCommandHoverInfo(completion)) {
|
||
classes.push('cm-command-info-completion');
|
||
}
|
||
return classes.join(' ');
|
||
},
|
||
addToOptions: [
|
||
{
|
||
render: renderCompletionHoverInfo,
|
||
position: 90,
|
||
},
|
||
],
|
||
maxRenderedOptions: 300,
|
||
aboveCursor: true,
|
||
positionInfo: (_view, list, option, info, space) => {
|
||
const infoHeight = info.bottom - info.top;
|
||
const spaceBelow = space.bottom - list.bottom;
|
||
const placeBelow =
|
||
spaceBelow >= infoHeight || spaceBelow > list.top;
|
||
const side = placeBelow ? 'top' : 'bottom';
|
||
const offset = placeBelow
|
||
? option.bottom - list.top
|
||
: list.bottom - option.top;
|
||
return {
|
||
style: `${side}: ${offset}px`,
|
||
class: 'cm-completionInfo-right-narrow',
|
||
};
|
||
},
|
||
activateOnCompletion: (completion) =>
|
||
typeof completion.apply === 'string' &&
|
||
completion.apply.endsWith(' '),
|
||
}),
|
||
tooltips({ parent: tooltipPortal }),
|
||
placeholderCompartment.of(placeholder('')),
|
||
followupGhostCompartment.of(createFollowupGhostExtension(null)),
|
||
EditorView.lineWrapping,
|
||
editableCompartment.of(EditorView.editable.of(true)),
|
||
inputHighlight(
|
||
() => commandsRef.current,
|
||
() => languageRef.current,
|
||
),
|
||
inputHighlightTheme,
|
||
inlineComposerTagField,
|
||
composerUpdateListener,
|
||
triggerCleanupListener,
|
||
// Update hasContent state when document changes
|
||
EditorView.updateListener.of((update) => {
|
||
const inlineTagsChanged =
|
||
update.docChanged ||
|
||
update.transactions.some((transaction) =>
|
||
transaction.effects.some(
|
||
(effect) =>
|
||
effect.is(addInlineTagEffect) ||
|
||
effect.is(removeInlineTagEffect) ||
|
||
effect.is(clearInlineTagsEffect),
|
||
),
|
||
);
|
||
if (inlineTagsChanged) {
|
||
const nextHasInlineTags = hasInlineComposerTags(update.view);
|
||
if (hasInlineTagsRef.current !== nextHasInlineTags) {
|
||
hasInlineTagsRef.current = nextHasInlineTags;
|
||
setHasInlineTags(nextHasInlineTags);
|
||
}
|
||
}
|
||
if (update.docChanged) {
|
||
const text = getDocText(update.state);
|
||
if (draftIdentityRef.current.storageKey === undefined) {
|
||
unscopedDraftEditedRef.current = true;
|
||
}
|
||
if (!historyBrowseActiveRef.current && !searchModeRef.current) {
|
||
scheduleDraftSaveRef.current();
|
||
}
|
||
onInputTextChangeRef.current?.(text);
|
||
const followup = followupStateRef.current;
|
||
const followupCompletion = getFollowupCompletion(
|
||
text,
|
||
followup?.isVisible ? followup.suggestion : null,
|
||
);
|
||
updateHasContent(
|
||
text.trim().length > 0 ||
|
||
!!followupCompletion ||
|
||
composerTagsRef.current.length > 0 ||
|
||
pastedImagesRef.current.length > 0,
|
||
);
|
||
}
|
||
}),
|
||
EditorView.inputHandler.of((view, from, to, insert) => {
|
||
if (
|
||
insert === '!' &&
|
||
view.state.doc.toString() === '' &&
|
||
completionStatus(view.state) !== 'active'
|
||
) {
|
||
toggleShellMode();
|
||
return true;
|
||
}
|
||
if (
|
||
insert === '?' &&
|
||
view.state.doc.toString() === '' &&
|
||
completionStatus(view.state) !== 'active'
|
||
) {
|
||
onToggleShortcutsRef.current?.();
|
||
return true;
|
||
}
|
||
return false;
|
||
}),
|
||
EditorView.domEventHandlers({
|
||
blur(event) {
|
||
closeSlashMenu();
|
||
if (
|
||
event.relatedTarget instanceof Element &&
|
||
event.relatedTarget.closest('[data-at-mention-panel="true"]')
|
||
) {
|
||
return false;
|
||
}
|
||
window.setTimeout(() => {
|
||
const currentView = viewRef.current;
|
||
if (currentView?.hasFocus) return;
|
||
if (
|
||
document.activeElement instanceof Element &&
|
||
document.activeElement.closest('[data-at-mention-panel="true"]')
|
||
) {
|
||
return;
|
||
}
|
||
closeAtMenu();
|
||
}, 0);
|
||
return false;
|
||
},
|
||
paste(event) {
|
||
const items = event.clipboardData?.items;
|
||
if (!items) return false;
|
||
const hasImage = collectClipboardImages(items, (image) =>
|
||
setPastedImages((prev) => [...prev, image]),
|
||
);
|
||
if (hasImage) {
|
||
event.preventDefault();
|
||
return true;
|
||
}
|
||
const pasted = normalizePastedText(
|
||
event.clipboardData?.getData('text/plain') ?? '',
|
||
);
|
||
if (!pasted || !isLargePaste(pasted)) return false;
|
||
|
||
event.preventDefault();
|
||
if (
|
||
view.state.doc.toString() === '' &&
|
||
followupStateRef.current?.isVisible
|
||
) {
|
||
onDismissFollowupRef.current?.();
|
||
}
|
||
const { placeholderText: pt, nextPasteId } =
|
||
createLargePastePlaceholder(
|
||
pendingPastesRef.current,
|
||
nextPasteIdRef.current,
|
||
pasted,
|
||
);
|
||
nextPasteIdRef.current = nextPasteId;
|
||
const selection = view.state.selection.main;
|
||
view.dispatch({
|
||
changes: {
|
||
from: selection.from,
|
||
to: selection.to,
|
||
insert: pt,
|
||
},
|
||
selection: { anchor: selection.from + pt.length },
|
||
scrollIntoView: true,
|
||
});
|
||
return true;
|
||
},
|
||
}),
|
||
EditorView.theme(editorTheme),
|
||
],
|
||
});
|
||
|
||
const view = new EditorView({
|
||
state,
|
||
parent: containerRef.current,
|
||
});
|
||
|
||
viewRef.current = view;
|
||
const handleDraftBlur = () => saveCurrentDraftRef.current();
|
||
view.dom.addEventListener('blur', handleDraftBlur, true);
|
||
// Programmatic (non-gesture) focus is suppressed on touch devices even
|
||
// when CodeMirror is forced via ?composer=codemirror: on iOS it claims
|
||
// document.activeElement without opening the keyboard, and later taps may
|
||
// then never fire the focus event that would (#5958).
|
||
if (!isCoarsePointerDevice()) {
|
||
view.focus();
|
||
}
|
||
|
||
// Initial check
|
||
const initialTextValue = view.state.doc.toString();
|
||
const initialText = initialTextValue.trim();
|
||
if (initialTextValue) {
|
||
onInputTextChangeRef.current?.(initialTextValue);
|
||
}
|
||
updateHasContent(
|
||
initialText.length > 0 ||
|
||
composerTagsRef.current.length > 0 ||
|
||
pastedImagesRef.current.length > 0,
|
||
);
|
||
|
||
return () => {
|
||
view.dom.removeEventListener('blur', handleDraftBlur, true);
|
||
saveCurrentDraftRef.current();
|
||
view.dispatch({ effects: clearInlineTagsEffect.of() });
|
||
view.destroy();
|
||
viewRef.current = null;
|
||
observer.disconnect();
|
||
tooltipPortal.remove();
|
||
tooltipPortalRef.current = null;
|
||
};
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
useEffect(() => {
|
||
const previousDraftIdentity = draftIdentityRef.current;
|
||
const view = viewRef.current;
|
||
const sessionChanged = previousDraftIdentity.sessionId !== sessionId;
|
||
const workspaceChanged =
|
||
previousDraftIdentity.workspaceCwd !== atWorkspaceCwd;
|
||
const draftStorageChanged =
|
||
previousDraftIdentity.storageKey !== composerDraftStorageKey;
|
||
const wasBrowsingHistory = historyBrowseActiveRef.current;
|
||
const wasSearchingHistory = searchModeRef.current;
|
||
|
||
if (!sessionChanged && !workspaceChanged) return;
|
||
|
||
historyActionsRef.current.reset();
|
||
shellHistoryActionsRef.current.reset();
|
||
historyBrowseActiveRef.current = false;
|
||
clearPromptHistoryDraftTags();
|
||
searchDraftRef.current = '';
|
||
setSearchMode(false);
|
||
setSearchQuery('');
|
||
setSearchMatches([]);
|
||
setSearchActiveIndex(0);
|
||
|
||
const currentText = view
|
||
? expandLargePastePlaceholders(
|
||
pendingPastesRef.current,
|
||
view.state.doc.toString(),
|
||
)
|
||
: mobileTextRef.current;
|
||
if (draftStorageChanged && !wasBrowsingHistory && !wasSearchingHistory) {
|
||
saveComposerDraft(previousDraftIdentity.storageKey, currentText);
|
||
}
|
||
draftIdentityRef.current = {
|
||
sessionId,
|
||
workspaceCwd: atWorkspaceCwd,
|
||
storageKey: composerDraftStorageKey,
|
||
};
|
||
|
||
if (!draftStorageChanged || (!view && !isTouchComposer)) return;
|
||
|
||
const storedDraft = loadComposerDraft(composerDraftStorageKey);
|
||
const adoptUnscopedInMemoryDraft =
|
||
!wasBrowsingHistory &&
|
||
!wasSearchingHistory &&
|
||
previousDraftIdentity.sessionId === undefined &&
|
||
sessionId === undefined &&
|
||
previousDraftIdentity.workspaceCwd === undefined &&
|
||
previousDraftIdentity.storageKey === undefined &&
|
||
atWorkspaceCwd !== undefined &&
|
||
(unscopedDraftEditedRef.current || storedDraft === null);
|
||
unscopedDraftEditedRef.current = false;
|
||
const nextText = adoptUnscopedInMemoryDraft
|
||
? currentText
|
||
: (storedDraft ?? '');
|
||
if (adoptUnscopedInMemoryDraft) {
|
||
saveComposerDraft(composerDraftStorageKey, currentText);
|
||
}
|
||
|
||
pendingPastesRef.current.clear();
|
||
nextPasteIdRef.current = 1;
|
||
setComposerTags([]);
|
||
setPastedImages([]);
|
||
if (view) {
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: nextText },
|
||
effects: clearInlineTagsEffect.of(),
|
||
selection: { anchor: nextText.length },
|
||
});
|
||
} else {
|
||
setMobileText(nextText);
|
||
}
|
||
if (composerDraftStorageKey === undefined) {
|
||
unscopedDraftEditedRef.current = false;
|
||
}
|
||
}, [
|
||
atWorkspaceCwd,
|
||
clearPromptHistoryDraftTags,
|
||
composerDraftStorageKey,
|
||
isTouchComposer,
|
||
sessionId,
|
||
setMobileText,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const tooltipPortal = tooltipPortalRef.current;
|
||
if (!tooltipPortal) return;
|
||
const tooltipParent = portalRoot ?? document.body;
|
||
ensureTooltipStyles(getTooltipStyleRoot(tooltipParent));
|
||
tooltipParent.appendChild(tooltipPortal);
|
||
}, [portalRoot]);
|
||
|
||
// ---- Reactions to prop changes ----
|
||
|
||
useEffect(() => {
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
view.dispatch({
|
||
effects: editableCompartment.reconfigure(
|
||
EditorView.editable.of(!disabled),
|
||
),
|
||
});
|
||
if (!disabled && !isCoarsePointerDevice()) {
|
||
view.focus();
|
||
}
|
||
}, [disabled]);
|
||
|
||
// Computed in the render scope so the mobile textarea backend can share the
|
||
// exact placeholder the CodeMirror path shows.
|
||
const followupSuggestion =
|
||
!disabled && followupState?.isVisible && followupState.suggestion
|
||
? followupState.suggestion
|
||
: null;
|
||
const composerPlaceholder =
|
||
followupSuggestion ??
|
||
(shellMode ? t('editor.shellPlaceholder') : placeholderText);
|
||
|
||
useEffect(() => {
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
view.dispatch({
|
||
effects: [
|
||
placeholderCompartment.reconfigure(placeholder(composerPlaceholder)),
|
||
followupGhostCompartment.reconfigure(
|
||
createFollowupGhostExtension(followupSuggestion),
|
||
),
|
||
],
|
||
});
|
||
}, [composerPlaceholder, followupSuggestion]);
|
||
|
||
useEffect(() => {
|
||
const view = viewRef.current;
|
||
if (!view || completionStatus(view.state) !== 'active') return;
|
||
closeCompletion(view);
|
||
window.setTimeout(() => {
|
||
if (viewRef.current === view) {
|
||
startCompletion(view);
|
||
}
|
||
}, 0);
|
||
}, [language]);
|
||
|
||
const slashMenuDataKey = [
|
||
commands
|
||
.map((command) =>
|
||
[
|
||
command.name,
|
||
command.description ?? '',
|
||
command.source ?? '',
|
||
command.displayCategory ?? '',
|
||
command.argumentHint ?? '',
|
||
command.subcommands?.join(',') ?? '',
|
||
].join('\u0000'),
|
||
)
|
||
.join('\u0001'),
|
||
skills
|
||
.map((skill) => [skill.name, skill.description].join('\u0000'))
|
||
.join('\u0001'),
|
||
slashCommandCategoryOrder?.join('|') ?? '',
|
||
].join('\u0002');
|
||
|
||
useEffect(() => {
|
||
if (slashMenuRef.current) {
|
||
refreshSlashMenuForView(viewRef.current);
|
||
}
|
||
}, [slashMenuDataKey, language, refreshSlashMenuForView]);
|
||
|
||
useEffect(() => {
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
if (dialogOpen) {
|
||
closeSlashMenu();
|
||
closeAtMenu();
|
||
view.contentDOM.blur();
|
||
} else if (!isCoarsePointerDevice()) {
|
||
view.focus();
|
||
}
|
||
}, [closeAtMenu, dialogOpen, closeSlashMenu]);
|
||
|
||
// Global keydown handler for focus-stealing
|
||
useEffect(() => {
|
||
const handler = (event: KeyboardEvent) => {
|
||
if (disabledRef.current || searchMode || dialogOpen) return;
|
||
if (event.defaultPrevented) return;
|
||
// Only capture keystrokes if the target is within the web-shell container
|
||
// or if no specific element has focus (document.body is active)
|
||
const target = event.target as Node;
|
||
const isWithinContainer = containerRef.current?.contains(target);
|
||
const isBodyFocused = document.activeElement === document.body;
|
||
if (!isWithinContainer && !isBodyFocused) return;
|
||
const view = viewRef.current;
|
||
const followup = followupStateRef.current;
|
||
const followupCompletion = getFollowupCompletion(
|
||
view?.state.doc.toString() ?? '',
|
||
followup?.suggestion,
|
||
);
|
||
if (
|
||
view &&
|
||
!view.hasFocus &&
|
||
followup?.isVisible &&
|
||
followupCompletion &&
|
||
!isEditableTarget(event.target)
|
||
) {
|
||
if (
|
||
event.key === 'Tab' &&
|
||
!event.shiftKey &&
|
||
!event.metaKey &&
|
||
!event.ctrlKey &&
|
||
!event.altKey &&
|
||
completionStatus(view.state) !== 'active'
|
||
) {
|
||
event.preventDefault();
|
||
view.dispatch({
|
||
changes: {
|
||
from: 0,
|
||
to: view.state.doc.length,
|
||
insert: followupCompletion,
|
||
},
|
||
selection: { anchor: followupCompletion.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
onAcceptFollowupRef.current?.('tab', { skipOnAccept: true });
|
||
return;
|
||
}
|
||
if (
|
||
event.key === 'ArrowRight' &&
|
||
!event.shiftKey &&
|
||
!event.metaKey &&
|
||
!event.ctrlKey &&
|
||
!event.altKey &&
|
||
completionStatus(view.state) !== 'active'
|
||
) {
|
||
event.preventDefault();
|
||
view.dispatch({
|
||
changes: {
|
||
from: 0,
|
||
to: view.state.doc.length,
|
||
insert: followupCompletion,
|
||
},
|
||
selection: { anchor: followupCompletion.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
onAcceptFollowupRef.current?.('right', { skipOnAccept: true });
|
||
return;
|
||
}
|
||
}
|
||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||
if (event.key.length !== 1) return;
|
||
if (isEditableTarget(event.target)) return;
|
||
|
||
if (!view || view.hasFocus) return;
|
||
|
||
event.preventDefault();
|
||
if (event.key === '!' && view.state.doc.toString() === '') {
|
||
toggleShellMode();
|
||
return;
|
||
}
|
||
const selection = view.state.selection.main;
|
||
view.dispatch({
|
||
changes: { from: selection.from, to: selection.to, insert: event.key },
|
||
selection: { anchor: selection.from + event.key.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
if (event.key === '/') {
|
||
window.setTimeout(() => {
|
||
refreshSlashMenuForView(viewRef.current);
|
||
}, 0);
|
||
} else if (event.key === '@') {
|
||
window.setTimeout(() => {
|
||
const nextView = viewRef.current;
|
||
if (nextView && nextView.hasFocus) {
|
||
closeSlashMenu();
|
||
refreshAtMenuForView(nextView);
|
||
}
|
||
}, 0);
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handler);
|
||
return () => window.removeEventListener('keydown', handler);
|
||
}, [
|
||
refreshAtMenuForView,
|
||
closeSlashMenu,
|
||
searchMode,
|
||
dialogOpen,
|
||
refreshSlashMenuForView,
|
||
toggleShellMode,
|
||
]);
|
||
|
||
// ---- Imperative methods ----
|
||
|
||
const focus = useCallback(() => {
|
||
if (isTouchComposer) {
|
||
mobileTextareaRef.current?.focus();
|
||
return;
|
||
}
|
||
viewRef.current?.focus();
|
||
}, [isTouchComposer]);
|
||
|
||
const insertText = useCallback(
|
||
(text: string, options?: WebShellComposerTextOptions) => {
|
||
if (isTouchComposer) {
|
||
if (text) {
|
||
if (options?.mode === 'replace') {
|
||
setMobileText(text);
|
||
} else {
|
||
// No slash/at menus on the textarea backend: '/' and '@' are
|
||
// inserted literally and interpreted from the submitted text.
|
||
const el = mobileTextareaRef.current;
|
||
const current = mobileTextRef.current;
|
||
const start = el ? el.selectionStart : current.length;
|
||
const end = el ? el.selectionEnd : current.length;
|
||
const caret = start + text.length;
|
||
setMobileText(current.slice(0, start) + text + current.slice(end));
|
||
// A controlled textarea resets the caret to the end when its
|
||
// value changes; put it back after React re-renders, matching
|
||
// the CodeMirror path's explicit selection anchor.
|
||
const restoreCaret = () => {
|
||
mobileTextareaRef.current?.setSelectionRange(caret, caret);
|
||
};
|
||
if (typeof requestAnimationFrame === 'function') {
|
||
requestAnimationFrame(restoreCaret);
|
||
} else {
|
||
window.setTimeout(restoreCaret, 0);
|
||
}
|
||
}
|
||
}
|
||
mobileTextareaRef.current?.focus();
|
||
return;
|
||
}
|
||
const view = viewRef.current;
|
||
if (!view || !text) {
|
||
view?.focus();
|
||
return;
|
||
}
|
||
if (options?.mode === 'replace') {
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: text },
|
||
effects: clearInlineTagsEffect.of(),
|
||
selection: { anchor: text.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
return;
|
||
}
|
||
const selection = view.state.selection.main;
|
||
let insert = text;
|
||
let skipInsert = false;
|
||
let caretOverride: number | null = null;
|
||
const openAtMenu = text === '@';
|
||
let openSlashMenu = text === '/';
|
||
if (text === '/') {
|
||
const line = view.state.doc.lineAt(selection.head);
|
||
if (line.text.startsWith('/')) {
|
||
skipInsert = true;
|
||
} else if (view.state.doc.length > 0) {
|
||
skipInsert = true;
|
||
openSlashMenu = false;
|
||
}
|
||
} else if (text === '@') {
|
||
const before =
|
||
selection.from > 0
|
||
? view.state.doc.sliceString(selection.from - 1, selection.from)
|
||
: '';
|
||
const after = view.state.doc.sliceString(
|
||
selection.from,
|
||
selection.from + 1,
|
||
);
|
||
if (after === '@') {
|
||
skipInsert = true;
|
||
caretOverride = selection.from + 1;
|
||
} else if (before === '@') {
|
||
skipInsert = true;
|
||
} else if (before && !/\s/.test(before)) {
|
||
insert = ' @';
|
||
}
|
||
}
|
||
if (!skipInsert) {
|
||
view.dispatch({
|
||
changes: { from: selection.from, to: selection.to, insert },
|
||
selection: { anchor: selection.from + insert.length },
|
||
scrollIntoView: true,
|
||
});
|
||
if (openAtMenu) {
|
||
autoTriggerRef.current = { text: insert, from: selection.from };
|
||
}
|
||
} else if (caretOverride !== null) {
|
||
view.dispatch({
|
||
selection: { anchor: caretOverride },
|
||
scrollIntoView: true,
|
||
});
|
||
}
|
||
view.focus();
|
||
if (openSlashMenu) {
|
||
window.setTimeout(() => {
|
||
refreshSlashMenuForView(viewRef.current);
|
||
}, 0);
|
||
} else if (openAtMenu) {
|
||
window.setTimeout(() => {
|
||
const nextView = viewRef.current;
|
||
if (nextView && nextView.hasFocus) {
|
||
closeSlashMenu();
|
||
refreshAtMenuForView(nextView);
|
||
}
|
||
}, 0);
|
||
}
|
||
},
|
||
[
|
||
closeSlashMenu,
|
||
isTouchComposer,
|
||
refreshAtMenuForView,
|
||
refreshSlashMenuForView,
|
||
setMobileText,
|
||
],
|
||
);
|
||
|
||
const getText = useCallback(() => {
|
||
if (isTouchComposer) return mobileTextRef.current;
|
||
return viewRef.current?.state.doc.toString() ?? '';
|
||
}, [isTouchComposer]);
|
||
|
||
const setText = useCallback(
|
||
(text: string) => {
|
||
if (isTouchComposer) {
|
||
// Unlike the CodeMirror path, no focus: on touch devices a
|
||
// programmatic focus would pop the virtual keyboard unexpectedly.
|
||
setMobileText(text);
|
||
return;
|
||
}
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: text },
|
||
effects: clearInlineTagsEffect.of(),
|
||
selection: { anchor: text.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
},
|
||
[isTouchComposer, setMobileText],
|
||
);
|
||
|
||
const removeInlineTags = useCallback(
|
||
(predicate?: (tag: WebShellComposerTag) => boolean) => {
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
const changes: Array<{ from: number; to: number; insert: string }> = [];
|
||
view.state
|
||
.field(inlineComposerTagField)
|
||
.between(0, view.state.doc.length, (from, to, value) => {
|
||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||
if (tag && (!predicate || predicate(tag))) {
|
||
changes.push({ from, to, insert: '' });
|
||
}
|
||
});
|
||
if (changes.length === 0) return;
|
||
view.dispatch({
|
||
changes,
|
||
effects: removeInlineTagEffect.of({ predicate }),
|
||
scrollIntoView: true,
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const clear = useCallback(
|
||
(options?: { text?: boolean; tags?: boolean }) => {
|
||
const clearTextOpt = options?.text ?? true;
|
||
const clearTags = options?.tags ?? true;
|
||
const view = viewRef.current;
|
||
if (clearTextOpt && view && view.state.doc.length > 0) {
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: '' },
|
||
effects: clearInlineTagsEffect.of(),
|
||
});
|
||
}
|
||
if (clearTextOpt && isTouchComposer) {
|
||
setMobileText('');
|
||
}
|
||
if (clearTextOpt) {
|
||
setPastedImages([]);
|
||
pendingPastesRef.current.clear();
|
||
nextPasteIdRef.current = 1;
|
||
}
|
||
if (clearTags) {
|
||
setComposerTags([]);
|
||
if (!clearTextOpt) {
|
||
removeInlineTags();
|
||
}
|
||
}
|
||
},
|
||
[isTouchComposer, removeInlineTags, setMobileText],
|
||
);
|
||
|
||
const clearText = useCallback(() => {
|
||
clear({ text: true, tags: false });
|
||
}, [clear]);
|
||
|
||
const addTags = useCallback(
|
||
(
|
||
tags: readonly WebShellComposerTag[],
|
||
tagOptions?: WebShellComposerTagOptions,
|
||
) => {
|
||
if (tags.length === 0) return;
|
||
// The textarea backend has no inline tag chips; inline requests fall
|
||
// through to the top placement below.
|
||
if (tagOptions?.placement === 'inline' && !isTouchComposer) {
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
const selection = view.state.selection.main;
|
||
let at = selection.from;
|
||
const ranges: InlineTagRange[] = [];
|
||
const insert = tags
|
||
.map((tag) => {
|
||
const tagText = serializeComposerTag(tag);
|
||
ranges.push({ from: at, to: at + tagText.length, tag });
|
||
at += tagText.length + 1;
|
||
return tagText;
|
||
})
|
||
.join(' ');
|
||
const text = insert ? `${insert} ` : '';
|
||
view.dispatch({
|
||
changes: { from: selection.from, to: selection.to, insert: text },
|
||
effects:
|
||
ranges.length > 0
|
||
? ranges.map((range) =>
|
||
addInlineTagEffect.of({
|
||
...range,
|
||
tag: resolveComposerTagIcon(range.tag),
|
||
}),
|
||
)
|
||
: undefined,
|
||
selection: { anchor: selection.from + text.length },
|
||
scrollIntoView: true,
|
||
});
|
||
view.focus();
|
||
return;
|
||
}
|
||
setComposerTags((current) => {
|
||
const next = [...current];
|
||
for (const tag of tags) {
|
||
const existingIndex = next.findIndex((item) => item.id === tag.id);
|
||
if (existingIndex >= 0) {
|
||
next[existingIndex] = tag;
|
||
} else {
|
||
next.push(tag);
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
},
|
||
[isTouchComposer, resolveComposerTagIcon],
|
||
);
|
||
|
||
const removeTopTag = useCallback(
|
||
(id: string) => {
|
||
setComposerTags((current) => {
|
||
const next = current.filter(
|
||
(tag) => tag.id !== id || tag.removable === false,
|
||
);
|
||
return next.length === current.length ? current : next;
|
||
});
|
||
removeInlineTags((tag) => tag.id === id && tag.removable !== false);
|
||
},
|
||
[removeInlineTags],
|
||
);
|
||
|
||
const hasInput = useCallback(() => {
|
||
const text = isTouchComposer
|
||
? mobileTextRef.current
|
||
: (viewRef.current?.state.doc.toString() ?? '');
|
||
return (
|
||
text.trim().length > 0 ||
|
||
composerTagsRef.current.length > 0 ||
|
||
pastedImagesRef.current.length > 0
|
||
);
|
||
}, [isTouchComposer]);
|
||
|
||
const hasAttachments = useCallback(() => {
|
||
const inlineTags = viewRef.current
|
||
? getInlineComposerTags(viewRef.current)
|
||
: [];
|
||
return (
|
||
inlineTags.length > 0 ||
|
||
composerTagsRef.current.length > 0 ||
|
||
pastedImagesRef.current.length > 0
|
||
);
|
||
}, []);
|
||
|
||
const submit = useCallback(
|
||
(input?: WebShellComposerInput) => {
|
||
const view = viewRef.current;
|
||
if (!view && !isTouchComposer) return;
|
||
const inlineTags = view ? getInlineComposerTags(view) : [];
|
||
if (input?.tagPlacement === 'inline') {
|
||
submitTextRef.current(view, input.text ?? '', input.tags ?? inlineTags);
|
||
return;
|
||
}
|
||
if (
|
||
input?.text !== undefined &&
|
||
input.tags === undefined &&
|
||
inlineTags.length > 0
|
||
) {
|
||
submitTextRef.current(view, input.text, inlineTags);
|
||
return;
|
||
}
|
||
submitTextRef.current(
|
||
view,
|
||
input?.text,
|
||
input ? (input.tags ?? []) : undefined,
|
||
);
|
||
},
|
||
[isTouchComposer],
|
||
);
|
||
|
||
const retryLast = useCallback(() => {
|
||
const last = historyActionsRef.current.getLastEntry(
|
||
(e) => !e.startsWith('/') && !e.startsWith('!'),
|
||
);
|
||
if (!last) return;
|
||
const accepted = onSubmitRef.current(last);
|
||
if (accepted === false) return;
|
||
setPastedImages([]);
|
||
}, []);
|
||
|
||
const replaceEditorText = useCallback(
|
||
(text: string) => {
|
||
if (isTouchComposer) {
|
||
setMobileText(text);
|
||
return;
|
||
}
|
||
const view = viewRef.current;
|
||
if (!view) return;
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: text },
|
||
selection: { anchor: text.length },
|
||
scrollIntoView: true,
|
||
});
|
||
},
|
||
[isTouchComposer, setMobileText],
|
||
);
|
||
|
||
// ---- composerInput sync ----
|
||
|
||
useEffect(() => {
|
||
const input = composerInputRef.current;
|
||
if (!input) return;
|
||
const view = viewRef.current;
|
||
if (!view && !isTouchComposer) return;
|
||
|
||
const tagPlacement = input.tagPlacement ?? 'top';
|
||
if (input.tags !== undefined && tagPlacement === 'top') {
|
||
setComposerTags([...input.tags]);
|
||
}
|
||
if (!view) {
|
||
// Mobile textarea backend: inline tag chips are not supported, so
|
||
// inline tags fall back to the top placement and only the plain text
|
||
// is seeded. No programmatic focus — that would pop the virtual
|
||
// keyboard outside a user gesture.
|
||
if (input.tags !== undefined && tagPlacement === 'inline') {
|
||
setComposerTags([...input.tags]);
|
||
}
|
||
if (input.text !== undefined) {
|
||
setMobileText(input.text);
|
||
}
|
||
let submitTimer: number | null = null;
|
||
if (input.submit) {
|
||
submitTimer = window.setTimeout(() => {
|
||
submit(input);
|
||
}, 0);
|
||
}
|
||
return () => {
|
||
if (submitTimer !== null) {
|
||
window.clearTimeout(submitTimer);
|
||
}
|
||
};
|
||
}
|
||
if (input.text !== undefined || tagPlacement === 'inline') {
|
||
const inlineTags =
|
||
tagPlacement === 'inline' ? [...(input.tags ?? [])] : [];
|
||
const inlineText = inlineTags.map(serializeComposerTag).join(' ');
|
||
const nextText =
|
||
tagPlacement === 'inline'
|
||
? inlineText && input.text
|
||
? `${inlineText} ${input.text}`
|
||
: inlineText || (input.text ?? '')
|
||
: (input.text ?? '');
|
||
const effects: StateEffect<unknown>[] = [clearInlineTagsEffect.of()];
|
||
if (inlineTags.length > 0) {
|
||
let from = 0;
|
||
for (const tag of inlineTags) {
|
||
const tagText = serializeComposerTag(tag);
|
||
effects.push(
|
||
addInlineTagEffect.of({
|
||
from,
|
||
to: from + tagText.length,
|
||
tag: resolveComposerTagIcon(tag),
|
||
}),
|
||
);
|
||
from += tagText.length + 1;
|
||
}
|
||
}
|
||
view.dispatch({
|
||
changes: { from: 0, to: view.state.doc.length, insert: nextText },
|
||
effects,
|
||
selection: { anchor: nextText.length },
|
||
scrollIntoView: true,
|
||
});
|
||
} else {
|
||
view.dispatch({ effects: clearInlineTagsEffect.of() });
|
||
}
|
||
if (
|
||
(input.text !== undefined || input.submit) &&
|
||
!isCoarsePointerDevice()
|
||
) {
|
||
view.focus();
|
||
}
|
||
let submitTimer: number | null = null;
|
||
if (input.submit) {
|
||
submitTimer = window.setTimeout(() => {
|
||
const nextView = viewRef.current;
|
||
if (!nextView) return;
|
||
submit(input);
|
||
}, 0);
|
||
}
|
||
return () => {
|
||
if (submitTimer !== null) {
|
||
window.clearTimeout(submitTimer);
|
||
}
|
||
};
|
||
}, [
|
||
composerInputVersion,
|
||
isTouchComposer,
|
||
resolveComposerTagIcon,
|
||
setMobileText,
|
||
submit,
|
||
]);
|
||
|
||
// ---- Search state ----
|
||
|
||
const closeSearch = useCallback(
|
||
(restoreDraft: boolean, keepFocus = true) => {
|
||
if (restoreDraft) {
|
||
replaceEditorText(searchDraftRef.current);
|
||
}
|
||
setSearchMode(false);
|
||
setSearchQuery('');
|
||
setSearchMatches([]);
|
||
setSearchActiveIndex(0);
|
||
const history = shellModeRef.current
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
history.resetSearch();
|
||
if (keepFocus) {
|
||
viewRef.current?.focus();
|
||
}
|
||
},
|
||
[replaceEditorText],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!searchMode) return;
|
||
const onPointerOutside = (event: Event) => {
|
||
if (event instanceof MouseEvent && event.button !== 0) return;
|
||
if (event.defaultPrevented) return;
|
||
const panel = searchUiRef.current;
|
||
const target = event.target;
|
||
if (panel && target instanceof Node && !panel.contains(target)) {
|
||
closeSearch(true, false);
|
||
}
|
||
};
|
||
window.addEventListener('mousedown', onPointerOutside);
|
||
window.addEventListener('touchstart', onPointerOutside);
|
||
return () => {
|
||
window.removeEventListener('mousedown', onPointerOutside);
|
||
window.removeEventListener('touchstart', onPointerOutside);
|
||
};
|
||
}, [searchMode, closeSearch]);
|
||
|
||
const submitSearchMatch = useCallback(
|
||
(match: string) => {
|
||
const view = viewRef.current;
|
||
if (!view && !isTouchComposer) return;
|
||
closeSearch(false);
|
||
if (!shellModeRef.current) {
|
||
restoreSelectedHistoryMatch(match);
|
||
submitTextRef.current(view, undefined, undefined, true);
|
||
return;
|
||
}
|
||
const text = match.trim();
|
||
if (!text) return;
|
||
const images = pastedImagesRef.current;
|
||
const accepted = onSubmitRef.current(
|
||
`!${text}`,
|
||
images.length > 0 ? [...images] : undefined,
|
||
);
|
||
if (accepted === false) {
|
||
restoreSelectedHistoryMatch(match);
|
||
return;
|
||
}
|
||
onDismissFollowupRef.current?.();
|
||
shellHistoryActionsRef.current.push(text);
|
||
shellHistoryActionsRef.current.reset();
|
||
setPastedImages([]);
|
||
replaceEditorText('');
|
||
},
|
||
[
|
||
closeSearch,
|
||
isTouchComposer,
|
||
replaceEditorText,
|
||
restoreSelectedHistoryMatch,
|
||
],
|
||
);
|
||
|
||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
// While an IME is composing, keys belong to the IME. For example, Enter
|
||
// commits the candidate instead of submitting the history search.
|
||
if (e.nativeEvent.isComposing) return;
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
closeSearch(true);
|
||
} else if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
const match = searchMatches[searchActiveIndex];
|
||
if (match) {
|
||
restoreSelectedHistoryMatch(match);
|
||
}
|
||
closeSearch(false);
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const match = searchMatches[searchActiveIndex];
|
||
if (match) {
|
||
submitSearchMatch(match);
|
||
} else {
|
||
closeSearch(false);
|
||
}
|
||
} else if (e.key === 'r' && e.ctrlKey) {
|
||
e.preventDefault();
|
||
if (searchMatches.length > 0) {
|
||
setSearchActiveIndex((index) => (index + 1) % searchMatches.length);
|
||
}
|
||
}
|
||
};
|
||
|
||
const runHistorySearch = (q: string) => {
|
||
const history = shellModeRef.current
|
||
? shellHistoryActionsRef.current
|
||
: historyActionsRef.current;
|
||
setSearchMatches(getSearchMatches(q));
|
||
setSearchActiveIndex(0);
|
||
history.resetSearch();
|
||
};
|
||
|
||
const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const q = e.target.value;
|
||
setSearchQuery(q);
|
||
if ((e.nativeEvent as InputEvent).isComposing) return;
|
||
runHistorySearch(q);
|
||
};
|
||
|
||
const handleSearchCompositionEnd = (
|
||
e: React.CompositionEvent<HTMLInputElement>,
|
||
) => {
|
||
const q = e.currentTarget.value;
|
||
setSearchQuery(q);
|
||
runHistorySearch(q);
|
||
};
|
||
|
||
const removeImage = useCallback((index: number) => {
|
||
setPastedImages((prev) => prev.filter((_, idx) => idx !== index));
|
||
}, []);
|
||
|
||
// ---- Computed ----
|
||
|
||
const showShortcutHints =
|
||
!shellMode &&
|
||
!searchMode &&
|
||
!followupState?.isVisible &&
|
||
!disabled &&
|
||
!dialogOpen;
|
||
|
||
// ---- Imperative handle ----
|
||
|
||
const restoreImages = useCallback((images: readonly PromptImage[]) => {
|
||
setPastedImages((prev) => [...prev, ...images]);
|
||
}, []);
|
||
const handle = useMemo<EditorHandle>(() => {
|
||
return {
|
||
clearText,
|
||
clear,
|
||
focus,
|
||
getText,
|
||
hasAttachments,
|
||
hasInput,
|
||
setText,
|
||
addTags,
|
||
removeTag: removeTopTag,
|
||
insertText,
|
||
retryLast,
|
||
restoreImages,
|
||
submit,
|
||
};
|
||
}, [
|
||
addTags,
|
||
clear,
|
||
clearText,
|
||
focus,
|
||
getText,
|
||
hasAttachments,
|
||
hasInput,
|
||
insertText,
|
||
removeTopTag,
|
||
restoreImages,
|
||
retryLast,
|
||
setText,
|
||
submit,
|
||
]);
|
||
|
||
return {
|
||
containerRef,
|
||
viewRef,
|
||
mobileComposer: isTouchComposer
|
||
? {
|
||
textareaRef: mobileTextareaRef,
|
||
value: mobileText,
|
||
onChange: handleMobileChange,
|
||
onBlur: () => saveCurrentDraftRef.current(),
|
||
onPaste: handleMobilePaste,
|
||
placeholder: composerPlaceholder,
|
||
}
|
||
: null,
|
||
focus,
|
||
submitText: useCallback(() => {
|
||
const view = viewRef.current;
|
||
if (!view && !isTouchComposer) return;
|
||
submitTextRef.current(view);
|
||
}, [isTouchComposer]),
|
||
clearText,
|
||
getText,
|
||
hasInput,
|
||
hasAttachments:
|
||
hasInlineTags || composerTags.length > 0 || pastedImages.length > 0,
|
||
hasContent,
|
||
handle,
|
||
pastedImages,
|
||
removeImage,
|
||
composerTags,
|
||
removeTopTag,
|
||
addTags,
|
||
removeInlineTags,
|
||
insertText,
|
||
setText,
|
||
submit,
|
||
clear,
|
||
retryLast,
|
||
replaceEditorText,
|
||
shellMode,
|
||
setShellMode,
|
||
toggleShellMode,
|
||
currentMode,
|
||
sessionName,
|
||
searchState: {
|
||
searchMode,
|
||
searchQuery,
|
||
searchMatches,
|
||
searchActiveIndex,
|
||
searchInputRef,
|
||
searchUiRef,
|
||
openHistorySearch,
|
||
closeSearch,
|
||
submitSearchMatch,
|
||
restoreSearchMatch: restoreSelectedHistoryMatch,
|
||
handleSearchKeyDown,
|
||
handleSearchInput,
|
||
handleSearchCompositionEnd,
|
||
},
|
||
navigatePrevHistory,
|
||
navigateNextHistory,
|
||
showShortcutHints,
|
||
followupState:
|
||
followupState as UseDaemonFollowupSuggestionReturn['followupState'],
|
||
disabled,
|
||
onAcceptFollowup:
|
||
onAcceptFollowup as UseDaemonFollowupSuggestionReturn['onAcceptFollowup'],
|
||
onDismissFollowup:
|
||
onDismissFollowup as UseDaemonFollowupSuggestionReturn['onDismissFollowup'],
|
||
slashMenu,
|
||
closeSlashMenu,
|
||
selectSlashCompletion,
|
||
acceptSlashCompletion,
|
||
atMenu: atMenu.state,
|
||
closeAtMenu,
|
||
selectAtCompletion: atMenu.select,
|
||
acceptAtCompletion: atMenu.accept,
|
||
enterAtCategory: atMenu.enterCategory,
|
||
backAtCategories: atMenu.backToCategories,
|
||
updateAtSearch: atMenu.updateSearch,
|
||
selectAtTab: atMenu.selectTab,
|
||
};
|
||
}
|