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.
This commit is contained in:
Peter Steinberger
2026-08-09 02:56:48 -07:00
committed by GitHub
parent e40e352fe7
commit b4564b02cd
5 changed files with 140 additions and 15 deletions
+1 -1
View File
@@ -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.
+66 -2
View File
@@ -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) => ({
+5 -2
View File
@@ -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),
@@ -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<HTMLButtonElement>(".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<HTMLButtonElement>(".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);
});
});
@@ -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<string>;
@@ -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`
<div class="task-suggestions" aria-live="polite">
${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`
<article class="task-suggestion" data-task-id=${suggestion.id}>
<div class="task-suggestion__icon" aria-hidden="true">${icons.spark}</div>
<div class="task-suggestion__body">
<div class="task-suggestion__eyebrow">${t("chat.taskSuggestions.eyebrow")}</div>
<div class="task-suggestion__title">${suggestion.title}</div>
<div class="task-suggestion__summary">${suggestion.tldr}</div>
<div class="task-suggestion__title">${title}</div>
<div class="task-suggestion__summary">${tldr}</div>
<div class="task-suggestion__details">
<div class="task-suggestion__detail">
<span>${t("chat.taskSuggestions.project")}</span>
<code>${suggestion.cwd}</code>
<code>${cwd}</code>
</div>
<div class="task-suggestion__detail task-suggestion__detail--instructions">
<span>${t("chat.taskSuggestions.instructions")}</span>
<pre>${suggestion.prompt}</pre>
<pre>${prompt}</pre>
</div>
</div>
</div>
@@ -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)}
>