Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 50 additions & 24 deletions src/agents/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,31 +509,57 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("`[embed ...]`: Control UI/webchat only");
});

it("includes embed guidance only for webchat sessions", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
runtimeInfo: {
channel: "webchat",
},
});
it.each(["automatic", "message_tool_only"] as const)(
"includes webchat attachment revision guidance for %s delivery",
(sourceReplyDeliveryMode) => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
sourceReplyDeliveryMode,
runtimeInfo: {
channel: "webchat",
},
});

expect(prompt).toContain("## Control UI Embed");
expect(prompt).toContain("`[embed ...]`: Control UI/webchat only");
expect(prompt).toContain('[embed ref="cv_123" title="Status" height="320" /]');
expect(prompt).toContain(
'[embed url="/__openclaw__/canvas/documents/cv_123/index.html" title="Status" height="320" /]',
);
expect(prompt).toContain("Never local/file:// or arbitrary URL");
expect(prompt).toContain("URL must start `/__openclaw__/canvas/`; else use `ref`");
expect(prompt).toContain("Hosted root is profile-, not workspace-scoped");
expect(prompt).toContain(
"Preview renders its owned attachment snapshot without another model call",
);
expect(prompt).toContain("No HTML re-output or browser access to server/VM paths is needed");
expect(prompt).toContain("Relative and external resources are not bundled");
expect(prompt).toContain("Original download stays available");
expect(prompt).not.toContain('[embed content_type="html" title="Status"]...[/embed]');
});
expect(prompt).toContain("## Control UI Embed");
expect(prompt).toContain("`[embed ...]`: Control UI/webchat only");
expect(prompt).toContain('[embed ref="cv_123" title="Status" height="320" /]');
expect(prompt).toContain(
'[embed url="/__openclaw__/canvas/documents/cv_123/index.html" title="Status" height="320" /]',
);
expect(prompt).toContain("Never local/file:// or arbitrary URL");
expect(prompt).toContain("URL must start `/__openclaw__/canvas/`; else use `ref`");
expect(prompt).toContain("Hosted root is profile-, not workspace-scoped");
expect(prompt).toContain(
"Preview renders its owned attachment snapshot without another model call",
);
expect(prompt).toContain("No HTML re-output or browser access to server/VM paths is needed");
expect(prompt).toContain("Relative and external resources are not bundled");
expect(prompt).toContain("Original download stays available");
expect(prompt).not.toContain('[embed content_type="html" title="Status"]...[/embed]');
const webchatGuidance = prompt.slice(prompt.indexOf("## Control UI Embed"));
expect(webchatGuidance).toContain(
"after creating or revising an image, HTML, PDF, PPT, or PPTX deliverable, attach the current file from the active workspace",
);
expect(webchatGuidance).toContain("using the current attachment delivery contract");
expect(webchatGuidance).toContain("even when you overwrite the same path");
expect(webchatGuidance).toContain(
"Earlier attachments are saved snapshots and do not update when workspace files change",
);
expect(webchatGuidance).toContain(
"Do not only announce completion or point to an earlier attachment after a revision",
);
expect(webchatGuidance).not.toContain("show_widget");
expect(prompt.indexOf("- Completed files:")).toBeGreaterThan(
prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY),
);
if (sourceReplyDeliveryMode === "message_tool_only") {
expect(webchatGuidance).toContain("Files: message attachment fields");
expect(webchatGuidance).not.toContain("`MEDIA:");
} else {
expect(webchatGuidance).toContain("Attachments: `MEDIA:`");
}
},
);

