From 7e5c02ccfee7366a4b3eee36cbcc54bd9ce3215f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 5 Aug 2026 09:57:48 -0700 Subject: [PATCH] refactor(ui): centralize composer attachment and capability controls (#119650) --- .../pages/chat/components/chat-attachments.ts | 156 +++++++------- .../components/chat-composer-plus-menu.ts | 202 +++++++----------- ui/src/pages/new-session/composer.ts | 71 +----- 3 files changed, 164 insertions(+), 265 deletions(-) diff --git a/ui/src/pages/chat/components/chat-attachments.ts b/ui/src/pages/chat/components/chat-attachments.ts index a74ccd531181..53eb74807f9e 100644 --- a/ui/src/pages/chat/components/chat-attachments.ts +++ b/ui/src/pages/chat/components/chat-attachments.ts @@ -63,7 +63,7 @@ export class ChatAttachmentReadLifecycle { } } -export function isFileDrag(dataTransfer: DataTransfer | null): boolean { +function isFileDrag(dataTransfer: DataTransfer | null): boolean { return Array.from(dataTransfer?.types ?? []).includes("Files"); } @@ -81,7 +81,7 @@ const TEXT_ENTRY_INPUT_TYPES = new Set([ // actually accept it; anywhere else (disabled/readonly inputs, non-text // controls like checkbox/range) an uncancelled URL drop navigates the app // away and discards unsent drafts. -export function isEditableDropTarget(event: DragEvent): boolean { +function isEditableDropTarget(event: DragEvent): boolean { const target = event.target; if (!(target instanceof Element)) { return false; @@ -100,7 +100,7 @@ function currentAttachments(props: ChatAttachmentControlsProps): ChatAttachment[ return props.getAttachments?.() ?? props.attachments ?? []; } -export function clickComposerInput(target: HTMLElement, selector: string) { +function clickComposerInput(target: HTMLElement, selector: string) { target.closest("details")?.removeAttribute("open"); target .closest(".agent-chat__composer-shell, .new-session-page__composer") @@ -363,7 +363,7 @@ function handleChatAttachmentFileSelect(e: Event, props: ChatAttachmentControlsP void appendAttachmentFiles(files, props); } -export function handleChatAttachmentDrop(e: DragEvent, props: ChatAttachmentControlsProps) { +function handleChatAttachmentDrop(e: DragEvent, props: ChatAttachmentControlsProps) { e.preventDefault(); void appendAttachmentFiles([...(e.dataTransfer?.files ?? [])], props); } @@ -372,6 +372,8 @@ type ChatAttachmentDropProps = ChatAttachmentControlsProps & { canCompose: boolean; }; +// Both composers share balanced nested drag state and cancel non-editable +// text/URL drops so disabled surfaces cannot navigate away from a draft. export function createChatAttachmentDropHandlers(props: ChatAttachmentDropProps) { let depth = 0; const setActive = (event: DragEvent, active: boolean) => { @@ -432,42 +434,74 @@ export function createChatAttachmentDropHandlers(props: ChatAttachmentDropProps) export function renderChatAttachmentInputs(props: ChatAttachmentControlsProps) { return html` - { - if (!props.disabled) { - handleChatAttachmentFileSelect(event, props); + ${(["file", "photo", "camera"] as const).map( + (kind) => html` + { + if (!props.disabled) { + handleChatAttachmentFileSelect(event, props); + } + }} + /> + `, + )} + `; +} + +export function handleChatAttachmentMenuSelection( + event: CustomEvent<{ item: { value?: string } }>, +): boolean { + const value = event.detail.item.value; + if (value !== "camera" && value !== "photo" && value !== "file") { + return false; + } + clickComposerInput(event.currentTarget as HTMLElement, `.agent-chat__${value}-input`); + return true; +} + +export function renderChatAttachmentMenuTrigger(disabled: boolean | undefined) { + return html` + + `; +} + +export function renderChatAttachmentMenuOptions(fileIcon = icons.folder) { + return html` + + + ${t("chat.composer.takePhoto")} + + + + ${t("chat.composer.attachPhoto")} + + + + ${t("chat.composer.attachFileOption")} + `; } @@ -477,51 +511,9 @@ export function renderChatAttachmentMenu(props: ChatAttachmentControlsProps) { class="agent-chat__attach-menu" placement="top-start" aria-label=${t("chat.composer.addAttachment")} - @wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => { - const menu = event.currentTarget as HTMLElement; - const selector = - event.detail.item.value === "camera" - ? ".agent-chat__camera-input" - : event.detail.item.value === "photo" - ? ".agent-chat__photo-input" - : event.detail.item.value === "file" - ? ".agent-chat__file-input" - : null; - if (selector) { - clickComposerInput(menu, selector); - } - }} + @wa-select=${handleChatAttachmentMenuSelection} > - - - - ${t("chat.composer.takePhoto")} - - - - ${t("chat.composer.attachPhoto")} - - - - ${t("chat.composer.attachFileOption")} - + ${renderChatAttachmentMenuTrigger(props.disabled)} ${renderChatAttachmentMenuOptions()} `; } diff --git a/ui/src/pages/chat/components/chat-composer-plus-menu.ts b/ui/src/pages/chat/components/chat-composer-plus-menu.ts index d44760e3886b..bdebe30969c3 100644 --- a/ui/src/pages/chat/components/chat-composer-plus-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-plus-menu.ts @@ -16,7 +16,12 @@ import { readOwnEntry, resolveToolOverrideState, } from "../../../lib/sessions/tool-overrides.ts"; -import { clickComposerInput, type ChatAttachmentControlsProps } from "./chat-attachments.ts"; +import { + handleChatAttachmentMenuSelection, + renderChatAttachmentMenuOptions, + renderChatAttachmentMenuTrigger, + type ChatAttachmentControlsProps, +} from "./chat-attachments.ts"; export type ChatComposerPlusMenuView = "root" | "skills" | "connectors" | `tools:${string}`; @@ -86,6 +91,38 @@ function renderBackRow() { `; } +function renderCapabilityToggleRow(options: { + value: string; + label: string; + checked: boolean; + disabled: boolean; + title: string | null | undefined; + note?: TemplateResult | typeof nothing; +}) { + return html` + + + ${options.label} + ${options.note ?? nothing} + + + + `; +} + function renderRootView(props: ChatComposerPlusMenuProps) { const connectorCount = props.mcpServers.filter((server) => resolveToolOverrideState( @@ -99,20 +136,7 @@ function renderRootView(props: ChatComposerPlusMenuProps) { props.webSearchBaseEnabled, props.toolOverrides?.webSearch, ); - const attachments = html` - - - ${t("chat.composer.takePhoto")} - - - - ${t("chat.composer.attachPhoto")} - - - - ${t("chat.composer.attachFileOption")} - - `; + const attachments = renderChatAttachmentMenuOptions(icons.paperclip); if (!props.showCapabilities) { return attachments; } @@ -183,42 +207,22 @@ function renderSkillView(props: ChatComposerPlusMenuProps) { ${t("chat.composer.menu.noSkills")} ` : props.skills.map((skill, index) => { - const rowDisabled = skill.missingDeps || skill.blocked || disabledReason !== null; const title = skill.missingDeps ? t("chat.composer.menu.depsMissing") : skill.blocked ? t("chat.composer.menu.skillBlocked") : disabledReason; - return html` - - - ${skill.name} - ${skill.missingDeps - ? html`${t("chat.composer.menu.depsMissing")}` - : skill.blocked - ? html`${t("chat.composer.menu.skillBlocked")}` - : nothing} - - - - `; + return renderCapabilityToggleRow({ + value: `skill:${index}`, + label: skill.name, + checked: skill.enabled, + disabled: skill.missingDeps || skill.blocked || disabledReason !== null, + title, + note: + skill.missingDeps || skill.blocked + ? html`${title}` + : nothing, + }); }); return html` ${renderBackRow()} ${rows} ${menuDivider()} @@ -239,33 +243,21 @@ function renderConnectorView(props: ChatComposerPlusMenuProps) { const override = readOwnEntry(props.toolOverrides?.mcpServers, server.name); const enabled = resolveToolOverrideState(server.enabled, override); return html` - - - ${server.name} - - ${enabled ? t("common.enabled") : t("common.disabled")} - ${override !== undefined - ? html`${t("chat.composer.menu.sessionTag")}` - : nothing} - - - - + ${renderCapabilityToggleRow({ + value: `connector:${index}`, + label: server.name, + checked: enabled, + disabled: disabledReason !== null, + title: disabledReason, + note: html` + ${enabled ? t("common.enabled") : t("common.disabled")} + ${override !== undefined + ? html`${t("chat.composer.menu.sessionTag")}` + : nothing} + `, + })} ${props.onOpenToolAccess ? html` group.tools) .filter( @@ -376,30 +368,17 @@ function renderToolAccessView(props: ChatComposerPlusMenuProps, serverName: stri const rawToolName = tool.mcpToolName; const label = tool.label?.trim(); const denied = isToolDenied(props, tool); - return html` - - - ${rawToolName} - ${label && label !== rawToolName - ? html`${label}` - : nothing} - - - - `; + return renderCapabilityToggleRow({ + value: `mcp-tool:${index}`, + label: rawToolName, + checked: !denied, + disabled: props.toolAccessMutationBlockedReason !== null, + title: props.toolAccessMutationBlockedReason, + note: + label && label !== rawToolName + ? html`${label}` + : nothing, + }); }); return html` ${renderBackRow()} @@ -420,6 +399,9 @@ function handleMenuSelection( props: ChatComposerPlusMenuProps, ) { const value = event.detail.item.value ?? ""; + if (handleChatAttachmentMenuSelection(event)) { + return; + } const menu = event.currentTarget as HTMLElement; const changeView = (view: ChatComposerPlusMenuView) => { props.onViewChange(view); @@ -427,10 +409,6 @@ function handleMenuSelection( menu.querySelector("wa-dropdown-item:not([disabled])")?.focus(), ); }; - if (value === "camera" || value === "photo" || value === "file") { - clickComposerInput(menu, `.agent-chat__${value === "file" ? "file" : value}-input`); - return; - } if (value === "back") { event.preventDefault(); changeView(props.view.startsWith("tools:") ? "connectors" : "root"); @@ -570,25 +548,7 @@ export function renderChatComposerPlusMenu(props: ChatComposerPlusMenuProps) { }} data-view=${view} > - - ${content} + ${renderChatAttachmentMenuTrigger(props.disabled)} ${content} ${props.addServerDialog ?? nothing} `; diff --git a/ui/src/pages/new-session/composer.ts b/ui/src/pages/new-session/composer.ts index 21fc71dad34e..d24ba558f200 100644 --- a/ui/src/pages/new-session/composer.ts +++ b/ui/src/pages/new-session/composer.ts @@ -5,10 +5,8 @@ import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; import { - handleChatAttachmentDrop, + createChatAttachmentDropHandlers, handleChatAttachmentPaste, - isEditableDropTarget, - isFileDrag, renderAttachmentPreview, renderChatAttachmentInputs, renderChatAttachmentMenu, @@ -144,69 +142,18 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) { onPendingReadsChange: options.onPendingReadsChange, readSignal: options.readSignal, }; - const enabled = !options.submitting && !options.messageLocked; + const attachmentDropHandlers = createChatAttachmentDropHandlers({ + ...attachmentProps, + canCompose: !options.submitting && !options.messageLocked, + }); options.textareaController.syncDraft(options.message); - // Nested dragenter/dragleave events must stay balanced so crossing composer - // children does not flicker the file drop affordance. - let attachmentDragDepth = 0; - const setAttachmentDropActive = (event: DragEvent, active: boolean) => { - const target = event.currentTarget; - if (!(target instanceof HTMLElement)) { - return; - } - if (active) { - if (!enabled || !isFileDrag(event.dataTransfer)) { - return; - } - attachmentDragDepth += 1; - } else { - attachmentDragDepth = Math.max(0, attachmentDragDepth - 1); - } - target.toggleAttribute("data-attachment-drop-active", attachmentDragDepth > 0); - }; - const clearAttachmentDropActive = (event: DragEvent) => { - attachmentDragDepth = 0; - const target = event.currentTarget; - if (target instanceof HTMLElement) { - target.removeAttribute("data-attachment-drop-active"); - } - }; return html`
{ - // Text/URL drops stay native only inside the textarea; elsewhere they - // are cancelled so a dropped link cannot navigate the app away. File - // drops are cancelled even while disabled for the same reason. - if (!isFileDrag(event.dataTransfer)) { - if (!isEditableDropTarget(event)) { - event.preventDefault(); - } - return; - } - event.preventDefault(); - clearAttachmentDropActive(event); - if (enabled) { - handleChatAttachmentDrop(event, attachmentProps); - } - }} - @dragenter=${(event: DragEvent) => setAttachmentDropActive(event, true)} - @dragleave=${(event: DragEvent) => setAttachmentDropActive(event, false)} - @dragover=${(event: DragEvent) => { - if (!isFileDrag(event.dataTransfer)) { - if (!isEditableDropTarget(event)) { - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "none"; - } - } - return; - } - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = enabled ? "copy" : "none"; - } - }} + @drop=${attachmentDropHandlers.onDrop} + @dragenter=${attachmentDropHandlers.onDragenter} + @dragleave=${attachmentDropHandlers.onDragleave} + @dragover=${attachmentDropHandlers.onDragover} >
${renderChatAttachmentInputs(attachmentProps)} ${renderAttachmentPreview(attachmentProps)}