Skip to content

Commit ed69acd

Browse files
author
Eva
committed
feat(workspaces): add persistent notes widget
1 parent b5d4eba commit ed69acd

11 files changed

Lines changed: 690 additions & 14 deletions

File tree

extensions/workspaces/src/schema.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ describe("Workspaces document schema", () => {
4242
}, "widgets[0].kind");
4343
});
4444

45+
it("accepts the trusted notes builtin", () => {
46+
const doc = validDoc();
47+
doc.tabs[0]!.widgets[0]!.kind = "builtin:notes";
48+
49+
expect(validateWorkspaceDoc(doc).tabs[0]?.widgets[0]?.kind).toBe("builtin:notes");
50+
});
51+
4552
it("rejects a prototype-setter custom widget kind", () => {
4653
expectInvalid((doc) => {
4754
doc.tabs[0]!.widgets[0]!.kind = "custom:__proto__";

extensions/workspaces/src/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const BUILTIN_WIDGET_KINDS = [
7070
"builtin:cron",
7171
"builtin:instances",
7272
"builtin:activity",
73+
"builtin:notes",
7374
] as const;
7475

7576
const BUILTIN_KINDS = new Set<string>(BUILTIN_WIDGET_KINDS);

extensions/workspaces/src/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const WIDGET_KIND_DESCRIPTION = [
5959
"builtin:markdown (props {markdown} or {text}, or a file binding of a .md file),",
6060
"builtin:table (binding id `rows`; props {columns: string[]}),",
6161
"builtin:iframe-embed (props {url}),",
62-
"builtin:sessions, builtin:usage, builtin:cron, builtin:instances, builtin:activity",
62+
"builtin:sessions, builtin:usage, builtin:cron, builtin:instances, builtin:activity, builtin:notes",
6363
"(each reads its own rpc binding; see workspace_get for a worked example).",
6464
"Charts are not builtins — author one with workspace_widget_scaffold and use custom:<name>.",
6565
].join(" ");

ui/src/i18n/locales/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,6 +2190,12 @@ export const en: TranslationMap = {
21902190
activity: {
21912191
empty: "No recent activity.",
21922192
},
2193+
notes: {
2194+
placeholder: "Write a note…",
2195+
readonlyHint: "Connect to the gateway to edit and save notes.",
2196+
saveError: "Your note couldn’t be saved. Keep editing and try again.",
2197+
conflict: "This note changed somewhere else. Copy your edits before reloading.",
2198+
},
21932199
embed: {
21942200
missing: "This embed has no URL yet.",
21952201
blockedExternal: "External embeds are disabled by your gateway policy.",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
2+
import type { WorkspaceWidget } from "../types.ts";
3+
import type { BuiltinWidgetContext } from "./types.ts";
4+
5+
type BuiltinContextProps = {
6+
client: GatewayBrowserClient | null;
7+
connected: boolean;
8+
basePath?: string;
9+
embed?: BuiltinWidgetContext["embed"];
10+
};
11+
12+
const DEFAULT_EMBED_CONTEXT: BuiltinWidgetContext["embed"] = {
13+
embedSandboxMode: "strict",
14+
allowExternalEmbedUrls: false,
15+
};
16+
17+
const builtinStateByClient = new WeakMap<
18+
GatewayBrowserClient,
19+
Map<string, NonNullable<BuiltinWidgetContext["state"]>>
20+
>();
21+
22+
function getBuiltinState(
23+
client: GatewayBrowserClient,
24+
widgetId: string,
25+
): NonNullable<BuiltinWidgetContext["state"]> {
26+
let states = builtinStateByClient.get(client);
27+
if (!states) {
28+
states = new Map();
29+
builtinStateByClient.set(client, states);
30+
}
31+
const existing = states.get(widgetId);
32+
if (existing) {
33+
return existing;
34+
}
35+
const state: NonNullable<BuiltinWidgetContext["state"]> = {
36+
get: async () => {
37+
const payload = (await client.request("workspaces.widget.state.get", {
38+
widgetId,
39+
})) as { state?: unknown; version?: unknown } | null;
40+
if (
41+
typeof payload?.version !== "number" ||
42+
!Number.isInteger(payload.version) ||
43+
payload.version < 0
44+
) {
45+
throw new Error("Invalid widget state response.");
46+
}
47+
return { state: payload.state ?? null, version: payload.version };
48+
},
49+
set: async (value, expectedVersion) => {
50+
const payload = (await client.request("workspaces.widget.state.set", {
51+
widgetId,
52+
state: value,
53+
expectedVersion,
54+
})) as { version?: unknown } | null;
55+
if (
56+
typeof payload?.version !== "number" ||
57+
!Number.isInteger(payload.version) ||
58+
payload.version < 1
59+
) {
60+
throw new Error("Invalid widget state response.");
61+
}
62+
return { version: payload.version };
63+
},
64+
};
65+
states.set(widgetId, state);
66+
return state;
67+
}
68+
69+
/** Bind trusted builtin persistence to the host-owned widget id. */
70+
export function buildBuiltinContext(
71+
props: BuiltinContextProps,
72+
widget: WorkspaceWidget,
73+
): BuiltinWidgetContext {
74+
const context: BuiltinWidgetContext = {
75+
basePath: props.basePath ?? "",
76+
embed: props.embed ?? DEFAULT_EMBED_CONTEXT,
77+
};
78+
if (!props.client || !props.connected) {
79+
return context;
80+
}
81+
return {
82+
...context,
83+
state: getBuiltinState(props.client, widget.id),
84+
};
85+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { renderCron } from "./cron.ts";
99
import { renderIframeEmbed } from "./iframe-embed.ts";
1010
import { renderInstances } from "./instances.ts";
1111
import { renderMarkdown } from "./markdown.ts";
12+
import { renderNotes } from "./notes.ts";
1213
import { renderSessions } from "./sessions.ts";
1314
import { renderStatCard } from "./stat-card.ts";
1415
import { renderTable } from "./table.ts";
@@ -25,6 +26,7 @@ export const BUILTIN_WIDGET_RENDERERS: Record<string, BuiltinWidgetRenderer> = {
2526
cron: (widget, value) => renderCron(widget, value),
2627
instances: (widget, value) => renderInstances(widget, value),
2728
activity: (widget, value) => renderActivity(widget, value),
29+
notes: renderNotes,
2830
};
2931

3032
export function getBuiltinRenderer(kind: string): BuiltinWidgetRenderer | undefined {
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { html, type TemplateResult } from "lit";
2+
import { ref } from "lit/directives/ref.js";
3+
import { t } from "../../../i18n/index.ts";
4+
import type { WorkspaceWidget } from "../types.ts";
5+
import type { BuiltinWidgetContext, BuiltinWidgetState } from "./types.ts";
6+
import { widgetProps } from "./types.ts";
7+
8+
const NOTES_PERSIST_DEBOUNCE_MS = 500;
9+
10+
function seedText(widget: WorkspaceWidget): string {
11+
const text = widgetProps(widget).text;
12+
return typeof text === "string" ? text : "";
13+
}
14+
15+
function isConflict(error: unknown): boolean {
16+
return error instanceof Error && /version conflict/i.test(error.message);
17+
}
18+
19+
const editorBindings = new WeakMap<BuiltinWidgetState, (element: Element | undefined) => void>();
20+
21+
function bindEditor(state: BuiltinWidgetState): (element: Element | undefined) => void {
22+
const existing = editorBindings.get(state);
23+
if (existing) {
24+
return existing;
25+
}
26+
let textarea: HTMLTextAreaElement | null = null;
27+
let timer: ReturnType<typeof setTimeout> | undefined;
28+
let dirty = false;
29+
let disconnected = false;
30+
let disconnectRevision = 0;
31+
let saving = false;
32+
let queued = false;
33+
let latest = "";
34+
let version: number | undefined;
35+
let hydrationPending = true;
36+
let hydrationStarted = false;
37+
38+
const showStatus = (key: "saveError" | "conflict" | null): void => {
39+
const status = textarea?.parentElement?.querySelector<HTMLElement>(
40+
"[data-test-id='workspace-notes-status']",
41+
);
42+
if (!status) {
43+
return;
44+
}
45+
status.dataset.state = key ? "error" : "idle";
46+
status.textContent =
47+
key === "conflict"
48+
? t("workspaces.widget.notes.conflict")
49+
: key === "saveError"
50+
? t("workspaces.widget.notes.saveError")
51+
: "";
52+
};
53+
54+
const persist = async (): Promise<void> => {
55+
timer = undefined;
56+
if (disconnected) {
57+
return;
58+
}
59+
if (saving) {
60+
queued = true;
61+
return;
62+
}
63+
if (version === undefined) {
64+
if (hydrationPending) {
65+
queued = true;
66+
return;
67+
}
68+
showStatus("saveError");
69+
return;
70+
}
71+
saving = true;
72+
queued = false;
73+
const value = latest;
74+
try {
75+
const result = await state.set(value, version);
76+
version = result.version;
77+
if (latest === value) {
78+
dirty = false;
79+
}
80+
showStatus(null);
81+
} catch (error) {
82+
showStatus(isConflict(error) ? "conflict" : "saveError");
83+
} finally {
84+
saving = false;
85+
if (!disconnected && queued && latest !== value) {
86+
void persist();
87+
}
88+
}
89+
};
90+
91+
const onInput = (): void => {
92+
if (!textarea) {
93+
return;
94+
}
95+
dirty = true;
96+
latest = textarea.value;
97+
showStatus(null);
98+
if (timer !== undefined) {
99+
clearTimeout(timer);
100+
}
101+
timer = setTimeout(() => void persist(), NOTES_PERSIST_DEBOUNCE_MS);
102+
};
103+
104+
const binding = (element: Element | undefined): void => {
105+
if (!(element instanceof HTMLTextAreaElement)) {
106+
disconnected = true;
107+
const revision = ++disconnectRevision;
108+
queueMicrotask(() => {
109+
if (!disconnected || revision !== disconnectRevision) {
110+
return;
111+
}
112+
if (timer !== undefined) {
113+
clearTimeout(timer);
114+
timer = undefined;
115+
}
116+
queued ||= dirty;
117+
textarea?.removeEventListener("input", onInput);
118+
textarea = null;
119+
});
120+
return;
121+
}
122+
disconnected = false;
123+
disconnectRevision += 1;
124+
if (textarea === element) {
125+
return;
126+
}
127+
textarea?.removeEventListener("input", onInput);
128+
textarea = element;
129+
if (dirty) {
130+
element.value = latest;
131+
} else {
132+
latest = element.value;
133+
}
134+
element.addEventListener("input", onInput);
135+
if (version !== undefined) {
136+
if (dirty && queued && timer === undefined && !saving) {
137+
timer = setTimeout(() => void persist(), NOTES_PERSIST_DEBOUNCE_MS);
138+
}
139+
return;
140+
}
141+
if (hydrationStarted) {
142+
return;
143+
}
144+
hydrationStarted = true;
145+
hydrationPending = true;
146+
void state
147+
.get()
148+
.then((result) => {
149+
hydrationPending = false;
150+
if (disconnected) {
151+
hydrationStarted = false;
152+
return;
153+
}
154+
version = result.version;
155+
if (!dirty && typeof result.state === "string" && textarea) {
156+
textarea.value = result.state;
157+
latest = result.state;
158+
}
159+
if (dirty && queued) {
160+
void persist();
161+
}
162+
})
163+
.catch(() => {
164+
hydrationPending = false;
165+
hydrationStarted = false;
166+
if (dirty) {
167+
showStatus("saveError");
168+
}
169+
});
170+
};
171+
editorBindings.set(state, binding);
172+
return binding;
173+
}
174+
175+
export function renderNotes(
176+
widget: WorkspaceWidget,
177+
_value: unknown,
178+
context: BuiltinWidgetContext,
179+
): TemplateResult {
180+
const seed = seedText(widget);
181+
const status = context.state ? "" : t("workspaces.widget.notes.readonlyHint");
182+
return html`
183+
<div class="workspace-notes" data-test-id="workspace-notes">
184+
<textarea
185+
class="workspace-notes__pad"
186+
data-test-id="workspace-notes-pad"
187+
aria-label=${widget.title ?? t("workspaces.widget.notes.placeholder")}
188+
placeholder=${t("workspaces.widget.notes.placeholder")}
189+
?readonly=${!context.state}
190+
.value=${seed}
191+
${context.state ? ref(bindEditor(context.state)) : null}
192+
></textarea>
193+
<div
194+
class="workspace-notes__status"
195+
data-test-id="workspace-notes-status"
196+
data-state=${context.state ? "idle" : "readonly"}
197+
role=${context.state ? "alert" : "status"}
198+
>
199+
${status}
200+
</div>
201+
</div>
202+
`;
203+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import type { TemplateResult } from "lit";
1313
import type { ApplicationConfigCapability } from "../../../app/config.ts";
1414
import type { WorkspaceWidget } from "../types.ts";
1515

16+
export type BuiltinWidgetState = {
17+
get(): Promise<{ state: unknown; version: number }>;
18+
set(state: unknown, expectedVersion: number): Promise<{ version: number }>;
19+
};
20+
1621
/** Ambient context a builtin may need beyond its own binding value. */
1722
export type BuiltinWidgetContext = {
1823
/** Control UI mount path used by builtins that link to another app route. */
@@ -22,6 +27,8 @@ export type BuiltinWidgetContext = {
2227
ApplicationConfigCapability["current"],
2328
"embedSandboxMode" | "allowExternalEmbedUrls"
2429
>;
30+
/** Host-bound persistence for trusted builtins; absent without a gateway client. */
31+
state?: BuiltinWidgetState;
2532
};
2633

2734
/** A builtin widget renderer: pure, side-effect-free, throws only on real bugs. */

0 commit comments

Comments
 (0)