it("teaches direct status answers only on the full Control UI surface", () => {
const defaultPrompt = buildAgentSystemPrompt({
Expand Down
2 changes: 2 additions & 0 deletions src/agents/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,8 @@ function buildWebchatCanvasSection(params: {
params.sourceMessageToolOnly
? "- Files: message attachment fields. Web rich render: `[embed ...]`."
: "- Attachments: `MEDIA:`. Web rich render: `[embed ...]`.",
"- Completed files: after creating or revising an image, HTML, PDF, PPT, or PPTX deliverable, attach the current file from the active workspace in your visible reply using the current attachment delivery contract, even when you overwrite the same path.",
"- Earlier attachments are saved snapshots and do not update when workspace files change. Do not only announce completion or point to an earlier attachment after a revision.",
"- Saved HTML: attach a self-contained file from the active workspace; Preview renders its owned attachment snapshot without another model call. No HTML re-output or browser access to server/VM paths is needed.",
"- HTML file previews: inline CSS/JS/SVG and data/blob images. Relative and external resources are not bundled; omitted resources show a notice. Original download stays available.",
...(params.widgetToolName
Expand Down
2 changes: 1 addition & 1 deletion ui/src/app/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const UI_APPEARANCE_DEFAULTS = {
textScale: 100,
terminalTextScale: 100,
sidebarLiveActivity: true,
chatMessageMaxWidth: "48rem",
chatMessageMaxWidth: "60rem",
chatSendShortcut: "enter",
catalogOpenTarget: "viewer",
composerHoldToRecord: true,
Expand Down
116 changes: 112 additions & 4 deletions ui/src/components/document-viewer-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class TestResizeObserver {
resizeObservers.push(this);
}
observe() {}
unobserve() {}
disconnect(): void {
this.disconnected = true;
}
Expand All @@ -43,6 +44,8 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect }
canvas.width = 1_200;
canvas.height = 800;
let pageNumber = 1;
let available = true;
let handlerAvailable = true;
let targets: readonly DocumentSelectionTarget[] = [
{ region: { x: 0.1, y: 0.1, width: 0.7, height: 0.2 }, text: "A larger text run" },
{ region: { x: 0.2, y: 0.1, width: 0.2, height: 0.08 }, text: "Owned title" },
Expand All @@ -65,17 +68,18 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect }
);
const refresh = () => {
render(
html`<div class="stage">
html`<div class="stage" tabindex="0">
<div
class="page-surface"
@pointerdown=${controller.pointerDown}
@pointermove=${controller.pointerMove}
@pointerup=${controller.pointerUp}
@pointercancel=${controller.pointerCancel}
@contextmenu=${controller.contextMenu}
>
${canvas}${controller.renderOverlay()}
</div>
${controller.renderPopover()}
${controller.renderPopover()}${controller.renderContextMenu()}
</div>
${controller.renderStatus()}`,
container,
Expand Down Expand Up @@ -122,8 +126,8 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect }
queueMicrotask(refresh);
},
currentPage: () =>
container.isConnected ? { number: pageNumber, canvas, targets } : undefined,
handler: () => handler,
container.isConnected && available ? { number: pageNumber, canvas, targets } : undefined,
handler: () => (handlerAvailable ? handler : undefined),
});
requestControllers.push(controller);
refresh();
Expand All @@ -145,6 +149,26 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect }
controller,
handler,
pointer,
setAvailable: (value: boolean) => {
available = value;
},
setHandlerAvailable: (value: boolean) => {
handlerAvailable = value;
},
contextMenu: async (x = 0.25, y = 0.13) => {
const bounds = canvas.getBoundingClientRect();
const event = new MouseEvent("contextmenu", {
button: 2,
clientX: bounds.left + bounds.width * x,
clientY: bounds.top + bounds.height * y,
bubbles: true,
cancelable: true,
});
container.querySelector(".page-surface")?.dispatchEvent(event);
await Promise.resolve();
await Promise.resolve();
return event;
},
setTargets: (value: readonly DocumentSelectionTarget[]) => {
targets = value;
},
Expand Down Expand Up @@ -207,6 +231,90 @@ function submit(container: HTMLElement): void {
}

describe("inline document selection requests", () => {
it("requires the right-click menu action before selecting the same title and submitting", async () => {
const request = mountRequest();
const bubbled = vi.fn();
request.container.addEventListener("contextmenu", bubbled);
const event = await request.contextMenu();
expect(event.defaultPrevented).toBe(true);
expect(bubbled).not.toHaveBeenCalled();
expect(request.controller.active).toBe(false);
expect(request.container.querySelector("form")).toBeNull();
const action = request.container.querySelector<HTMLElement>("wa-dropdown-item");
expect(action?.textContent?.trim()).toBe("Request changes");
// Node Lit reports isServer, so Web Awesome preactivates its SSR popup and
// skips the client open-focus step. Chromium E2E proves autofocus and Enter;
// this DOM test exercises the same native menu action through submission.
action?.click();
await vi.waitFor(() => expect(request.container.querySelector("form")).not.toBeNull());
expect(request.container.querySelector("wa-dropdown")).toBeNull();
expect(request.container.querySelector(".selected-text")?.textContent).toContain("Owned title");
expect(request.handler).not.toHaveBeenCalled();
await request.instruction("Make this title shorter.");
submit(request.container);
await vi.waitFor(() => expect(request.handler).toHaveBeenCalledTimes(1));
expect(request.handler.mock.calls[0]?.[0]).toMatchObject({
pageNumber: 1,
region: { x: 0.2, y: 0.1, width: 0.2, height: 0.08 },
selectedText: "Owned title",
instruction: "Make this title shorter.",
cropDataUrl: PNG,
});
});

it("dismisses transient menus without losing the existing selection or unsent draft", async () => {
const request = mountRequest();
request.controller.begin();
await request.select();
await request.instruction("Keep my draft.");
const dismissals = [
() =>
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true })),
() => document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })),
() => window.dispatchEvent(new Event("scroll")),
() => window.dispatchEvent(new Event("resize")),
];
for (const dismiss of dismissals) {
await request.contextMenu(0.9, 0.9);
expect(request.container.querySelector("wa-dropdown")).not.toBeNull();
dismiss();
await Promise.resolve();
expect(request.container.querySelector("wa-dropdown")).toBeNull();
expect(request.container.querySelector("textarea")?.value).toBe("Keep my draft.");
expect(request.container.querySelector(".selected-text")?.textContent).toContain(
"Owned title",
);
}
expect(request.handler).not.toHaveBeenCalled();
});

it("retires menus with their page/document and leaves unavailable or pending previews alone", async () => {
const request = mountRequest();
await request.contextMenu();
await request.changePage(2);
expect(request.container.querySelector("wa-dropdown")).toBeNull();
await request.contextMenu();
request.controller.reset();
await Promise.resolve();
expect(request.container.querySelector("wa-dropdown")).toBeNull();
request.setAvailable(false);
expect((await request.contextMenu()).defaultPrevented).toBe(false);
request.setAvailable(true);
request.setHandlerAvailable(false);
expect((await request.contextMenu()).defaultPrevented).toBe(false);
request.setHandlerAvailable(true);
const pending = deferred<DocumentChangeResult>();
request.handler.mockReturnValueOnce(pending.promise);
request.controller.begin();
await request.select();
await request.instruction("Pending request.");
submit(request.container);
expect((await request.contextMenu()).defaultPrevented).toBe(false);
expect(request.container.querySelector("wa-dropdown")).toBeNull();
pending.resolve({ admitted: false });
await vi.waitFor(() => expect(request.controller.busy).toBe(false));
});

it("ignores ordinary clicks and only opens a popover after an explicit mode selection", async () => {
const request = mountRequest();
await request.select();
Expand Down
Loading
Loading