diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 31d644a63b2f..ddc6e0f50f9c 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -565,6 +565,7 @@ export type GatewaySessionRow = { thinkingOptions?: string[]; thinkingDefault?: string; fastMode?: FastMode; + toolOverrides?: import("../lib/sessions/patch.js").SessionToolOverrides; effectiveFastMode?: FastMode; effectiveFastModeSource?: FastModeSource; fastAutoOnSeconds?: number; diff --git a/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts b/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts new file mode 100644 index 000000000000..e88b04291cac --- /dev/null +++ b/ui/src/e2e/chat-composer-capability-menu.e2e.test.ts @@ -0,0 +1,398 @@ +// Control UI E2E coverage proves the composer capability menu against a mocked Gateway. +import { chromium, type Browser, type Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, + type MockGatewayControls, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let server: ControlUiE2eServer; +let browser: Browser; + +function skill( + name: string, + options: { blocked?: boolean; disabled?: boolean; missingDeps?: boolean } = {}, +) { + const missingBins = options.missingDeps ? ["missing-cli"] : []; + return { + name, + description: `${name} skill`, + source: "test", + filePath: `/tmp/openclaw-e2e/skills/${name}/SKILL.md`, + baseDir: `/tmp/openclaw-e2e/skills/${name}`, + skillKey: name.toLowerCase(), + always: false, + disabled: options.disabled ?? false, + blockedByAllowlist: options.blocked ?? false, + eligible: missingBins.length === 0, + requirements: { anyBins: [], bins: missingBins, env: [], config: [], os: [] }, + missing: { anyBins: [], bins: missingBins, env: [], config: [], os: [] }, + configChecks: [], + install: [], + }; +} + +function sessionsList(toolOverrides?: Record) { + return { + count: 1, + defaults: { contextTokens: 200_000, model: "gpt-5.5", modelProvider: "openai" }, + path: "", + sessions: [ + { + key: "main", + kind: "direct", + model: "gpt-5.5", + modelProvider: "openai", + status: "done", + updatedAt: Date.now(), + ...(toolOverrides ? { toolOverrides } : {}), + }, + ], + ts: Date.now(), + }; +} + +function configResponse(servers: Record, webSearch = true) { + const config = { + mcp: { servers }, + plugins: webSearch + ? { + entries: { brave: { enabled: true, config: { webSearch: { apiKey: "redacted" } } } }, + } + : undefined, + tools: { web: { search: webSearch ? { provider: "brave" } : { enabled: false } } }, + }; + return { + raw: JSON.stringify(config), + hash: "capability-menu-config", + sourceConfig: config, + runtimeConfig: config, + config, + }; +} + +async function latestToolOverrides(gateway: MockGatewayControls) { + const requests = await gateway.getRequests("sessions.patch"); + return (requests.at(-1)?.params as { toolOverrides?: unknown } | undefined)?.toolOverrides; +} + +async function openMenu(page: Page) { + const composer = page.locator(".agent-chat__input"); + await composer.getByRole("button", { name: "Add attachment" }).click(); + await expect + .poll(() => composer.getByRole("menuitem", { name: "Skills" }).isVisible()) + .toBe(true); + return composer; +} + +describeControlUiE2e("Control UI composer capability menu", () => { + beforeAll(async () => { + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + try { + server = await startControlUiE2eServer(); + } catch (error) { + await browser.close(); + throw error; + } + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("renders the root stack, proxies attachments, patches sparse overrides, and clears the pill", async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": configResponse({ + github: { url: "https://mcp.example.test", enabled: true }, + notion: { command: "notion-mcp", enabled: false }, + }), + "sessions.list": sessionsList({ + mcpServers: { github: false }, + mcpToolsDeny: { notion: ["delete_page"] }, + skills: { docs: false }, + webSearch: false, + }), + "skills.status": { + workspaceDir: "/tmp/openclaw-e2e/workspace", + managedSkillsDir: "/tmp/openclaw-e2e/skills", + skills: [ + skill("Docs"), + skill("Deploy", { disabled: true }), + skill("Broken", { missingDeps: true }), + skill("Private", { blocked: true }), + ], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + const pill = page.locator(".agent-chat__session-overrides-pill"); + await expect + .poll(async () => (await pill.textContent())?.replace(/\s+/g, " ").trim()) + .toBe("4 session overrides"); + + let composer = await openMenu(page); + const dropdown = composer.locator("wa-dropdown.agent-chat__capability-menu"); + await expect.poll(() => dropdown.getAttribute("data-view")).toBe("root"); + await expect + .poll(() => dropdown.getByRole("menuitem").allTextContents()) + .toEqual( + expect.arrayContaining([ + expect.stringContaining("Take photo"), + expect.stringContaining("Photo"), + expect.stringContaining("File"), + expect.stringContaining("Skills"), + expect.stringContaining("Connectors"), + expect.stringContaining("Manage plugins"), + ]), + ); + await expect + .poll(() => dropdown.getByRole("menuitemcheckbox", { name: "Web search" }).isVisible()) + .toBe(true); + const skillsRoot = dropdown.getByRole("menuitem", { name: /^Skills/ }); + await skillsRoot.focus(); + await skillsRoot.evaluate((item) => { + item + .closest("wa-dropdown") + ?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } })); + }); + await expect.poll(() => dropdown.getAttribute("data-view")).toBe("skills"); + await expect + .poll(() => dropdown.locator("wa-dropdown-item:focus").textContent()) + .toContain("Back"); + await dropdown.locator("wa-dropdown-item:focus").evaluate((item) => { + item + .closest("wa-dropdown") + ?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } })); + }); + await expect.poll(() => dropdown.getAttribute("data-view")).toBe("root"); + await expect + .poll(() => dropdown.locator("wa-dropdown-item:focus").textContent()) + .toContain("Take photo"); + await page.evaluate(() => { + const input = document.querySelector(".agent-chat__file-input"); + if (!input) { + throw new Error("file input missing"); + } + input.click = () => { + input.dataset.proxied = "true"; + }; + }); + await dropdown.getByRole("menuitem", { name: "File", exact: true }).click(); + await expect + .poll(() => page.locator(".agent-chat__file-input").getAttribute("data-proxied")) + .toBe("true"); + + await pill.getByRole("button", { name: "Clear session overrides" }).click(); + await expect.poll(() => latestToolOverrides(gateway)).toEqual(null); + await expect.poll(() => pill.count()).toBe(0); + + composer = await openMenu(page); + const menu = composer.locator("wa-dropdown.agent-chat__capability-menu"); + await menu.getByRole("menuitem", { name: /^Skills/ }).click(); + await expect.poll(() => menu.getAttribute("data-view")).toBe("skills"); + const docs = menu.getByRole("menuitem", { name: /^Docs/ }); + const broken = menu.getByRole("menuitem", { name: /Broken.*deps missing/ }); + const blocked = menu.getByRole("menuitem", { + name: /Private.*not available for this agent/, + }); + await expect.poll(() => broken.isDisabled()).toBe(true); + await expect.poll(() => blocked.isDisabled()).toBe(true); + await docs.click(); + await expect.poll(() => latestToolOverrides(gateway)).toEqual({ skills: { docs: false } }); + await docs.click(); + await expect.poll(() => latestToolOverrides(gateway)).toEqual({}); + + await menu.getByRole("menuitem", { name: "Back" }).click(); + await menu.getByRole("menuitem", { name: /^Connectors/ }).click(); + await expect.poll(() => menu.getAttribute("data-view")).toBe("connectors"); + const github = menu.getByRole("menuitem", { name: /^github/ }); + await github.click(); + await expect + .poll(() => latestToolOverrides(gateway)) + .toEqual({ + mcpServers: { github: false }, + }); + await github.click(); + await expect.poll(() => latestToolOverrides(gateway)).toEqual({}); + await expect + .poll(() => menu.getByRole("menuitem", { name: "Browse connectors" }).isDisabled()) + .toBe(false); + await expect + .poll(() => menu.getByRole("menuitem", { name: /Add MCP server/ }).count()) + .toBe(0); + + await menu.getByRole("menuitem", { name: "Back" }).click(); + const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" }); + await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true"); + await webSearch.click(); + await expect.poll(() => latestToolOverrides(gateway)).toEqual({ webSearch: false }); + await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("false"); + await webSearch.click(); + await expect.poll(() => latestToolOverrides(gateway)).toEqual({}); + await expect.poll(() => webSearch.getAttribute("aria-checked")).toBe("true"); + + const themeBackgrounds: string[] = []; + for (const mode of ["dark", "light"] as const) { + themeBackgrounds.push( + await menu.evaluate((node, nextMode) => { + document.documentElement.dataset.themeMode = nextMode; + const surface = node.shadowRoot?.querySelector('[part="menu"]'); + return surface ? getComputedStyle(surface).backgroundColor : ""; + }, mode), + ); + } + expect(themeBackgrounds[0]).not.toBe(""); + expect(themeBackgrounds[1]).not.toBe(""); + expect(themeBackgrounds[0]).not.toBe(themeBackgrounds[1]); + } finally { + await context.close(); + } + }); + + it("disables capability mutations and admin rows for a read-only operator", async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + await installMockGateway(page, { + operatorScopes: ["operator.read"], + methodResponses: { + "config.get": configResponse({ github: { url: "https://mcp.example.test" } }), + "sessions.list": sessionsList({ webSearch: false }), + "skills.status": { + workspaceDir: "/tmp/openclaw-e2e/workspace", + managedSkillsDir: "/tmp/openclaw-e2e/skills", + skills: [skill("Docs")], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + const composer = await openMenu(page); + const menu = composer.locator("wa-dropdown.agent-chat__capability-menu"); + const clear = composer.getByRole("button", { name: "Clear session overrides" }); + await expect.poll(() => clear.isDisabled()).toBe(true); + await expect.poll(() => clear.getAttribute("title")).toContain("Write access"); + await expect + .poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).isDisabled()) + .toBe(true); + await menu.getByRole("menuitem", { name: /^Skills/ }).click(); + const docs = menu.getByRole("menuitem", { name: /^Docs/ }); + await expect.poll(() => docs.isDisabled()).toBe(true); + await expect.poll(() => docs.getAttribute("title")).toContain("Write access"); + await menu.getByRole("menuitem", { name: "Back" }).click(); + await menu.getByRole("menuitem", { name: /^Connectors/ }).click(); + await expect + .poll(() => menu.getByRole("menuitem", { name: /^github/ }).isDisabled()) + .toBe(true); + const browse = menu.getByRole("menuitem", { name: "Browse connectors" }); + await expect.poll(() => browse.isDisabled()).toBe(true); + await expect.poll(() => browse.getAttribute("title")).toContain("Admin access"); + } finally { + await context.close(); + } + }); + + it("blocks capability mutations until the session row and runtime config load", async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + deferredMethods: ["sessions.list", "config.get"], + methodResponses: { + "skills.status": { + workspaceDir: "/tmp/openclaw-e2e/workspace", + managedSkillsDir: "/tmp/openclaw-e2e/skills", + skills: [skill("Docs")], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await Promise.all([ + gateway.waitForRequest("sessions.list"), + gateway.waitForRequest("config.get"), + ]); + const composer = await openMenu(page); + const menu = composer.locator("wa-dropdown.agent-chat__capability-menu"); + const webSearch = menu.getByRole("menuitemcheckbox", { name: "Web search" }); + await expect.poll(() => webSearch.isDisabled()).toBe(true); + await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…"); + await webSearch.evaluate((item) => { + item + .closest("wa-dropdown") + ?.dispatchEvent(new CustomEvent("wa-select", { bubbles: true, detail: { item } })); + }); + expect(await gateway.getRequests("sessions.patch")).toHaveLength(0); + + await gateway.resolveDeferred( + "sessions.list", + sessionsList({ + mcpToolsDeny: { notion: ["delete_page"] }, + webSearch: true, + }), + ); + await expect.poll(() => webSearch.isDisabled()).toBe(true); + await expect.poll(() => webSearch.getAttribute("title")).toBe("Loading…"); + expect(await gateway.getRequests("sessions.patch")).toHaveLength(0); + + await gateway.resolveDeferred("config.get", configResponse({}, false)); + await expect.poll(() => webSearch.isDisabled()).toBe(false); + await webSearch.click(); + await expect + .poll(() => latestToolOverrides(gateway)) + .toEqual({ + mcpToolsDeny: { notion: ["delete_page"] }, + }); + } finally { + await context.close(); + } + }); + + it("shows empty skills and connector states", async () => { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + await installMockGateway(page, { + methodResponses: { + "config.get": configResponse({}, false), + "sessions.list": sessionsList(), + "skills.status": { + workspaceDir: "/tmp/openclaw-e2e/workspace", + managedSkillsDir: "/tmp/openclaw-e2e/skills", + skills: [], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + const composer = await openMenu(page); + const menu = composer.locator("wa-dropdown.agent-chat__capability-menu"); + await expect + .poll(() => menu.getByRole("menuitemcheckbox", { name: "Web search" }).count()) + .toBe(1); + await menu.getByRole("menuitem", { name: /^Skills/ }).click(); + await expect.poll(() => menu.getByText("No skills available.").isVisible()).toBe(true); + await menu.getByRole("menuitem", { name: "Back" }).click(); + await menu.getByRole("menuitem", { name: /^Connectors/ }).click(); + await expect.poll(() => menu.getByText("No MCP servers configured.").isVisible()).toBe(true); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index f313d7f724a4..2ebc5798a0d2 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4994,6 +4994,34 @@ export const en: TranslationMap = { attachPhoto: "Photo", attachFile: "Attach file", attachFileOption: "File", + menu: { + back: "Back", + skills: "Skills", + connectors: "Connectors", + webSearch: "Web search", + managePlugins: "Manage plugins", + manageSkills: "Manage skills", + browseConnectors: "Browse connectors", + addMcpServer: "Add MCP server…", + toolAccess: "Tool access", + enabledCount: "{count} on", + loadingSkills: "Loading skills…", + skillsLoadFailed: "Couldn’t load skills.", + noSkills: "No skills available.", + noConnectors: "No MCP servers configured.", + depsMissing: "deps missing", + skillBlocked: "not available for this agent", + sessionTag: "session", + offlineBlocked: "Connect to the gateway to change session capabilities.", + readOnlyBlocked: "Write access is required to change session capabilities.", + adminBlocked: "Admin access is required to manage connectors.", + savingBlocked: "Wait for the current session capability change to finish.", + }, + overrides: { + count: "{count} session overrides", + countOne: "{count} session override", + clear: "Clear session overrides", + }, contextUsage: { title: "Context usage details", open: "Open context usage details", diff --git a/ui/src/lib/sessions/patch.ts b/ui/src/lib/sessions/patch.ts index 52085c78cb83..b7496a79b0c3 100644 --- a/ui/src/lib/sessions/patch.ts +++ b/ui/src/lib/sessions/patch.ts @@ -1,5 +1,12 @@ import type { FastMode, SessionsPatchResult } from "../../api/types.ts"; +export type SessionToolOverrides = { + mcpServers?: Record; + mcpToolsDeny?: Record; + skills?: Record; + webSearch?: boolean; +}; + export type SessionPatch = { label?: string | null; category?: string | null; @@ -10,6 +17,7 @@ export type SessionPatch = { fastMode?: FastMode | null; verboseLevel?: string | null; reasoningLevel?: string | null; + toolOverrides?: SessionToolOverrides | null; archived?: boolean; pinned?: boolean; unread?: boolean; diff --git a/ui/src/lib/sessions/tool-overrides.test.ts b/ui/src/lib/sessions/tool-overrides.test.ts new file mode 100644 index 000000000000..03e5dc336cd8 --- /dev/null +++ b/ui/src/lib/sessions/tool-overrides.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + countSessionToolOverrides, + nextBooleanToolOverrides, + nextWebSearchToolOverrides, + resolveToolOverrideState, + sessionToolOverrideNames, +} from "./tool-overrides.ts"; + +describe("session tool overrides", () => { + it("resolves sparse booleans against their base state", () => { + expect(resolveToolOverrideState(true, undefined)).toBe(true); + expect(resolveToolOverrideState(false, undefined)).toBe(false); + expect(resolveToolOverrideState(true, false)).toBe(false); + expect(resolveToolOverrideState(false, true)).toBe(true); + }); + + it("adds and removes named overrides without disturbing sibling groups", () => { + const current = { + mcpServers: { github: false }, + mcpToolsDeny: { notion: ["delete_page"] }, + webSearch: false, + }; + expect(nextBooleanToolOverrides(current, "skills", "release", false, true)).toEqual({ + ...current, + skills: { release: false }, + }); + expect(nextBooleanToolOverrides(current, "mcpServers", "github", true, true)).toEqual({ + mcpToolsDeny: { notion: ["delete_page"] }, + webSearch: false, + }); + expect(current).toEqual({ + mcpServers: { github: false }, + mcpToolsDeny: { notion: ["delete_page"] }, + webSearch: false, + }); + }); + + it("restores default-on web search by removing the key", () => { + const off = nextWebSearchToolOverrides({ skills: { docs: true } }, false); + expect(off).toEqual({ skills: { docs: true }, webSearch: false }); + expect(nextWebSearchToolOverrides(off, true)).toEqual({ skills: { docs: true } }); + expect(nextWebSearchToolOverrides({}, true, false)).toEqual({ webSearch: true }); + expect(nextWebSearchToolOverrides({ webSearch: true }, false, false)).toEqual({}); + }); + + it("counts override categories and returns deterministic tooltip names", () => { + const overrides = { + mcpServers: { zeta: false, alpha: true }, + mcpToolsDeny: { tools: ["danger"] }, + skills: { beta: false }, + webSearch: false, + }; + expect(countSessionToolOverrides(overrides)).toBe(5); + expect(sessionToolOverrideNames(overrides, "Web search")).toEqual([ + "alpha", + "beta", + "tools", + "Web search", + "zeta", + ]); + }); +}); diff --git a/ui/src/lib/sessions/tool-overrides.ts b/ui/src/lib/sessions/tool-overrides.ts new file mode 100644 index 000000000000..befc62650698 --- /dev/null +++ b/ui/src/lib/sessions/tool-overrides.ts @@ -0,0 +1,81 @@ +import type { SessionToolOverrides } from "./patch.ts"; + +type BooleanOverrideGroup = "mcpServers" | "skills"; + +function copyOverrides(overrides: SessionToolOverrides | null | undefined): SessionToolOverrides { + return { + ...(overrides?.mcpServers ? { mcpServers: { ...overrides.mcpServers } } : {}), + ...(overrides?.mcpToolsDeny + ? { + mcpToolsDeny: Object.fromEntries( + Object.entries(overrides.mcpToolsDeny).map(([name, tools]) => [name, [...tools]]), + ), + } + : {}), + ...(overrides?.skills ? { skills: { ...overrides.skills } } : {}), + ...(overrides?.webSearch !== undefined ? { webSearch: overrides.webSearch } : {}), + }; +} + +export function resolveToolOverrideState(baseEnabled: boolean, override: boolean | undefined) { + return override ?? baseEnabled; +} + +export function nextBooleanToolOverrides( + current: SessionToolOverrides | null | undefined, + group: BooleanOverrideGroup, + name: string, + nextEnabled: boolean, + baseEnabled: boolean, +): SessionToolOverrides { + const next = copyOverrides(current); + const values = { ...next[group] }; + if (nextEnabled === baseEnabled) { + delete values[name]; + } else { + values[name] = nextEnabled; + } + if (Object.keys(values).length === 0) { + delete next[group]; + } else { + next[group] = values; + } + return next; +} + +export function nextWebSearchToolOverrides( + current: SessionToolOverrides | null | undefined, + nextEnabled: boolean, + baseEnabled = true, +): SessionToolOverrides { + const next = copyOverrides(current); + if (nextEnabled === baseEnabled) { + delete next.webSearch; + } else { + next.webSearch = nextEnabled; + } + return next; +} + +export function countSessionToolOverrides( + overrides: SessionToolOverrides | null | undefined, +): number { + return ( + Object.keys(overrides?.mcpServers ?? {}).length + + Object.keys(overrides?.skills ?? {}).length + + Object.keys(overrides?.mcpToolsDeny ?? {}).length + + (overrides?.webSearch !== undefined ? 1 : 0) + ); +} + +export function sessionToolOverrideNames( + overrides: SessionToolOverrides | null | undefined, + webSearchLabel: string, +): string[] { + return [ + ...Object.keys(overrides?.mcpServers ?? {}), + ...Object.keys(overrides?.skills ?? {}), + ...Object.keys(overrides?.mcpToolsDeny ?? {}), + ...(overrides?.webSearch !== undefined ? [webSearchLabel] : []), + ].toSorted((left, right) => left.localeCompare(right)); +} diff --git a/ui/src/pages/chat/chat-composer-capability-host.test.ts b/ui/src/pages/chat/chat-composer-capability-host.test.ts new file mode 100644 index 000000000000..389da2b9c35f --- /dev/null +++ b/ui/src/pages/chat/chat-composer-capability-host.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ConfigSnapshot, GatewaySessionRow } from "../../api/types.ts"; +import type { ApplicationContext } from "../../app/context.ts"; +import { ChatComposerCapabilityHost } from "./chat-composer-capability-host.ts"; +import type { ChatPageHost } from "./chat-state-host.ts"; + +function createContext(configSnapshot: ConfigSnapshot | null): ApplicationContext { + return { + gateway: { snapshot: { hello: null } }, + navigate: vi.fn(), + runtimeConfig: { + ensureLoaded: vi.fn(async () => undefined), + state: { configLoading: false, configSnapshot }, + }, + } as unknown as ApplicationContext; +} + +function createState(): ChatPageHost { + return { + basePath: "", + client: {} as GatewayBrowserClient, + connected: true, + sessionKey: "main", + } as ChatPageHost; +} + +describe("ChatComposerCapabilityHost", () => { + it("blocks session mutations until the row and runtime config have loaded", () => { + const host = new ChatComposerCapabilityHost(vi.fn()); + const context = createContext(null); + const state = createState(); + const session = { key: "main" } as GatewaySessionRow; + + expect(host.props(context, state, undefined, "main").mutationBlockedReason).toBe("Loading…"); + expect(host.props(context, state, session, "main").mutationBlockedReason).toBe("Loading…"); + + context.runtimeConfig.state.configSnapshot = { + runtimeConfig: { tools: { web: { search: { enabled: false } } } }, + }; + const props = host.props(context, state, session, "main"); + expect(props.mutationBlockedReason).toBeNull(); + expect(props.webSearchBaseEnabled).toBe(false); + }); + + it("derives capability defaults from the active runtime snapshot", () => { + const host = new ChatComposerCapabilityHost(vi.fn()); + const props = host.props( + createContext({ + sourceConfig: { + mcp: { servers: { source: { command: "source-mcp", enabled: true } } }, + tools: { web: { search: { enabled: false } } }, + }, + runtimeConfig: { + mcp: { servers: { runtime: { command: "runtime-mcp", enabled: false } } }, + tools: { web: { search: { enabled: true } } }, + }, + }), + createState(), + { key: "main" } as GatewaySessionRow, + "main", + ); + + expect(props.mcpServers.map(({ name, enabled }) => ({ name, enabled }))).toEqual([ + { name: "runtime", enabled: false }, + ]); + expect(props.webSearchBaseEnabled).toBe(true); + }); +}); diff --git a/ui/src/pages/chat/chat-composer-capability-host.ts b/ui/src/pages/chat/chat-composer-capability-host.ts new file mode 100644 index 000000000000..2596cb05257d --- /dev/null +++ b/ui/src/pages/chat/chat-composer-capability-host.ts @@ -0,0 +1,209 @@ +import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow, SkillStatusEntry } from "../../api/types.ts"; +import type { ApplicationContext } from "../../app/context.ts"; +import { readGatewayOperatorAccess } from "../../app/operator-access.ts"; +import { t } from "../../i18n/index.ts"; +import { summarizeMcpServers } from "../../lib/config/mcp-servers.ts"; +import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; +import type { SessionToolOverrides } from "../../lib/sessions/patch.ts"; +import { loadSkillStatusReport } from "../../lib/skills/index.ts"; +import { refreshCurrentChatSessionList } from "./chat-session.ts"; +import { patchChatSessionSettings } from "./chat-settings-patches.ts"; +import type { ChatPageHost } from "./chat-state-host.ts"; +import type { + ChatComposerMenuSkill, + ChatComposerPlusMenuProps, +} from "./components/chat-composer-plus-menu.ts"; + +type CapabilityMenuProps = Omit< + ChatComposerPlusMenuProps, + | "attachments" + | "disabled" + | "open" + | "view" + | "toolOverrides" + | "onOpenChange" + | "onViewChange" + | "showCapabilities" +>; + +function webSearchBaseEnabled(config: Record | null): boolean { + return asRecord(asRecord(asRecord(config?.tools)?.web)?.search)?.enabled !== false; +} + +function toComposerSkill(skill: SkillStatusEntry): ChatComposerMenuSkill { + const missingDeps = Object.values(skill.missing).some((values) => values.length > 0); + const blocked = skill.blockedByAllowlist || skill.blockedByAgentFilter === true; + const baseEnabled = !skill.disabled; + return { + key: skill.skillKey, + name: skill.name, + enabled: baseEnabled && !missingDeps && !blocked, + baseEnabled, + ...(missingDeps ? { missingDeps: true } : {}), + ...(blocked ? { blocked: true } : {}), + }; +} + +export class ChatComposerCapabilityHost { + private readonly skills = new Map(); + private readonly loading = new Set(); + private readonly loadErrors = new Set(); + private readonly patchTokens = new Map(); + private client: GatewayBrowserClient | null = null; + + constructor(private readonly notify: () => void) {} + + private loadSkills(context: ApplicationContext, state: ChatPageHost, agentId: string): void { + const config = context.runtimeConfig.state; + if (!config.configSnapshot && !config.configLoading) { + void context.runtimeConfig.ensureLoaded().catch(() => undefined); + } + const client = state.client; + if (!state.connected || !client || this.skills.has(agentId) || this.loading.has(agentId)) { + return; + } + this.loadErrors.delete(agentId); + this.loading.add(agentId); + this.notify(); + void loadSkillStatusReport(client, agentId) + .then((report) => { + if (report && state.client === client && this.client === client) { + this.skills.set( + agentId, + report.skills + .map(toComposerSkill) + .toSorted((left, right) => left.name.localeCompare(right.name)), + ); + } + }) + .catch(() => { + if (state.client === client && this.client === client) { + this.loadErrors.add(agentId); + } + }) + .finally(() => { + if (this.client === client) { + this.loading.delete(agentId); + this.notify(); + } + }); + } + + private patch( + context: ApplicationContext, + state: ChatPageHost, + next: SessionToolOverrides | null, + ): void { + if ( + !state.connected || + !state.client || + !readGatewayOperatorAccess(context.gateway.snapshot).canWrite || + this.patchTokens.has(state.sessionKey) + ) { + return; + } + const sessionKey = state.sessionKey; + const client = state.client; + const patchToken = Symbol(sessionKey); + this.patchTokens.set(sessionKey, patchToken); + const isCurrentPatch = () => + this.client === client && + state.client === client && + state.sessionKey === sessionKey && + this.patchTokens.get(sessionKey) === patchToken; + state.lastError = null; + state.chatError = null; + this.notify(); + void patchChatSessionSettings( + state, + sessionKey, + { toolOverrides: next }, + { + ...scopedAgentParamsForSession(state, sessionKey), + }, + ) + .catch(async (error: unknown) => { + if (!isCurrentPatch()) { + return; + } + try { + await refreshCurrentChatSessionList(state); + } catch { + // The unchanged session row remains authoritative if refresh also fails. + } + if (!isCurrentPatch()) { + return; + } + state.lastError = error instanceof Error ? error.message : String(error); + state.chatError = state.lastError; + state.requestUpdate?.(); + }) + .finally(() => { + if (this.patchTokens.get(sessionKey) === patchToken) { + this.patchTokens.delete(sessionKey); + if (this.client === client) { + this.notify(); + } + } + }); + } + + props( + context: ApplicationContext, + state: ChatPageHost, + session: GatewaySessionRow | undefined, + agentId: string, + ): CapabilityMenuProps { + if (this.client !== state.client) { + this.client = state.client; + this.skills.clear(); + this.loading.clear(); + this.loadErrors.clear(); + this.patchTokens.clear(); + } + // Sparse session overrides resolve against active runtime defaults, so display and key + // removal decisions must use the same runtime snapshot that executes the session. + const runtimeConfig = context.runtimeConfig.state.configSnapshot?.runtimeConfig ?? null; + const access = readGatewayOperatorAccess(context.gateway.snapshot); + const gatewayAvailable = state.connected && Boolean(state.client); + const capabilitiesReady = gatewayAvailable && session !== undefined && runtimeConfig !== null; + const mutationBlockedReason = !gatewayAvailable + ? t("chat.composer.menu.offlineBlocked") + : !capabilitiesReady + ? t("common.loading") + : !access.canWrite + ? t("chat.composer.menu.readOnlyBlocked") + : this.patchTokens.has(state.sessionKey) + ? t("chat.composer.menu.savingBlocked") + : null; + const adminBlockedReason = !gatewayAvailable + ? t("chat.composer.menu.offlineBlocked") + : !access.canAdmin + ? t("chat.composer.menu.adminBlocked") + : null; + return { + basePath: state.basePath, + skills: + this.skills.get(agentId)?.map((skill) => + Object.assign({}, skill, { + enabled: + skill.missingDeps || skill.blocked + ? false + : (session?.toolOverrides?.skills?.[skill.key] ?? skill.baseEnabled), + }), + ) ?? null, + skillsLoading: this.loading.has(agentId), + skillsError: this.loadErrors.has(agentId), + mcpServers: summarizeMcpServers(runtimeConfig) ?? [], + webSearchBaseEnabled: webSearchBaseEnabled(runtimeConfig), + mutationBlockedReason, + canAdmin: access.canAdmin && gatewayAvailable, + adminBlockedReason, + onLoadSkills: () => this.loadSkills(context, state, agentId), + onPatchToolOverrides: (next) => this.patch(context, state, next), + onNavigate: (routeId, options) => context.navigate(routeId, options), + }; + } +} diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 4e3e024628fb..b9505024fcb6 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -40,6 +40,7 @@ import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { PollController } from "../../lit/poll-controller.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import type { BoardChatDockSize } from "./board-session-surface.ts"; +import { ChatComposerCapabilityHost } from "./chat-composer-capability-host.ts"; import type { ChatHistoryPagination } from "./chat-history-pagination.ts"; import { sendSessionObserverVisibility } from "./chat-observer.ts"; import { @@ -103,6 +104,9 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @property({ attribute: false }) boardProvider?: BoardProvider; protected readonly chatState = new ChatStateController(this); + protected readonly composerCapabilities = new ChatComposerCapabilityHost(() => + this.requestUpdate(), + ); protected readonly transcript = new ChatTranscriptController(this); protected readonly questionPromptState = createQuestionPromptState(() => { this.questionPrompts = listQuestionPrompts(this.questionPromptState); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 7bbc233c7194..b8e09b91d773 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -108,18 +108,18 @@ export class ChatPane extends ChatPaneHeader { layout: state.sidebarLayout, paneWidth: this.paneWidth, }); - const runtimeConfigState = this.context.runtimeConfig.state; - const configSnapshot = runtimeConfigState.configSnapshot; - const serverQueueMode = resolveControlUiServerQueueMode(configSnapshot?.runtimeConfig, { - configNeedsApply: runtimeConfigState.configNeedsApply, - effectiveMode: state.chatEffectiveQueueMode, - sessionMetadataLoaded: - selectedSession !== undefined || state.chatEffectiveQueueMode !== undefined, - sessionMode: state.chatQueueModeOverride, - }); state.chatFollowUpMode = resolveControlUiFollowUpMode( state.settings.chatFollowUpMode, - serverQueueMode, + resolveControlUiServerQueueMode( + this.context.runtimeConfig.state.configSnapshot?.runtimeConfig, + { + configNeedsApply: this.context.runtimeConfig.state.configNeedsApply, + effectiveMode: state.chatEffectiveQueueMode, + sessionMetadataLoaded: + selectedSession !== undefined || state.chatEffectiveQueueMode !== undefined, + sessionMode: state.chatQueueModeOverride, + }, + ), ); const currentAgentId = resolveChatAgentId(state); const catalogKey = parseCatalogSessionKey(state.sessionKey); @@ -191,10 +191,9 @@ export class ChatPane extends ChatPaneHeader { column.panels.some((panel) => panel.slot === "chat"), ); const sidebarRegionCollapsed = isSidebarRegionCollapsed(sidebarLayout, this.paneWidth); - const primaryWidth = sidebarPrimaryWidth(sidebarLayout, this.paneWidth); const chatLayoutWidth = sidebarRegionCollapsed ? this.paneWidth - : (sidebarChatColumn?.width ?? primaryWidth); + : (sidebarChatColumn?.width ?? sidebarPrimaryWidth(sidebarLayout, this.paneWidth)); const sessionWorkspace = createSessionWorkspaceProps(state, { draftScope: this.paneId, narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH, @@ -203,8 +202,7 @@ export class ChatPane extends ChatPaneHeader { !sessionWorkspace.collapsed && !sessionWorkspace.narrowLayout && sessionWorkspace.dock !== "bottom"; - // The workspace rail claims the side slot first; the tasks rail needs - // room for both columns before it may side-dock next to it. + // The workspace rail claims the first side slot; tasks need room for both columns. const backgroundTasks = createBackgroundTasksProps(state, { narrowLayout: chatLayoutWidth < @@ -214,8 +212,7 @@ export class ChatPane extends ChatPaneHeader { }, }); const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout; - // Side-docked workspace surfaces narrow the conversation region; bottom - // strips do not affect whether the session rail can dock beside it. + // Only side-docked rails narrow the conversation region. const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0); const chatMainWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH; const selectedSessionRailMode = @@ -231,7 +228,6 @@ export class ChatPane extends ChatPaneHeader { activeRunIds: selectedSession?.activeRunIds, usageByRun: state.chatRunUsageById, }); - const loadSidebarFullMessage = createSidebarFullMessageLoader(state, Boolean(catalogKey)); const projectionRunId = resolveChatProjectionRunId({ localRunId: state.chatRunId, activeRunIds: selectedSession?.activeRunIds, @@ -371,6 +367,10 @@ export class ChatPane extends ChatPaneHeader { } : undefined, sessions: state.sessionsResult, + toolOverrides: selectedSession?.toolOverrides, + capabilityMenu: catalogKey + ? undefined + : this.composerCapabilities.props(this.context, state, selectedSession, currentAgentId), swarmSessions: this.swarmHydrator?.rows ?? [], sessionHost: { assistantAgentId: state.assistantAgentId, @@ -615,7 +615,7 @@ export class ChatPane extends ChatPaneHeader { detail: html`, + patch: Pick, options: { agentId?: string; reconcile?: (result: SessionsPatchResult) => Promise | void; diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 38e67f9dde9a..4e67119df1df 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -4167,6 +4167,13 @@ describe("chat attachment picker", () => { expect(clickInput).toHaveBeenCalledTimes(1); }); + it("keeps attachment-only composers free of capability rows", () => { + const container = renderChatView(); + + expect(container.querySelectorAll(".agent-chat__attach-menu-option")).toHaveLength(3); + expect(container.querySelector(".agent-chat__capability-menu-item")).toBeNull(); + }); + it("opens the camera input from the attachment menu and attaches the captured photo", async () => { const onAttachmentsChange = vi.fn(); const container = renderChatView({ onAttachmentsChange }); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 088daa5043ae..53da516d7839 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -31,6 +31,7 @@ import type { import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts"; import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts"; import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary.ts"; +import type { SessionToolOverrides } from "../../lib/sessions/patch.ts"; import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts"; import type { ChatRunStartupStatus } from "./chat-run-startup.ts"; import type { ChatSessionCompanionThread } from "./chat-session-companion.ts"; @@ -40,6 +41,7 @@ import { renderBackgroundTasksRail, type BackgroundTasksProps, } from "./components/chat-background-tasks.ts"; +import type { ChatComposerPlusMenuProps } from "./components/chat-composer-plus-menu.ts"; import type { ChatComposerDisabledBanner } from "./components/chat-composer-types.ts"; import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts"; import { inlineChatImageFromEvent, openInlineChatImage } from "./components/chat-image-lightbox.ts"; @@ -160,6 +162,18 @@ export type ChatProps = { workspaceConflict?: WorkspaceResultConflict; onDismissWorkspaceConflict?: () => void; sessions: SessionsListResult | null; + toolOverrides?: SessionToolOverrides; + capabilityMenu?: Omit< + ChatComposerPlusMenuProps, + | "attachments" + | "disabled" + | "open" + | "view" + | "toolOverrides" + | "onOpenChange" + | "onViewChange" + | "showCapabilities" + >; swarmSessions?: readonly GatewaySessionRow[]; /** Host context resolving global-alias session keys (scope=global fleets). */ sessionHost?: UiSessionDefaultsHost | null; @@ -404,6 +418,8 @@ export function renderChat(props: ChatProps) { queue: props.queue, draft: props.draft, sessions: props.sessions, + toolOverrides: props.toolOverrides, + capabilityMenu: props.capabilityMenu, providerUsage: props.providerUsage, assistantName: props.assistantName, sendShortcut: props.sendShortcut, diff --git a/ui/src/pages/chat/components/chat-attachments.ts b/ui/src/pages/chat/components/chat-attachments.ts index f5f9bd9a8f04..a74ccd531181 100644 --- a/ui/src/pages/chat/components/chat-attachments.ts +++ b/ui/src/pages/chat/components/chat-attachments.ts @@ -24,7 +24,7 @@ const PASTED_TEXT_PREVIEW_MAX_LENGTH = 20; const largePastedTextAttachments = new WeakSet(); const pastedTextPreviews = new WeakMap(); -type ChatAttachmentControlsProps = { +export type ChatAttachmentControlsProps = { attachments?: ChatAttachment[]; disabled?: boolean; getAttachments?: () => ChatAttachment[]; @@ -100,7 +100,7 @@ function currentAttachments(props: ChatAttachmentControlsProps): ChatAttachment[ return props.getAttachments?.() ?? props.attachments ?? []; } -function clickComposerInput(target: HTMLElement, selector: string) { +export function clickComposerInput(target: HTMLElement, selector: string) { target.closest("details")?.removeAttribute("open"); target .closest(".agent-chat__composer-shell, .new-session-page__composer") diff --git a/ui/src/pages/chat/components/chat-composer-plus-menu.ts b/ui/src/pages/chat/components/chat-composer-plus-menu.ts new file mode 100644 index 000000000000..d21ad35be08d --- /dev/null +++ b/ui/src/pages/chat/components/chat-composer-plus-menu.ts @@ -0,0 +1,445 @@ +import "@awesome.me/webawesome/dist/components/switch/switch.js"; +import { html, nothing, type TemplateResult } from "lit"; +import { pathForPluginsHubTab, pathForRoute } from "../../../app-route-paths.ts"; +import type { ApplicationNavigationOptions } from "../../../app/context.ts"; +import { icons } from "../../../components/icons.ts"; +import "../../../components/tooltip.ts"; +import "../../../components/web-awesome.ts"; +import { t } from "../../../i18n/index.ts"; +import type { McpServerSummary } from "../../../lib/config/mcp-servers.ts"; +import type { SessionToolOverrides } from "../../../lib/sessions/patch.ts"; +import { + nextBooleanToolOverrides, + nextWebSearchToolOverrides, + resolveToolOverrideState, +} from "../../../lib/sessions/tool-overrides.ts"; +import { clickComposerInput, type ChatAttachmentControlsProps } from "./chat-attachments.ts"; + +export type ChatComposerPlusMenuView = "root" | "skills" | "connectors" | `tools:${string}`; + +export type ChatComposerMenuSkill = { + key: string; + name: string; + enabled: boolean; + baseEnabled: boolean; + missingDeps?: boolean; + blocked?: boolean; +}; + +type MenuRoute = "mcp" | "plugins" | "skills"; + +export type ChatComposerPlusMenuProps = { + attachments: ChatAttachmentControlsProps; + showCapabilities: boolean; + basePath: string; + disabled: boolean; + open: boolean; + view: ChatComposerPlusMenuView; + toolOverrides: SessionToolOverrides | null | undefined; + skills: readonly ChatComposerMenuSkill[] | null; + skillsLoading: boolean; + skillsError: boolean; + mcpServers: readonly McpServerSummary[]; + webSearchBaseEnabled: boolean; + mutationBlockedReason: string | null; + canAdmin: boolean; + adminBlockedReason: string | null; + onOpenChange: (open: boolean) => void; + onViewChange: (view: ChatComposerPlusMenuView) => void; + onLoadSkills: () => void; + onPatchToolOverrides: (next: SessionToolOverrides | null) => void; + onNavigate: (routeId: MenuRoute, options?: ApplicationNavigationOptions) => void; + onAddServer?: () => void; + onOpenToolAccess?: (serverName: string) => void; +}; + +function menuDivider(): TemplateResult { + return html``; +} + +function internalLink(href: string, label: string): TemplateResult { + return html` event.preventDefault()} + >${label}`; +} + +function renderBackRow() { + return html` + + + ${t("chat.composer.menu.back")} + + ${menuDivider()} + `; +} + +function renderRootView(props: ChatComposerPlusMenuProps) { + const connectorCount = props.mcpServers.filter((server) => + resolveToolOverrideState(server.enabled, props.toolOverrides?.mcpServers?.[server.name]), + ).length; + const hasSkillOverrides = Object.keys(props.toolOverrides?.skills ?? {}).length > 0; + const enabledSkillCount = props.skills?.filter((skill) => skill.enabled).length ?? 0; + const webSearchEnabled = resolveToolOverrideState( + props.webSearchBaseEnabled, + props.toolOverrides?.webSearch, + ); + const attachments = html` + + + ${t("chat.composer.takePhoto")} + + + + ${t("chat.composer.attachPhoto")} + + + + ${t("chat.composer.attachFileOption")} + + `; + if (!props.showCapabilities) { + return attachments; + } + // Core gates managed and Codex-native search. Config sniffing misses env/native providers; + // without a provider, this session override is a harmless no-op. + return html` + ${attachments} ${menuDivider()} + + + ${t("chat.composer.menu.skills")} + + ${hasSkillOverrides + ? html`${t("chat.composer.menu.enabledCount", { + count: String(enabledSkillCount), + })}` + : nothing} + + + + + + ${t("chat.composer.menu.connectors")} + + ${connectorCount} + + + + + + ${t("chat.composer.menu.webSearch")} + + ${menuDivider()} + + + ${internalLink( + pathForRoute("plugins", props.basePath), + t("chat.composer.menu.managePlugins"), + )} + + `; +} + +function renderSkillView(props: ChatComposerPlusMenuProps) { + const disabledReason = props.mutationBlockedReason; + const rows = props.skillsLoading + ? html`
+ ${t("chat.composer.menu.loadingSkills")} +
` + : props.skillsError + ? html`` + : !props.skills || props.skills.length === 0 + ? html`
+ ${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 html` + ${renderBackRow()} ${rows} ${menuDivider()} + + ${internalLink(pathForRoute("skills", props.basePath), t("chat.composer.menu.manageSkills"))} + + `; +} + +function renderConnectorView(props: ChatComposerPlusMenuProps) { + const disabledReason = props.mutationBlockedReason; + const rows = + props.mcpServers.length === 0 + ? html`
+ ${t("chat.composer.menu.noConnectors")} +
` + : props.mcpServers.map((server, index) => { + const override = 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} + + + + + ${props.onOpenToolAccess + ? html` + + ${t("chat.composer.menu.toolAccess")} + ` + : nothing} + `; + }); + const adminDisabled = !props.canAdmin; + return html` + ${renderBackRow()} ${rows} ${menuDivider()} + ${props.onAddServer + ? html` + + ${t("chat.composer.menu.addMcpServer")} + ` + : nothing} + + + ${internalLink( + pathForPluginsHubTab("discover", props.basePath), + t("chat.composer.menu.browseConnectors"), + )} + + `; +} + +function handleMenuSelection( + event: CustomEvent<{ item: { value?: string } }>, + props: ChatComposerPlusMenuProps, +) { + const value = event.detail.item.value ?? ""; + const menu = event.currentTarget as HTMLElement; + const changeView = (view: ChatComposerPlusMenuView) => { + props.onViewChange(view); + requestAnimationFrame(() => + 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("root"); + return; + } + if (value === "open-skills" || value === "open-connectors") { + event.preventDefault(); + changeView(value === "open-skills" ? "skills" : "connectors"); + return; + } + if (value === "toggle-web-search") { + event.preventDefault(); + if (props.mutationBlockedReason) { + return; + } + const enabled = resolveToolOverrideState( + props.webSearchBaseEnabled, + props.toolOverrides?.webSearch, + ); + props.onPatchToolOverrides( + nextWebSearchToolOverrides(props.toolOverrides, !enabled, props.webSearchBaseEnabled), + ); + return; + } + if (value.startsWith("skill:")) { + event.preventDefault(); + const skill = props.skills?.[Number(value.slice("skill:".length))]; + if (skill && !skill.missingDeps && !skill.blocked && !props.mutationBlockedReason) { + props.onPatchToolOverrides( + nextBooleanToolOverrides( + props.toolOverrides, + "skills", + skill.key, + !skill.enabled, + skill.baseEnabled, + ), + ); + } + return; + } + if (value.startsWith("connector:")) { + event.preventDefault(); + const server = props.mcpServers[Number(value.slice("connector:".length))]; + if (server && !props.mutationBlockedReason) { + const enabled = resolveToolOverrideState( + server.enabled, + props.toolOverrides?.mcpServers?.[server.name], + ); + props.onPatchToolOverrides( + nextBooleanToolOverrides( + props.toolOverrides, + "mcpServers", + server.name, + !enabled, + server.enabled, + ), + ); + } + return; + } + if (value.startsWith("tools:")) { + const server = props.mcpServers[Number(value.slice("tools:".length))]; + if (server) { + props.onOpenToolAccess?.(server.name); + } + return; + } + if (value === "add-server") { + props.onAddServer?.(); + return; + } + if (value === "manage-skills") { + props.onNavigate("skills"); + } else if (value === "manage-plugins") { + props.onNavigate("plugins"); + } else if (value === "browse-connectors") { + props.onNavigate("plugins", { + pathname: pathForPluginsHubTab("discover", props.basePath), + }); + } +} + +export function renderChatComposerPlusMenu(props: ChatComposerPlusMenuProps) { + const view = props.showCapabilities ? props.view : "root"; + const content = + view === "skills" + ? renderSkillView(props) + : view === "connectors" + ? renderConnectorView(props) + : renderRootView(props); + return html` + ) => + handleMenuSelection(event, props)} + @wa-show=${() => { + if (!props.open) { + props.onOpenChange(true); + } + props.onLoadSkills(); + }} + @wa-hide=${() => { + if (props.open) { + props.onOpenChange(false); + } + }} + data-view=${view} + > + + ${content} + + `; +} diff --git a/ui/src/pages/chat/components/chat-composer-state.ts b/ui/src/pages/chat/components/chat-composer-state.ts index 5f9e3720e300..968b0eb08282 100644 --- a/ui/src/pages/chat/components/chat-composer-state.ts +++ b/ui/src/pages/chat/components/chat-composer-state.ts @@ -29,6 +29,8 @@ function createChatComposerState(): ChatComposerState { microphoneDevices: [], microphoneWarning: null, microphoneDiscoveryRequest: 0, + capabilityMenuOpen: false, + capabilityMenuView: "root", textareaRef: null, dictation: null, dictationDraftKey: null, diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index a15cf230ccee..02cf09785495 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -7,6 +7,7 @@ import type { ChatAttachment, ChatQueueItem } from "../../../lib/chat/chat-types import type { SlashCommandDef } from "../../../lib/chat/commands.ts"; import type { ControlUiFollowUpMode } from "../../../lib/chat/follow-up-mode.ts"; import type { ProviderUsageDisplayProps } from "../../../lib/provider-quota-summary.ts"; +import type { SessionToolOverrides } from "../../../lib/sessions/patch.ts"; import type { ComposerDictationController } from "../composer-dictation.ts"; import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts"; import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts"; @@ -15,6 +16,10 @@ import type { RealtimeTalkLevelSignal } from "../realtime-talk-level.ts"; import type { RealtimeTalkStatus } from "../realtime-talk.ts"; import type { ChatRunUiStatus } from "../run-lifecycle.ts"; import type { CompactionStatus, FallbackStatus, PlanStatus } from "../tool-stream.ts"; +import type { + ChatComposerPlusMenuProps, + ChatComposerPlusMenuView, +} from "./chat-composer-plus-menu.ts"; type ChatComposerDisabledBannerContent = { text: string; @@ -49,6 +54,18 @@ export type ChatComposerProps = { queue: ChatQueueItem[]; draft: string; sessions: SessionsListResult | null; + toolOverrides?: SessionToolOverrides; + capabilityMenu?: Omit< + ChatComposerPlusMenuProps, + | "attachments" + | "disabled" + | "open" + | "view" + | "toolOverrides" + | "onOpenChange" + | "onViewChange" + | "showCapabilities" + >; providerUsage?: ProviderUsageDisplayProps; assistantName: string; sendShortcut?: ChatSendShortcut; @@ -140,6 +157,8 @@ export type ChatComposerState = { microphoneDevices: RealtimeTalkInputDevice[]; microphoneWarning: string | null; microphoneDiscoveryRequest: number; + capabilityMenuOpen: boolean; + capabilityMenuView: ChatComposerPlusMenuView; // Stable Lit ref: an inline arrow would change identity per render and force // a layout re-measure of the textarea on every chat render, not just attach. textareaRef: ((element?: Element) => void) | null; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index 132446665bfe..35e8ae02cb54 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -5,13 +5,16 @@ import { ref } from "lit/directives/ref.js"; import type { GatewaySessionRow } from "../../../api/types.ts"; import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; +import { + countSessionToolOverrides, + sessionToolOverrideNames, +} from "../../../lib/sessions/tool-overrides.ts"; import { detectTextDirection } from "../../../lib/text-direction.ts"; import type { ComposerDictationController } from "../composer-dictation.ts"; import { handleChatAttachmentPaste, renderAttachmentPreview, renderChatAttachmentInputs, - renderChatAttachmentMenu, } from "./chat-attachments.ts"; import type { ChatRunControlsProps } from "./chat-composer-controls.ts"; import { renderChatPrimaryActions } from "./chat-composer-controls.ts"; @@ -21,6 +24,7 @@ import { observeQuestionDock, } from "./chat-composer-dom.ts"; import { renderChatGoal } from "./chat-composer-goal.ts"; +import { renderChatComposerPlusMenu } from "./chat-composer-plus-menu.ts"; import { renderChatQueue } from "./chat-composer-queue.ts"; import { renderSlashMenu } from "./chat-composer-slash-menu.ts"; import { commitComposerDraft } from "./chat-composer-state.ts"; @@ -116,6 +120,14 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ` : nothing; const showComposerInput = showComposer && props.disabledBanner?.kind !== "composer-replacement"; + if (!props.capabilityMenu) { + state.capabilityMenuView = "root"; + } + const overrideCount = countSessionToolOverrides(props.toolOverrides); + const overrideTooltip = sessionToolOverrideNames( + props.toolOverrides, + t("chat.composer.menu.webSearch"), + ).join(", "); return html` ${renderChatQueue({ @@ -303,9 +315,38 @@ export function renderChatComposerView(context: ChatComposerViewContext) { : nothing}
- ${renderChatAttachmentMenu({ - ...props, + ${renderChatComposerPlusMenu({ + attachments: props, + showCapabilities: props.capabilityMenu !== undefined, + basePath: props.capabilityMenu?.basePath ?? "", disabled: !canCompose || props.suggestionComposer === true, + open: state.capabilityMenuOpen, + view: state.capabilityMenuView, + toolOverrides: props.toolOverrides, + skills: props.capabilityMenu?.skills ?? null, + skillsLoading: props.capabilityMenu?.skillsLoading ?? false, + skillsError: props.capabilityMenu?.skillsError ?? false, + mcpServers: props.capabilityMenu?.mcpServers ?? [], + webSearchBaseEnabled: props.capabilityMenu?.webSearchBaseEnabled ?? true, + mutationBlockedReason: props.capabilityMenu?.mutationBlockedReason ?? null, + canAdmin: props.capabilityMenu?.canAdmin ?? false, + adminBlockedReason: props.capabilityMenu?.adminBlockedReason ?? null, + onOpenChange: (open) => { + state.capabilityMenuOpen = open; + if (!open) { + state.capabilityMenuView = "root"; + } + requestUpdate(); + }, + onViewChange: (view) => { + state.capabilityMenuView = view; + requestUpdate(); + }, + onLoadSkills: props.capabilityMenu?.onLoadSkills ?? (() => {}), + onPatchToolOverrides: props.capabilityMenu?.onPatchToolOverrides ?? (() => {}), + onNavigate: props.capabilityMenu?.onNavigate ?? (() => {}), + onAddServer: props.capabilityMenu?.onAddServer, + onOpenToolAccess: props.capabilityMenu?.onOpenToolAccess, })}