@frockbot/plugin-applets 0.0.0 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +144 -0
- package/package.json +28 -6
- package/skills/applets.md +187 -0
- package/src/manifest.ts +3 -0
- package/src/package.test.ts +345 -0
- package/src/package.ts +394 -0
- package/src/pages/canvas.html +128 -0
- package/src/pages/list.html +180 -0
- package/src/root.test.ts +93 -0
- package/src/root.ts +111 -0
- package/src/template.generated.ts +37 -0
- package/README.md +0 -3
package/src/package.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// The Applets Package's isolate module.
|
|
2
|
+
//
|
|
3
|
+
// This is the whole Package. There is no in-process code anywhere in
|
|
4
|
+
// `@frockbot/plugin-applets`: ADR 0022 decision 8 makes the Applets product
|
|
5
|
+
// itself the pressure test for "every Contribution kind is resolved from the
|
|
6
|
+
// manifest and an artifact, never from a switch over Package identity", so the
|
|
7
|
+
// seven `applet_*` tools are written the way a Bot would have to write them —
|
|
8
|
+
// one `package.ts` exporting `tools` and `execute`, reaching the kernel only
|
|
9
|
+
// through the narrow `ctx` the generated wrapper hands it.
|
|
10
|
+
//
|
|
11
|
+
// Everything here is text a model reads. A tool that returns a JSON blob makes
|
|
12
|
+
// the model guess; a tool that returns a sentence naming the next command does
|
|
13
|
+
// not. So each verb answers with what happened, what it is called now, and the
|
|
14
|
+
// single next thing to do.
|
|
15
|
+
//
|
|
16
|
+
// The imports are type-only and one generated value module, so the bundled
|
|
17
|
+
// artifact has no import at all — `findUnresolvedSpecifier` in the bundler
|
|
18
|
+
// refuses a module that still carries one, and the isolate's module map has
|
|
19
|
+
// exactly two entries.
|
|
20
|
+
import type {
|
|
21
|
+
AppletGenerationSummaryV1,
|
|
22
|
+
AppletPublishResultV1,
|
|
23
|
+
AppletSummaryV1,
|
|
24
|
+
BotPackageContextV1,
|
|
25
|
+
IsolateAppletsOutcomeV1,
|
|
26
|
+
IsolateWorkspaceOutcomeV1,
|
|
27
|
+
} from "@frockbot/kernel-contracts";
|
|
28
|
+
import { APPLET_TEMPLATE_FILES_V1 } from "./template.generated.js";
|
|
29
|
+
|
|
30
|
+
/** Matches `frockbot.json`'s `id` and its declared root. */
|
|
31
|
+
const PACKAGE_ID = "applets";
|
|
32
|
+
const SOURCE_ROOT_ID = "source";
|
|
33
|
+
/** Where the durable root is mounted on a Fly Sprite. */
|
|
34
|
+
const COMPUTER_ROOT = "/home/box/agent-data/user-packages/applets/source";
|
|
35
|
+
|
|
36
|
+
export const tools = [
|
|
37
|
+
{
|
|
38
|
+
name: "applet_list",
|
|
39
|
+
description:
|
|
40
|
+
"List this User's Applets: the small real-time apps that appear beside the conversation. Every Bot of this User sees every Applet. Call this before creating one, so you extend an Applet that already exists instead of building a second one.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {},
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
},
|
|
46
|
+
idempotent: true,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "applet_create",
|
|
50
|
+
description:
|
|
51
|
+
"Create a new Applet and scaffold its source. This makes the directory entry, writes a working todo-list starting point into the Applet's source directory on this User's Computer, and focuses it so the User watches you build it. It does not publish anything: edit the files, run `applet check` and `applet build` on the Computer, then call applet_publish. Load the `applets` Skill before you start editing.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
displayName: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description:
|
|
58
|
+
"What the User will call this Applet, in their words. 1-128 characters.",
|
|
59
|
+
minLength: 1,
|
|
60
|
+
maxLength: 128,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ["displayName"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
idempotent: false,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "applet_publish",
|
|
70
|
+
description:
|
|
71
|
+
"Publish what `applet build` last wrote for this Applet. Reads dist/server.js, dist/ui.html and dist/manifest.json from the Applet's source directory, records an immutable generation, mounts it, and offers its tools to every Bot of this User from the next Turn. Run `applet check` and `applet build` on the Computer first; a publish of a stale or failing build is refused and tells you why.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
appletId: { type: "string", description: "The Applet's id." },
|
|
76
|
+
},
|
|
77
|
+
required: ["appletId"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
idempotent: false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "applet_revert",
|
|
84
|
+
description:
|
|
85
|
+
"Move an Applet back to an earlier generation. The revert is itself recorded as a generation, and the Applet's stored data is untouched — reverting the code never clears what the User put in it. Use applet_generations to find the id.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
appletId: { type: "string", description: "The Applet's id." },
|
|
90
|
+
generationId: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "The generation to make current again.",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
required: ["appletId", "generationId"],
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
},
|
|
98
|
+
idempotent: false,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "applet_delete",
|
|
102
|
+
description:
|
|
103
|
+
"Delete an Applet permanently: its stored data, its versions, and its entry. This cannot be undone and it is the User's decision, not yours — ask before calling it.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
appletId: { type: "string", description: "The Applet's id." },
|
|
108
|
+
},
|
|
109
|
+
required: ["appletId"],
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
},
|
|
112
|
+
idempotent: false,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "applet_focus",
|
|
116
|
+
description:
|
|
117
|
+
"Show one Applet in the panel beside this conversation, or clear it. Pass null to close the panel. Creating and publishing already focus the Applet, so use this when the User asks to look at a different one.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: {
|
|
121
|
+
appletId: {
|
|
122
|
+
type: ["string", "null"],
|
|
123
|
+
description: "The Applet to show, or null to close the panel.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
required: ["appletId"],
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
},
|
|
129
|
+
idempotent: false,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "applet_generations",
|
|
133
|
+
description:
|
|
134
|
+
"List an Applet's version history, newest first: which generation is current, which failed, and what tools each one offered. Read this before applet_revert.",
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: "object",
|
|
137
|
+
properties: {
|
|
138
|
+
appletId: { type: "string", description: "The Applet's id." },
|
|
139
|
+
},
|
|
140
|
+
required: ["appletId"],
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
idempotent: true,
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
function text(value: unknown): string {
|
|
148
|
+
return typeof value === "string" ? value : String(value ?? "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function requireString(input: unknown, field: string): string {
|
|
152
|
+
const value = (input as Record<string, unknown> | null | undefined)?.[field];
|
|
153
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
154
|
+
throw new Error(`${field} is required`);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Unwrap a capability outcome, or throw the reason as the tool's answer.
|
|
161
|
+
*
|
|
162
|
+
* An `unavailable` outcome is a fact about this Bot's authority, not a bug, so
|
|
163
|
+
* the model is told the reason verbatim rather than a generic failure.
|
|
164
|
+
*/
|
|
165
|
+
function value(outcome: IsolateAppletsOutcomeV1): unknown {
|
|
166
|
+
if (outcome.status !== "available") {
|
|
167
|
+
throw new Error(`Applets are unavailable: ${text(outcome.reason)}`);
|
|
168
|
+
}
|
|
169
|
+
return outcome.value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function summary(input: unknown): AppletSummaryV1 {
|
|
173
|
+
return input as AppletSummaryV1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function describe(applet: AppletSummaryV1): string {
|
|
177
|
+
const tools =
|
|
178
|
+
applet.tools.length === 0 ? "no tools yet" : applet.tools.join(", ");
|
|
179
|
+
return `${applet.displayName} (${applet.appletId}) — ${applet.status}, ${
|
|
180
|
+
applet.currentGenerationId
|
|
181
|
+
? `generation ${applet.currentGenerationId}`
|
|
182
|
+
: "never published"
|
|
183
|
+
}, ${tools}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sourceDirectory(appletId: string): string {
|
|
187
|
+
return `${COMPUTER_ROOT}/${appletId}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The template's bytes, from the base64 the build embedded.
|
|
192
|
+
*
|
|
193
|
+
* Not a nicety: the Package bundler refuses a module whose text carries an
|
|
194
|
+
* import specifier it cannot resolve, and Applet source carries several, so
|
|
195
|
+
* the scaffold travels encoded and is decoded here.
|
|
196
|
+
*/
|
|
197
|
+
function decodeTemplate(base64: string): string {
|
|
198
|
+
const binary = atob(base64);
|
|
199
|
+
const bytes = new Uint8Array(binary.length);
|
|
200
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
201
|
+
bytes[index] = binary.charCodeAt(index);
|
|
202
|
+
}
|
|
203
|
+
return new TextDecoder().decode(bytes);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The scaffold, with the template's two placeholders filled in. */
|
|
207
|
+
function scaffold(
|
|
208
|
+
appletId: string,
|
|
209
|
+
displayName: string,
|
|
210
|
+
): Array<{ path: string; text: string }> {
|
|
211
|
+
const slug =
|
|
212
|
+
displayName
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
215
|
+
.replace(/^-+|-+$/g, "")
|
|
216
|
+
.replace(/^[^a-z]*/, "")
|
|
217
|
+
.slice(0, 32) || "applet";
|
|
218
|
+
return APPLET_TEMPLATE_FILES_V1.map((file) => ({
|
|
219
|
+
path: `${appletId}/${file.path}`,
|
|
220
|
+
text: decodeTemplate(file.base64)
|
|
221
|
+
.split("__APPLET_ID__")
|
|
222
|
+
.join(slug)
|
|
223
|
+
.split("__APPLET_NAME__")
|
|
224
|
+
.join(displayName),
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function writeScaffold(
|
|
229
|
+
ctx: BotPackageContextV1,
|
|
230
|
+
appletId: string,
|
|
231
|
+
displayName: string,
|
|
232
|
+
): Promise<string[]> {
|
|
233
|
+
const written: string[] = [];
|
|
234
|
+
const encoder = new TextEncoder();
|
|
235
|
+
for (const file of scaffold(appletId, displayName)) {
|
|
236
|
+
const outcome: IsolateWorkspaceOutcomeV1 = await ctx.workspace.write({
|
|
237
|
+
path: {
|
|
238
|
+
root: {
|
|
239
|
+
kind: "package-declared",
|
|
240
|
+
packageId: PACKAGE_ID,
|
|
241
|
+
rootId: SOURCE_ROOT_ID,
|
|
242
|
+
},
|
|
243
|
+
path: file.path,
|
|
244
|
+
},
|
|
245
|
+
bytes: encoder.encode(file.text),
|
|
246
|
+
expectedGenerationId: null,
|
|
247
|
+
mediaType: file.path.endsWith(".json")
|
|
248
|
+
? "application/json"
|
|
249
|
+
: "text/plain; charset=utf-8",
|
|
250
|
+
});
|
|
251
|
+
if (outcome.status !== "available") {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`the Applet was created but its source could not be written: ${text(
|
|
254
|
+
outcome.reason,
|
|
255
|
+
)}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
written.push(file.path);
|
|
259
|
+
}
|
|
260
|
+
return written;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function publishText(result: AppletPublishResultV1, verb: string): string {
|
|
264
|
+
if (result.status === "published") {
|
|
265
|
+
const tools =
|
|
266
|
+
result.tools.length === 0
|
|
267
|
+
? "It declares no tools."
|
|
268
|
+
: `Its tools are now ${result.tools.join(", ")}, offered from your next Turn.`;
|
|
269
|
+
return [
|
|
270
|
+
`${verb} ${result.appletId} as generation ${result.generationId}.`,
|
|
271
|
+
tools,
|
|
272
|
+
result.compositionGenerationId
|
|
273
|
+
? `Recorded as Composition generation ${result.compositionGenerationId}.`
|
|
274
|
+
: undefined,
|
|
275
|
+
"It is in the panel beside the conversation now.",
|
|
276
|
+
]
|
|
277
|
+
.filter((part): part is string => part !== undefined)
|
|
278
|
+
.join(" ");
|
|
279
|
+
}
|
|
280
|
+
return [
|
|
281
|
+
`${verb === "Published" ? "Publishing" : "Reverting"} ${result.appletId} failed: ${result.reason}`,
|
|
282
|
+
...result.diagnostics,
|
|
283
|
+
"Nothing changed: the Applet is still on the generation it was on.",
|
|
284
|
+
].join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function generationsText(
|
|
288
|
+
appletId: string,
|
|
289
|
+
generations: AppletGenerationSummaryV1[],
|
|
290
|
+
): string {
|
|
291
|
+
if (generations.length === 0) {
|
|
292
|
+
return `${appletId} has no generations yet. Build it and call applet_publish.`;
|
|
293
|
+
}
|
|
294
|
+
return [
|
|
295
|
+
`${appletId} has ${generations.length} generation(s), newest first:`,
|
|
296
|
+
...generations.map(
|
|
297
|
+
(generation) =>
|
|
298
|
+
`${generation.generationId} — ${generation.origin}, ${generation.status}${
|
|
299
|
+
generation.isCurrent ? ", current" : ""
|
|
300
|
+
}, tools: ${
|
|
301
|
+
generation.tools.length === 0 ? "none" : generation.tools.join(", ")
|
|
302
|
+
}`,
|
|
303
|
+
),
|
|
304
|
+
].join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function execute(
|
|
308
|
+
tool: string,
|
|
309
|
+
input: unknown,
|
|
310
|
+
ctx: BotPackageContextV1,
|
|
311
|
+
): Promise<string> {
|
|
312
|
+
switch (tool) {
|
|
313
|
+
case "applet_list": {
|
|
314
|
+
const applets = (value(await ctx.applets.list()) as unknown[]).map(
|
|
315
|
+
summary,
|
|
316
|
+
);
|
|
317
|
+
if (applets.length === 0) {
|
|
318
|
+
return "This User has no Applets yet. applet_create scaffolds one from a working todo-list starting point.";
|
|
319
|
+
}
|
|
320
|
+
return [
|
|
321
|
+
`${applets.length} Applet(s):`,
|
|
322
|
+
...applets.map((applet) => describe(applet)),
|
|
323
|
+
].join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case "applet_create": {
|
|
327
|
+
const displayName = requireString(input, "displayName");
|
|
328
|
+
const created = summary(value(await ctx.applets.create({ displayName })));
|
|
329
|
+
const written = await writeScaffold(ctx, created.appletId, displayName);
|
|
330
|
+
const directory = sourceDirectory(created.appletId);
|
|
331
|
+
return [
|
|
332
|
+
`Created "${created.displayName}" (${created.appletId}) and put it in the panel beside the conversation.`,
|
|
333
|
+
`Its source is on the Computer at ${directory}: ${written
|
|
334
|
+
.map((path) => path.slice(created.appletId.length + 1))
|
|
335
|
+
.join(", ")}.`,
|
|
336
|
+
"It is the SDK's todo-list starting point and it already builds.",
|
|
337
|
+
"Next, on the Computer:",
|
|
338
|
+
`1. Read the \`applets\` Skill if you have not already — it is the SDK reference.`,
|
|
339
|
+
`2. Edit server.ts (tables and tools) and ui.tsx (the page) in ${directory}.`,
|
|
340
|
+
`3. Run \`applet check\` in ${directory} and fix every error it prints.`,
|
|
341
|
+
`4. Run \`applet build\` in ${directory}.`,
|
|
342
|
+
`5. Call applet_publish with appletId ${created.appletId}.`,
|
|
343
|
+
].join("\n");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case "applet_publish": {
|
|
347
|
+
const appletId = requireString(input, "appletId");
|
|
348
|
+
const result = value(
|
|
349
|
+
await ctx.applets.publish({ appletId }),
|
|
350
|
+
) as AppletPublishResultV1;
|
|
351
|
+
return publishText(result, "Published");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case "applet_revert": {
|
|
355
|
+
const appletId = requireString(input, "appletId");
|
|
356
|
+
const generationId = requireString(input, "generationId");
|
|
357
|
+
const result = value(
|
|
358
|
+
await ctx.applets.revert({ appletId, generationId }),
|
|
359
|
+
) as AppletPublishResultV1;
|
|
360
|
+
return publishText(result, "Reverted");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
case "applet_delete": {
|
|
364
|
+
const appletId = requireString(input, "appletId");
|
|
365
|
+
value(await ctx.applets.delete({ appletId }));
|
|
366
|
+
return `Deleted ${appletId}. Its data, its versions, and its tools are gone; this cannot be undone.`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case "applet_focus": {
|
|
370
|
+
const raw = (input as Record<string, unknown> | null | undefined)
|
|
371
|
+
?.appletId;
|
|
372
|
+
if (raw !== null && typeof raw !== "string") {
|
|
373
|
+
throw new Error("appletId must be an Applet id or null");
|
|
374
|
+
}
|
|
375
|
+
const focused = value(await ctx.applets.focus({ appletId: raw })) as {
|
|
376
|
+
appletId: string | null;
|
|
377
|
+
};
|
|
378
|
+
return focused.appletId === null
|
|
379
|
+
? "Closed the Applet panel."
|
|
380
|
+
: `Showing ${focused.appletId} in the panel beside the conversation.`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
case "applet_generations": {
|
|
384
|
+
const appletId = requireString(input, "appletId");
|
|
385
|
+
const generations = value(
|
|
386
|
+
await ctx.applets.generations({ appletId }),
|
|
387
|
+
) as AppletGenerationSummaryV1[];
|
|
388
|
+
return generationsText(appletId, generations);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
default:
|
|
392
|
+
throw new Error(`the Applets Package does not implement "${tool}"`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
The Applet canvas page.
|
|
4
|
+
|
|
5
|
+
`AppletCanvas.vue` owns the frame — the header, the two states, the slide —
|
|
6
|
+
and mounts this page in `frockbot.right-panel` for the "ready" half. All this
|
|
7
|
+
page does is nest the Applet's own UI artifact and hand it the one thing it
|
|
8
|
+
cannot fetch for itself: the short-lived viewer token, the socket URL, and
|
|
9
|
+
the theme tokens, in the `init` message `listenForAppletInit` in
|
|
10
|
+
`@frockbot/applet-sdk/client` waits for.
|
|
11
|
+
|
|
12
|
+
Two frames rather than one because the trust boundaries differ. This page is
|
|
13
|
+
a page of the Applets Package, pinned by the Composition. The Applet inside
|
|
14
|
+
it is one User's own code, on its own generation, replaced whenever they
|
|
15
|
+
publish — so it gets its own document, and a new generation is a new frame,
|
|
16
|
+
never a reused one.
|
|
17
|
+
-->
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8" />
|
|
21
|
+
<title>Applet</title>
|
|
22
|
+
<style>
|
|
23
|
+
html,
|
|
24
|
+
body {
|
|
25
|
+
height: 100%;
|
|
26
|
+
margin: 0;
|
|
27
|
+
color: var(--frockbot-text);
|
|
28
|
+
background: var(--frockbot-surface);
|
|
29
|
+
font-family:
|
|
30
|
+
ui-sans-serif,
|
|
31
|
+
system-ui,
|
|
32
|
+
-apple-system,
|
|
33
|
+
sans-serif;
|
|
34
|
+
font-size: 14px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#frame {
|
|
38
|
+
display: block;
|
|
39
|
+
width: 100%;
|
|
40
|
+
height: 100%;
|
|
41
|
+
border: 0;
|
|
42
|
+
background: var(--frockbot-surface);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#idle {
|
|
46
|
+
display: flex;
|
|
47
|
+
height: 100%;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
padding: 24px;
|
|
51
|
+
color: var(--frockbot-text-muted);
|
|
52
|
+
text-align: center;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
[hidden] {
|
|
56
|
+
display: none !important;
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<p id="idle">This Applet has no published version yet.</p>
|
|
62
|
+
<iframe
|
|
63
|
+
id="frame"
|
|
64
|
+
hidden
|
|
65
|
+
title="Applet"
|
|
66
|
+
sandbox="allow-scripts"
|
|
67
|
+
referrerpolicy="no-referrer"
|
|
68
|
+
></iframe>
|
|
69
|
+
<script>
|
|
70
|
+
PACKAGE_IFRAME_HELPER_JS_V1;
|
|
71
|
+
</script>
|
|
72
|
+
<script>
|
|
73
|
+
(() => {
|
|
74
|
+
const frame = document.getElementById("frame");
|
|
75
|
+
const idle = document.getElementById("idle");
|
|
76
|
+
let themeTokens = {};
|
|
77
|
+
let mounted = null;
|
|
78
|
+
let pending = null;
|
|
79
|
+
|
|
80
|
+
/* The Applet is told who it is once its own document has loaded. */
|
|
81
|
+
frame.addEventListener("load", () => {
|
|
82
|
+
if (!pending || !frame.contentWindow) return;
|
|
83
|
+
frame.contentWindow.postMessage(
|
|
84
|
+
{
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
type: "init",
|
|
87
|
+
themeTokens,
|
|
88
|
+
applet: {
|
|
89
|
+
socketUrl: pending.socketUrl,
|
|
90
|
+
token: pending.token,
|
|
91
|
+
generationId: pending.generationId,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
"*",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function show(viewer) {
|
|
99
|
+
if (!viewer) {
|
|
100
|
+
mounted = null;
|
|
101
|
+
pending = null;
|
|
102
|
+
frame.hidden = true;
|
|
103
|
+
frame.removeAttribute("src");
|
|
104
|
+
idle.hidden = false;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
idle.hidden = true;
|
|
108
|
+
frame.hidden = false;
|
|
109
|
+
pending = viewer;
|
|
110
|
+
// A new generation, a new token, or a new URL is a new document: the
|
|
111
|
+
// Applet's `init` is delivered on load and never re-sent into a page
|
|
112
|
+
// that has already connected with a token that is about to expire.
|
|
113
|
+
const identity = `${viewer.generationId}|${viewer.uiUrl}|${viewer.token}`;
|
|
114
|
+
if (identity === mounted) return;
|
|
115
|
+
mounted = identity;
|
|
116
|
+
frame.setAttribute("src", viewer.uiUrl);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
window.frockbot.ready.then((init) => {
|
|
120
|
+
themeTokens = init.themeTokens;
|
|
121
|
+
window.frockbot.subscribe("applets", (state) => {
|
|
122
|
+
show(state && state.viewer ? state.viewer : null);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
})();
|
|
126
|
+
</script>
|
|
127
|
+
</body>
|
|
128
|
+
</html>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
The Applets surface: everything this User owns, and one action per row.
|
|
4
|
+
|
|
5
|
+
It is a page of the Applets Package, not of the shell, so it holds no
|
|
6
|
+
authority: it reads the `applets` state the host feeds it and asks the host
|
|
7
|
+
to change the focus. Every colour is a `--frockbot-*` token the host injects
|
|
8
|
+
on `init`, so the page follows the User's theme without knowing which one is
|
|
9
|
+
on.
|
|
10
|
+
-->
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8" />
|
|
14
|
+
<title>Applets</title>
|
|
15
|
+
<style>
|
|
16
|
+
:root {
|
|
17
|
+
color-scheme: light dark;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
html,
|
|
21
|
+
body {
|
|
22
|
+
margin: 0;
|
|
23
|
+
color: var(--frockbot-text);
|
|
24
|
+
background: var(--frockbot-surface);
|
|
25
|
+
font-family:
|
|
26
|
+
ui-sans-serif,
|
|
27
|
+
system-ui,
|
|
28
|
+
-apple-system,
|
|
29
|
+
sans-serif;
|
|
30
|
+
font-size: 14px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main {
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
gap: 8px;
|
|
37
|
+
padding: 16px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
h1 {
|
|
41
|
+
margin: 0 0 4px;
|
|
42
|
+
font-size: 18px;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.row {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 12px;
|
|
50
|
+
padding: 10px 12px;
|
|
51
|
+
border: 1px solid var(--frockbot-border);
|
|
52
|
+
border-radius: var(--frockbot-radius-card, 12px);
|
|
53
|
+
background: var(--frockbot-surface-raised);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.grow {
|
|
57
|
+
display: flex;
|
|
58
|
+
min-width: 0;
|
|
59
|
+
flex: 1;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: 2px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.name {
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
font-weight: 600;
|
|
67
|
+
white-space: nowrap;
|
|
68
|
+
text-overflow: ellipsis;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.meta {
|
|
72
|
+
overflow: hidden;
|
|
73
|
+
color: var(--frockbot-text-muted);
|
|
74
|
+
font-size: 12px;
|
|
75
|
+
white-space: nowrap;
|
|
76
|
+
text-overflow: ellipsis;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
button {
|
|
80
|
+
flex: 0 0 auto;
|
|
81
|
+
padding: 6px 12px;
|
|
82
|
+
border: 1px solid var(--frockbot-border);
|
|
83
|
+
border-radius: 999px;
|
|
84
|
+
color: var(--frockbot-accent-text);
|
|
85
|
+
background: var(--frockbot-accent-surface);
|
|
86
|
+
font: inherit;
|
|
87
|
+
font-size: 13px;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.empty {
|
|
92
|
+
padding: 24px 12px;
|
|
93
|
+
color: var(--frockbot-text-muted);
|
|
94
|
+
text-align: center;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.empty strong {
|
|
98
|
+
display: block;
|
|
99
|
+
margin-bottom: 6px;
|
|
100
|
+
color: var(--frockbot-text);
|
|
101
|
+
font-size: 15px;
|
|
102
|
+
}
|
|
103
|
+
</style>
|
|
104
|
+
</head>
|
|
105
|
+
<body>
|
|
106
|
+
<main>
|
|
107
|
+
<h1>Applets</h1>
|
|
108
|
+
<div id="applets" aria-live="polite">
|
|
109
|
+
<p class="empty">Loading…</p>
|
|
110
|
+
</div>
|
|
111
|
+
</main>
|
|
112
|
+
<script>
|
|
113
|
+
PACKAGE_IFRAME_HELPER_JS_V1;
|
|
114
|
+
</script>
|
|
115
|
+
<script>
|
|
116
|
+
(() => {
|
|
117
|
+
const host = document.getElementById("applets");
|
|
118
|
+
|
|
119
|
+
function meta(applet) {
|
|
120
|
+
const generation = applet.currentGenerationId
|
|
121
|
+
? `generation ${applet.currentGenerationId}`
|
|
122
|
+
: "not published yet";
|
|
123
|
+
const tools =
|
|
124
|
+
applet.tools.length === 0
|
|
125
|
+
? "no tools"
|
|
126
|
+
: `${applet.tools.length} tool${applet.tools.length === 1 ? "" : "s"}`;
|
|
127
|
+
return `${applet.status} · ${generation} · ${tools}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function render(state) {
|
|
131
|
+
const applets = Array.isArray(state?.list) ? state.list : [];
|
|
132
|
+
host.textContent = "";
|
|
133
|
+
if (applets.length === 0) {
|
|
134
|
+
const empty = document.createElement("p");
|
|
135
|
+
empty.className = "empty";
|
|
136
|
+
const title = document.createElement("strong");
|
|
137
|
+
title.textContent = "No Applets yet";
|
|
138
|
+
empty.append(
|
|
139
|
+
title,
|
|
140
|
+
"Ask this Bot to build one — a todo list, a tracker, a board. It writes it, publishes it, and it opens here.",
|
|
141
|
+
);
|
|
142
|
+
host.append(empty);
|
|
143
|
+
window.frockbot.resize();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const applet of applets) {
|
|
147
|
+
const row = document.createElement("div");
|
|
148
|
+
row.className = "row";
|
|
149
|
+
const grow = document.createElement("div");
|
|
150
|
+
grow.className = "grow";
|
|
151
|
+
const name = document.createElement("span");
|
|
152
|
+
name.className = "name";
|
|
153
|
+
name.textContent = applet.displayName;
|
|
154
|
+
const detail = document.createElement("span");
|
|
155
|
+
detail.className = "meta";
|
|
156
|
+
detail.textContent = meta(applet);
|
|
157
|
+
grow.append(name, detail);
|
|
158
|
+
const open = document.createElement("button");
|
|
159
|
+
open.type = "button";
|
|
160
|
+
open.textContent =
|
|
161
|
+
state.focused && state.focused.appletId === applet.appletId
|
|
162
|
+
? "Showing"
|
|
163
|
+
: "Open";
|
|
164
|
+
open.addEventListener("click", () => {
|
|
165
|
+
window.frockbot.focus(applet.appletId);
|
|
166
|
+
});
|
|
167
|
+
row.append(grow, open);
|
|
168
|
+
host.append(row);
|
|
169
|
+
}
|
|
170
|
+
window.frockbot.resize();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
window.frockbot.ready.then(() => {
|
|
174
|
+
window.frockbot.subscribe("applets", render);
|
|
175
|
+
window.frockbot.resize();
|
|
176
|
+
});
|
|
177
|
+
})();
|
|
178
|
+
</script>
|
|
179
|
+
</body>
|
|
180
|
+
</html>
|