mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
e40e352fe7
commit
b4564b02cd
@@ -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.
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user