From b4564b02cd7405789542bf69ff376efb0a03e324 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 02:56:48 -0700 Subject: [PATCH] fix(ui): hide inert task-suggestion chips and strip bidi controls (#120967) * fix(ui): hide inert task-suggestion chips and strip bidi controls - render no suggestion card for operators who can neither accept nor dismiss (matches TUI behavior) - strip RTL/bidi control characters from chip title, summary, project path, and instructions before rendering - document the git-checkout requirement for spawn_task project directories (gateway enforcement landed in #120940) * fix(ui): gate task-suggestion actions on negotiated gateway methods Mirror the TUI capability rule: accept requires the taskSuggestions.accept method plus operator.admin, dismiss requires taskSuggestions.dismiss plus operator.write. List-only gateways render no chip; e2e fixtures now advertise the mutation methods they exercise, and a new regression proves a pending suggestion renders no chip when only listing is advertised. Addresses the ClawSweeper P2 on this PR. --- docs/gateway/config-tools.md | 2 +- ui/src/e2e/chat-flow.follow-ups.e2e.test.ts | 68 ++++++++++++++++++- ui/src/pages/chat/chat-pane-render.ts | 7 +- .../pages/chat/chat-task-suggestions.test.ts | 56 +++++++++++++-- .../chat/components/chat-task-suggestions.ts | 22 ++++-- 5 files changed, 140 insertions(+), 15 deletions(-) diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 2c5d380cc64e..474e40011b88 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -47,7 +47,7 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un | `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) | | `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` | -`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting either creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. +`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The suggestion's project directory must be a git checkout; invalid suggestions, including a non-git directory or blank prompt, are rejected when the tool records them. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting either creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`. The tools are offered only when the initiating operator surface can receive and action Gateway task-suggestion events. Channel sessions and local/embedded TUI sessions do not receive them; channel transports need a portable typed task action before they can safely expose this flow. Suggestions are process-local and disappear when the Gateway restarts. Both tools remain in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically when the surface supports them. diff --git a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts index dea3b21072d7..89e93b14702d 100644 --- a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts @@ -104,7 +104,13 @@ suite.define(() => { }); const page = await context.newPage(); const gateway = await installMockGateway(page, { - featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "taskSuggestions.list", + "taskSuggestions.accept", + "taskSuggestions.dismiss", + ], methodResponses: { "sessions.list": chatSessionListResponse(), "taskSuggestions.list": { @@ -144,6 +150,58 @@ suite.define(() => { } }); + it("hides model-suggested follow-ups when only listing is advertised", async () => { + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], + methodResponses: { + "taskSuggestions.list": { + suggestions: [ + { + id: "task_list_only", + title: "Unavailable follow-up", + prompt: "This suggestion has no advertised action methods.", + tldr: "Listing alone must not expose an unusable chip.", + cwd: "/projects/example", + sessionKey: "main", + agentId: "main", + createdAt: Date.now(), + }, + ], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("taskSuggestions.list"); + await expect + .poll(() => + page + .locator("openclaw-chat-pane") + .evaluate( + (pane) => + (pane as HTMLElement & { taskSuggestions?: unknown[] }).taskSuggestions?.length ?? + 0, + ), + ) + .toBe(1); + + await page + .locator(".agent-chat__composer-shell") + .waitFor({ state: "visible", timeout: 10_000 }); + expect(await page.getByRole("button", { name: "Start in worktree" }).count()).toBe(0); + expect(await page.locator(".task-suggestion").count()).toBe(0); + } finally { + await suite.closeBrowserContext(context); + } + }); + it("keeps the composer visible when follow-up suggestions overflow", async () => { const context = await suite.newBrowserContext({ locale: "en-US", @@ -152,7 +210,13 @@ suite.define(() => { }); const page = await context.newPage(); await installMockGateway(page, { - featureMethods: ["chat.metadata", "chat.startup", "taskSuggestions.list"], + featureMethods: [ + "chat.metadata", + "chat.startup", + "taskSuggestions.list", + "taskSuggestions.accept", + "taskSuggestions.dismiss", + ], methodResponses: { "taskSuggestions.list": { suggestions: Array.from({ length: 12 }, (_, index) => ({ diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 03fa4099678a..158a2ec61818 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -446,10 +446,13 @@ export class ChatPane extends ChatPaneHeader { void this.resolveCurrentSessionSuggestion(suggestion, resolution), canAcceptTaskSuggestions: state.connected && - hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null), + hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null) && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.accept") === true, canDismissTaskSuggestions: state.connected && - hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null), + hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.dismiss") === + true, onAcceptTaskSuggestion: (suggestion) => void this.acceptTaskSuggestion(suggestion), onDismissTaskSuggestion: (suggestion) => void this.dismissTaskSuggestion(suggestion), onOpenWorkspaceFile: (target) => openSessionWorkspaceFile(state, target), diff --git a/ui/src/pages/chat/chat-task-suggestions.test.ts b/ui/src/pages/chat/chat-task-suggestions.test.ts index 64169a1e22ab..6a5bf8dd2541 100644 --- a/ui/src/pages/chat/chat-task-suggestions.test.ts +++ b/ui/src/pages/chat/chat-task-suggestions.test.ts @@ -43,7 +43,7 @@ describe("chat task suggestions", () => { expect(onDismiss).toHaveBeenCalledWith(suggestion); }); - it("hides dismissal without write access and requires admin access to start", () => { + it("renders nothing when no task actions are permitted", () => { const container = document.createElement("div"); render( renderChatTaskSuggestions({ @@ -57,9 +57,57 @@ describe("chat task suggestions", () => { container, ); - expect(container.querySelector(".task-suggestion__start")?.disabled).toBe( - true, + expect(container.querySelector(".task-suggestions")).toBeNull(); + }); + + it("allows dismissal while requiring admin access to start", () => { + const container = document.createElement("div"); + render( + renderChatTaskSuggestions({ + suggestions: [suggestion], + busyIds: new Set(), + canAccept: false, + canDismiss: true, + onAccept: vi.fn(), + onDismiss: vi.fn(), + }), + container, ); - expect(container.querySelector(".task-suggestion__dismiss")).toBeNull(); + + const start = container.querySelector(".task-suggestion__start"); + expect(start?.disabled).toBe(true); + expect(start?.title).toBe( + "Administrator access is required to create a worktree from this project.", + ); + expect(container.querySelector(".task-suggestion__dismiss")).not.toBeNull(); + }); + + it("strips bidi controls from every displayed field", () => { + const container = document.createElement("div"); + render( + renderChatTaskSuggestions({ + suggestions: [ + { + ...suggestion, + title: "safe\u202eevil", + tldr: "why\u200f now", + cwd: "/repo/\u2066project", + prompt: "run\u202d exactly", + }, + ], + busyIds: new Set(), + canAccept: true, + canDismiss: true, + onAccept: vi.fn(), + onDismiss: vi.fn(), + }), + container, + ); + + expect(container.textContent).toContain("safeevil"); + expect(container.textContent).toContain("why now"); + expect(container.textContent).toContain("/repo/project"); + expect(container.textContent).toContain("run exactly"); + expect(container.textContent).not.toMatch(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u); }); }); diff --git a/ui/src/pages/chat/components/chat-task-suggestions.ts b/ui/src/pages/chat/components/chat-task-suggestions.ts index 8b0dc970c367..0c56e5a881a3 100644 --- a/ui/src/pages/chat/components/chat-task-suggestions.ts +++ b/ui/src/pages/chat/components/chat-task-suggestions.ts @@ -4,6 +4,12 @@ import type { TaskSuggestion } from "../../../../../packages/gateway-protocol/sr import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; +// Mirrors the TUI sanitizer to prevent directionality spoofing. This stays local +// because the Control UI cannot import core src/ modules. +function sanitizeTaskSuggestionText(text: string): string { + return text.replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ""); +} + export function renderChatTaskSuggestions(props: { suggestions: TaskSuggestion[]; busyIds: ReadonlySet; @@ -12,28 +18,32 @@ export function renderChatTaskSuggestions(props: { onAccept: (suggestion: TaskSuggestion) => void; onDismiss: (suggestion: TaskSuggestion) => void; }) { - if (props.suggestions.length === 0) { + if (props.suggestions.length === 0 || (!props.canAccept && !props.canDismiss)) { return nothing; } return html`
${props.suggestions.map((suggestion) => { const busy = props.busyIds.has(suggestion.id); + const title = sanitizeTaskSuggestionText(suggestion.title); + const tldr = sanitizeTaskSuggestionText(suggestion.tldr); + const cwd = sanitizeTaskSuggestionText(suggestion.cwd); + const prompt = sanitizeTaskSuggestionText(suggestion.prompt); return html`
${t("chat.taskSuggestions.eyebrow")}
-
${suggestion.title}
-
${suggestion.tldr}
+
${title}
+
${tldr}
${t("chat.taskSuggestions.project")} - ${suggestion.cwd} + ${cwd}
${t("chat.taskSuggestions.instructions")} -
${suggestion.prompt}
+
${prompt}
@@ -55,7 +65,7 @@ export function renderChatTaskSuggestions(props: { type="button" ?disabled=${busy} aria-label=${t("chat.taskSuggestions.dismiss", { - title: suggestion.title, + title, })} @click=${() => props.onDismiss(suggestion)} >