refactor(ui): centralize composer attachment and capability controls (#119650)

This commit is contained in:
Peter Steinberger
2026-08-05 09:57:48 -07:00
committed by GitHub
parent 36554c2820
commit 7e5c02ccfe
3 changed files with 164 additions and 265 deletions
@@ -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`
<input
type="file"
accept=${CHAT_ATTACHMENT_ACCEPT}
multiple
class="agent-chat__file-input"
?disabled=${props.disabled}
@change=${(event: Event) => {
if (!props.disabled) {
handleChatAttachmentFileSelect(event, props);
${(["file", "photo", "camera"] as const).map(
(kind) => html`
<input
type="file"
accept=${kind === "file" ? CHAT_ATTACHMENT_ACCEPT : "image/*"}
?multiple=${kind !== "camera"}
capture=${kind === "camera" ? "environment" : nothing}
class=${`agent-chat__${kind}-input`}
?disabled=${props.disabled}
@change=${(event: Event) => {
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`
<button
slot="trigger"
type="button"
class="agent-chat__input-btn agent-chat__input-btn--attach"
aria-label=${t("chat.composer.addAttachment")}
?disabled=${disabled}
title=${t("chat.composer.addAttachment")}
@pointerdown=${(event: PointerEvent) => {
const composer = (event.currentTarget as HTMLElement)
.closest(".agent-chat__composer-shell")
?.querySelector("textarea");
if (document.activeElement === composer) {
event.preventDefault();
}
}}
/>
<input
type="file"
accept="image/*"
multiple
class="agent-chat__photo-input"
?disabled=${props.disabled}
@change=${(event: Event) => {
if (!props.disabled) {
handleChatAttachmentFileSelect(event, props);
}
}}
/>
<input
type="file"
accept="image/*"
capture="environment"
class="agent-chat__camera-input"
?disabled=${props.disabled}
@change=${(event: Event) => {
if (!props.disabled) {
handleChatAttachmentFileSelect(event, props);
}
}}
/>
>
${icons.plus}
</button>
`;
}
export function renderChatAttachmentMenuOptions(fileIcon = icons.folder) {
return html`
<wa-dropdown-item class="agent-chat__attach-menu-option" value="camera">
<span slot="icon" aria-hidden="true">${icons.camera}</span>
<span>${t("chat.composer.takePhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="photo">
<span slot="icon" aria-hidden="true">${icons.image}</span>
<span>${t("chat.composer.attachPhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="file">
<span slot="icon" aria-hidden="true">${fileIcon}</span>
<span>${t("chat.composer.attachFileOption")}</span>
</wa-dropdown-item>
`;
}
@@ -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}
>
<button
slot="trigger"
type="button"
class="agent-chat__input-btn agent-chat__input-btn--attach"
aria-label=${t("chat.composer.addAttachment")}
?disabled=${props.disabled}
title=${t("chat.composer.addAttachment")}
@pointerdown=${(event: PointerEvent) => {
const composer = (event.currentTarget as HTMLElement)
.closest(".agent-chat__composer-shell")
?.querySelector("textarea");
if (document.activeElement === composer) {
event.preventDefault();
}
}}
>
${icons.plus}
</button>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="camera">
<span slot="icon" aria-hidden="true">${icons.camera}</span>
<span>${t("chat.composer.takePhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="photo">
<span slot="icon" aria-hidden="true">${icons.image}</span>
<span>${t("chat.composer.attachPhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="file">
<span slot="icon" aria-hidden="true">${icons.folder}</span>
<span>${t("chat.composer.attachFileOption")}</span>
</wa-dropdown-item>
${renderChatAttachmentMenuTrigger(props.disabled)} ${renderChatAttachmentMenuOptions()}
</wa-dropdown>
`;
}
@@ -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`
<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-toggle"
value=${options.value}
?disabled=${options.disabled}
title=${options.title ?? ""}
>
<span class="agent-chat__capability-menu-label">
<span>${options.label}</span>
${options.note ?? nothing}
</span>
<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${options.checked}
?disabled=${options.disabled}
aria-label=${options.label}
></wa-switch>
</wa-dropdown-item>
`;
}
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`
<wa-dropdown-item class="agent-chat__attach-menu-option" value="camera">
<span slot="icon" aria-hidden="true">${icons.camera}</span>
<span>${t("chat.composer.takePhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="photo">
<span slot="icon" aria-hidden="true">${icons.image}</span>
<span>${t("chat.composer.attachPhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="file">
<span slot="icon" aria-hidden="true">${icons.paperclip}</span>
<span>${t("chat.composer.attachFileOption")}</span>
</wa-dropdown-item>
`;
const attachments = renderChatAttachmentMenuOptions(icons.paperclip);
if (!props.showCapabilities) {
return attachments;
}
@@ -183,42 +207,22 @@ function renderSkillView(props: ChatComposerPlusMenuProps) {
${t("chat.composer.menu.noSkills")}
</div>`
: 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`
<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-toggle"
value=${`skill:${index}`}
?disabled=${rowDisabled}
title=${title ?? ""}
>
<span class="agent-chat__capability-menu-label">
<span>${skill.name}</span>
${skill.missingDeps
? html`<span class="agent-chat__capability-menu-note"
>${t("chat.composer.menu.depsMissing")}</span
>`
: skill.blocked
? html`<span class="agent-chat__capability-menu-note"
>${t("chat.composer.menu.skillBlocked")}</span
>`
: nothing}
</span>
<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${skill.enabled}
?disabled=${rowDisabled}
aria-label=${skill.name}
></wa-switch>
</wa-dropdown-item>
`;
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`<span class="agent-chat__capability-menu-note">${title}</span>`
: 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`
<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-toggle"
value=${`connector:${index}`}
?disabled=${disabledReason !== null}
title=${disabledReason ?? ""}
>
<span class="agent-chat__capability-menu-label">
<span>${server.name}</span>
<span class="agent-chat__capability-menu-note">
${enabled ? t("common.enabled") : t("common.disabled")}
${override !== undefined
? html`<span class="agent-chat__capability-menu-session-tag"
>${t("chat.composer.menu.sessionTag")}</span
>`
: nothing}
</span>
</span>
<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${enabled}
?disabled=${disabledReason !== null}
aria-label=${server.name}
></wa-switch>
</wa-dropdown-item>
${renderCapabilityToggleRow({
value: `connector:${index}`,
label: server.name,
checked: enabled,
disabled: disabledReason !== null,
title: disabledReason,
note: html`<span class="agent-chat__capability-menu-note">
${enabled ? t("common.enabled") : t("common.disabled")}
${override !== undefined
? html`<span class="agent-chat__capability-menu-session-tag"
>${t("chat.composer.menu.sessionTag")}</span
>`
: nothing}
</span>`,
})}
${props.onOpenToolAccess
? html`<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-subrow"
@@ -309,7 +301,7 @@ function renderConnectorView(props: ChatComposerPlusMenuProps) {
function toolsForServer(
result: ToolsEffectiveResult | null,
serverName: string,
): ToolsEffectiveEntry[] {
): (ToolsEffectiveEntry & { mcpToolName: string })[] {
return (result?.groups ?? [])
.flatMap((group) => 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`
<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-toggle"
value=${`mcp-tool:${index}`}
?disabled=${props.toolAccessMutationBlockedReason !== null}
title=${props.toolAccessMutationBlockedReason ?? ""}
>
<span class="agent-chat__capability-menu-label">
<span>${rawToolName}</span>
${label && label !== rawToolName
? html`<span class="agent-chat__capability-menu-note">${label}</span>`
: nothing}
</span>
<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${!denied}
?disabled=${props.toolAccessMutationBlockedReason !== null}
aria-label=${rawToolName}
></wa-switch>
</wa-dropdown-item>
`;
return renderCapabilityToggleRow({
value: `mcp-tool:${index}`,
label: rawToolName,
checked: !denied,
disabled: props.toolAccessMutationBlockedReason !== null,
title: props.toolAccessMutationBlockedReason,
note:
label && label !== rawToolName
? html`<span class="agent-chat__capability-menu-note">${label}</span>`
: 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<HTMLElement>("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}
>
<button
slot="trigger"
type="button"
class="agent-chat__input-btn agent-chat__input-btn--attach"
aria-label=${t("chat.composer.addAttachment")}
?disabled=${props.disabled}
title=${t("chat.composer.addAttachment")}
@pointerdown=${(event: PointerEvent) => {
const composer = (event.currentTarget as HTMLElement)
.closest(".agent-chat__composer-shell")
?.querySelector("textarea");
if (document.activeElement === composer) {
event.preventDefault();
}
}}
>
${icons.plus}
</button>
${content}
${renderChatAttachmentMenuTrigger(props.disabled)} ${content}
</wa-dropdown>
${props.addServerDialog ?? nothing}
`;
+9 -62
View File
@@ -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`
<div
class="agent-chat__composer-shell new-session-page__composer"
@drop=${(event: DragEvent) => {
// 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}
>
<div class="agent-chat__input">
${renderChatAttachmentInputs(attachmentProps)} ${renderAttachmentPreview(attachmentProps)}