Skip to content

Commit b3ae66d

Browse files
author
Eva
committed
fix(workspaces): keep living answers helpers private
1 parent fac9d16 commit b3ae66d

4 files changed

Lines changed: 77 additions & 79 deletions

File tree

extensions/workspaces/src/store.test.ts

Lines changed: 37 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { describe, expect, it } from "vitest";
5-
import { DEFAULT_WORKSPACE } from "./default-workspace.js";
65
import { validateWorkspaceDoc, type WorkspaceDoc } from "./schema.js";
7-
import { sweepExpiredEphemeral, WorkspaceStore } from "./store.js";
6+
import { WorkspaceStore } from "./store.js";
87

98
async function withStore<T>(run: (store: WorkspaceStore) => Promise<T> | T): Promise<T> {
109
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
@@ -42,73 +41,56 @@ function docWithPendingWidget(store: WorkspaceStore): WorkspaceDoc {
4241
}
4342

4443
describe("WorkspaceStore", () => {
45-
it("sweeps expired temporary widgets but preserves future and pinned widgets", () => {
46-
const doc = validateWorkspaceDoc({
47-
...structuredClone(DEFAULT_WORKSPACE),
48-
tabs: [
49-
{
50-
...structuredClone(DEFAULT_WORKSPACE.tabs[0]!),
51-
widgets: [
52-
{
53-
id: "expired",
54-
kind: "builtin:markdown",
55-
grid: { x: 0, y: 0, w: 4, h: 2 },
56-
collapsed: false,
57-
hidden: false,
58-
createdBy: "agent:main",
59-
ephemeral: { expiresAt: "2026-07-01T00:00:00Z" },
60-
},
61-
{
62-
id: "future",
63-
kind: "builtin:markdown",
64-
grid: { x: 4, y: 0, w: 4, h: 2 },
65-
collapsed: false,
66-
hidden: false,
67-
createdBy: "agent:main",
68-
ephemeral: { expiresAt: "2026-08-01T00:00:00Z" },
69-
},
70-
{
71-
id: "pinned",
72-
kind: "builtin:markdown",
73-
grid: { x: 8, y: 0, w: 4, h: 2 },
74-
collapsed: false,
75-
hidden: false,
76-
createdBy: "agent:main",
77-
},
78-
],
79-
},
80-
],
81-
});
82-
const swept = sweepExpiredEphemeral(doc, Date.parse("2026-07-13T00:00:00Z"));
83-
expect(swept?.tabs[0]?.widgets.map((widget) => widget.id)).toEqual(["future", "pinned"]);
84-
});
85-
8644
it("sweeps a widget after it expires even when the document was cached", async () => {
8745
let now = Date.parse("2026-07-13T00:00:00Z");
8846
await withTimedStore(
8947
() => now,
9048
(store) => {
9149
store.mutate(
9250
(draft) => {
93-
draft.tabs[0]!.widgets.push({
94-
id: "temporary",
95-
kind: "builtin:markdown",
96-
grid: { x: 0, y: 2, w: 4, h: 2 },
97-
collapsed: false,
98-
hidden: false,
99-
createdBy: "agent:main",
100-
ephemeral: { expiresAt: "2026-07-14T00:00:00Z" },
101-
});
51+
draft.tabs[0]!.widgets.push(
52+
{
53+
id: "temporary",
54+
kind: "builtin:markdown",
55+
grid: { x: 0, y: 2, w: 4, h: 2 },
56+
collapsed: false,
57+
hidden: false,
58+
createdBy: "agent:main",
59+
ephemeral: { expiresAt: "2026-07-14T00:00:00Z" },
60+
},
61+
{
62+
id: "future",
63+
kind: "builtin:markdown",
64+
grid: { x: 4, y: 2, w: 4, h: 2 },
65+
collapsed: false,
66+
hidden: false,
67+
createdBy: "agent:main",
68+
ephemeral: { expiresAt: "2026-08-01T00:00:00Z" },
69+
},
70+
{
71+
id: "pinned",
72+
kind: "builtin:markdown",
73+
grid: { x: 8, y: 2, w: 4, h: 2 },
74+
collapsed: false,
75+
hidden: false,
76+
createdBy: "agent:main",
77+
},
78+
);
10279
},
10380
{ actor: "agent:main" },
10481
);
10582
expect(store.read().tabs[0]?.widgets.some((widget) => widget.id === "temporary")).toBe(
10683
true,
10784
);
10885
now = Date.parse("2026-07-15T00:00:00Z");
109-
expect(store.read().tabs[0]?.widgets.some((widget) => widget.id === "temporary")).toBe(
110-
false,
111-
);
86+
expect(
87+
store
88+
.read()
89+
.tabs[0]?.widgets.filter((widget) =>
90+
["temporary", "future", "pinned"].includes(widget.id),
91+
)
92+
.map((widget) => widget.id),
93+
).toEqual(["future", "pinned"]);
11294
},
11395
);
11496
});

extensions/workspaces/src/store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const DIR_MODE = 0o700;
4242
const FILE_MODE = 0o600;
4343
const BUSY_TIMEOUT_MS = 5000;
4444

45-
export function sweepExpiredEphemeral(doc: WorkspaceDoc, nowMs: number): WorkspaceDoc | null {
45+
function sweepExpiredEphemeral(doc: WorkspaceDoc, nowMs: number): WorkspaceDoc | null {
4646
let removed = false;
4747
const tabs = doc.tabs.map((tab) => {
4848
const widgets = tab.widgets.filter((widget) => {

ui/src/lib/workspace/widgets/action-form.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { html, type TemplateResult } from "lit";
22
import type { WorkspaceWidget } from "../types.ts";
33
import { isRecord, widgetProps, type BuiltinWidgetContext } from "./types.ts";
44

5-
export type ActionFormField = {
5+
type ActionFormField = {
66
name: string;
77
label: string;
88
type: "text" | "number" | "select";
99
options?: string[];
1010
maxLength?: number;
1111
};
12-
export type ActionFormModel = { template: string; fields: ActionFormField[]; buttonLabel: string };
13-
export const ACTION_FORM_DEFAULT_MAX_LENGTH = 200;
12+
type ActionFormModel = { template: string; fields: ActionFormField[]; buttonLabel: string };
13+
const ACTION_FORM_DEFAULT_MAX_LENGTH = 200;
1414
const SLOT_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
1515

1616
function mapField(value: unknown): ActionFormField | null {
@@ -40,7 +40,7 @@ function mapField(value: unknown): ActionFormField | null {
4040
};
4141
}
4242

43-
export function mapActionForm(widget: WorkspaceWidget): ActionFormModel {
43+
function mapActionForm(widget: WorkspaceWidget): ActionFormModel {
4444
const props = widgetProps(widget);
4545
return {
4646
template: typeof props.template === "string" ? props.template : "",
@@ -51,7 +51,7 @@ export function mapActionForm(widget: WorkspaceWidget): ActionFormModel {
5151
};
5252
}
5353

54-
export function coerceFieldValue(field: ActionFormField, raw: string): string {
54+
function coerceFieldValue(field: ActionFormField, raw: string): string {
5555
const cap = field.maxLength ?? ACTION_FORM_DEFAULT_MAX_LENGTH;
5656
if (field.type === "number") {
5757
const value = raw.trim();
@@ -63,10 +63,7 @@ export function coerceFieldValue(field: ActionFormField, raw: string): string {
6363
return raw.slice(0, cap);
6464
}
6565

66-
export function buildActionFormPrompt(
67-
model: ActionFormModel,
68-
values: Record<string, string>,
69-
): string {
66+
function buildActionFormPrompt(model: ActionFormModel, values: Record<string, string>): string {
7067
const fields = new Map(model.fields.map((field) => [field.name, field]));
7168
return model.template.replace(SLOT_PATTERN, (match, name: string) => {
7269
const field = fields.get(name);

ui/src/lib/workspace/widgets/widgets.test.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { expectDefined } from "@openclaw/normalization-core";
66
import { render } from "lit";
77
import { describe, expect, it } from "vitest";
88
import type { WorkspaceWidget } from "../types.ts";
9-
import { buildActionFormPrompt, coerceFieldValue, mapActionForm } from "./action-form.ts";
9+
import { renderActionForm } from "./action-form.ts";
1010
import { mapActivity, renderActivity } from "./activity.ts";
1111
import { mapCron, renderCron } from "./cron.ts";
1212
import { evaluateEmbedUrl, renderIframeEmbed } from "./iframe-embed.ts";
@@ -30,21 +30,40 @@ function widget(overrides: Partial<WorkspaceWidget> = {}): WorkspaceWidget {
3030
}
3131

3232
describe("action-form", () => {
33-
it("interpolates declared slots once and bounds typed values", () => {
34-
const model = mapActionForm(
35-
widget({
36-
kind: "builtin:action-form",
37-
props: {
38-
template: "Run {topic} {count}",
39-
fields: [
40-
{ name: "topic", label: "Topic", type: "text", maxLength: 8 },
41-
{ name: "count", label: "Count", type: "number" },
42-
],
33+
it("interpolates declared slots once and bounds typed values", async () => {
34+
const sent: string[] = [];
35+
const container = renderToContainer(
36+
renderActionForm(
37+
widget({
38+
kind: "builtin:action-form",
39+
props: {
40+
template: "Run {topic} {count}",
41+
fields: [
42+
{ name: "topic", label: "Topic", type: "text", maxLength: 8 },
43+
{ name: "count", label: "Count", type: "number" },
44+
],
45+
},
46+
}),
47+
undefined,
48+
{
49+
...STRICT_EMBED,
50+
dispatchPrompt: async ({ text }) => {
51+
sent.push(text);
52+
return "sent";
53+
},
4354
},
44-
}),
55+
),
4556
);
46-
expect(buildActionFormPrompt(model, { topic: "{count}!!", count: "3" })).toBe("Run {count}! 3");
47-
expect(coerceFieldValue(model.fields[1]!, "not-a-number")).toBe("");
57+
const topic = container.querySelector<HTMLInputElement>('input[name="topic"]')!;
58+
const count = container.querySelector<HTMLInputElement>('input[name="count"]')!;
59+
topic.value = "{count}!!";
60+
count.value = "3";
61+
container
62+
.querySelector<HTMLFormElement>("form")!
63+
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
64+
await Promise.resolve();
65+
expect(sent).toEqual(["Run {count}! 3"]);
66+
expect(topic.maxLength).toBe(8);
4867
});
4968
});
5069

0 commit comments

Comments
 (0)