@frockbot/plugin-shell 0.3.22 → 0.3.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,42 +52,42 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@cordisjs/plugin-webui": "0.8.2",
55
- "@frockbot/agent-runtime": "0.3.22",
56
- "@frockbot/application-foundation": "0.3.22",
57
- "@frockbot/catalog-core": "0.3.22",
58
- "@frockbot/client-core": "0.3.22",
59
- "@frockbot/client-ui": "0.3.22",
60
- "@frockbot/computer-core": "0.3.22",
61
- "@frockbot/computer-host-protocol": "0.3.22",
62
- "@frockbot/configuration-core": "0.3.22",
63
- "@frockbot/connection-core": "0.3.22",
64
- "@frockbot/kernel-agent-loop": "0.3.22",
65
- "@frockbot/kernel-composition": "0.3.22",
66
- "@frockbot/kernel-contracts": "0.3.22",
67
- "@frockbot/kernel-do": "0.3.22",
68
- "@frockbot/machine-protocol": "0.3.22",
69
- "@frockbot/plugin-applets": "0.3.22",
70
- "@frockbot/plugin-authoring": "0.3.22",
71
- "@frockbot/plugin-bot-template": "0.3.22",
72
- "@frockbot/plugin-computer": "0.3.22",
73
- "@frockbot/plugin-flock": "0.3.22",
74
- "@frockbot/plugin-image": "0.3.22",
75
- "@frockbot/plugin-machine-messages": "0.3.22",
76
- "@frockbot/plugin-mcp": "0.3.22",
77
- "@frockbot/plugin-memory": "0.3.22",
78
- "@frockbot/plugin-package-catalog": "0.3.22",
79
- "@frockbot/plugin-routines": "0.3.22",
80
- "@frockbot/plugin-skills": "0.3.22",
81
- "@frockbot/plugin-subagents": "0.3.22",
82
- "@frockbot/plugin-user-machine": "0.3.22",
83
- "@frockbot/protocol": "0.3.22",
55
+ "@frockbot/agent-runtime": "0.3.23",
56
+ "@frockbot/application-foundation": "0.3.23",
57
+ "@frockbot/catalog-core": "0.3.23",
58
+ "@frockbot/client-core": "0.3.23",
59
+ "@frockbot/client-ui": "0.3.23",
60
+ "@frockbot/computer-core": "0.3.23",
61
+ "@frockbot/computer-host-protocol": "0.3.23",
62
+ "@frockbot/configuration-core": "0.3.23",
63
+ "@frockbot/connection-core": "0.3.23",
64
+ "@frockbot/kernel-agent-loop": "0.3.23",
65
+ "@frockbot/kernel-composition": "0.3.23",
66
+ "@frockbot/kernel-contracts": "0.3.23",
67
+ "@frockbot/kernel-do": "0.3.23",
68
+ "@frockbot/machine-protocol": "0.3.23",
69
+ "@frockbot/plugin-applets": "0.3.23",
70
+ "@frockbot/plugin-authoring": "0.3.23",
71
+ "@frockbot/plugin-bot-template": "0.3.23",
72
+ "@frockbot/plugin-computer": "0.3.23",
73
+ "@frockbot/plugin-flock": "0.3.23",
74
+ "@frockbot/plugin-image": "0.3.23",
75
+ "@frockbot/plugin-machine-messages": "0.3.23",
76
+ "@frockbot/plugin-mcp": "0.3.23",
77
+ "@frockbot/plugin-memory": "0.3.23",
78
+ "@frockbot/plugin-package-catalog": "0.3.23",
79
+ "@frockbot/plugin-routines": "0.3.23",
80
+ "@frockbot/plugin-skills": "0.3.23",
81
+ "@frockbot/plugin-subagents": "0.3.23",
82
+ "@frockbot/plugin-user-machine": "0.3.23",
83
+ "@frockbot/protocol": "0.3.23",
84
84
  "cordis": "4.0.0-rc.8",
85
85
  "vue": "3.5.41"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@cloudflare/workers-types": "latest",
89
- "@frockbot/plugin-prompt": "0.3.22",
90
- "@frockbot/plugin-tools": "0.3.22",
89
+ "@frockbot/plugin-prompt": "0.3.23",
90
+ "@frockbot/plugin-tools": "0.3.23",
91
91
  "@types/bun": "1.3.6",
92
92
  "@types/node": "26.2.0",
93
93
  "@vitejs/plugin-vue": "6.0.8",
@@ -19,6 +19,7 @@ import { planBotRunRecovery } from "./backend-recovery.js";
19
19
  import {
20
20
  CLIENT_RUN_LIST_MAX_BYTES,
21
21
  CLIENT_RUN_PAGE_LIMIT,
22
+ CLIENT_RUN_SCAN_LIMIT,
22
23
  clientRunListWireBytes,
23
24
  } from "./run-protocol.js";
24
25
 
@@ -1475,6 +1476,139 @@ describe("Bot recovery", () => {
1475
1476
  ).toBe(false);
1476
1477
  });
1477
1478
 
1479
+ /**
1480
+ * The run index is global; the transcript is one conversation. These cover
1481
+ * the shape that used to answer "this conversation is empty" whenever the
1482
+ * newest page of the index happened to hold nothing the reader could see.
1483
+ */
1484
+ async function writeRunHistory(
1485
+ storage: MemoryStorage,
1486
+ runs: Array<{
1487
+ runId: string;
1488
+ sessionId: string;
1489
+ turnType?: "chat" | "automation";
1490
+ }>,
1491
+ ): Promise<void> {
1492
+ const baseTime = Date.parse("2026-09-01T00:00:00.000Z");
1493
+ for (const [index, entry] of runs.entries()) {
1494
+ const acceptedAt = new Date(baseTime + index * 1_000).toISOString();
1495
+ const run = {
1496
+ runId: entry.runId,
1497
+ commandFingerprint: `fingerprint-${index}`,
1498
+ sessionId: entry.sessionId,
1499
+ acceptedAt,
1500
+ input: "hello",
1501
+ events: [],
1502
+ effectAdmissions: [],
1503
+ status: "completed",
1504
+ phase: "executing",
1505
+ compositionGenerationId: "test-composition-generation",
1506
+ configurationSnapshot: initializeBotSettingsV1("primary"),
1507
+ previousEventCount: 0,
1508
+ responseText: "answer",
1509
+ ...(entry.turnType && entry.turnType !== "chat"
1510
+ ? { admission: { schemaVersion: 1, turnType: entry.turnType } }
1511
+ : {}),
1512
+ } satisfies StoredRun;
1513
+ await storage.put({
1514
+ [`run:${entry.runId}`]: run,
1515
+ [`run-index:${acceptedAt}:${entry.runId}`]: entry.runId,
1516
+ });
1517
+ }
1518
+ }
1519
+
1520
+ test("reaches an older conversation buried under newer Turns", async () => {
1521
+ const storage = new MemoryStorage();
1522
+ await writeRunHistory(storage, [
1523
+ { runId: "run-older", sessionId: "user:primary:older" },
1524
+ ...Array.from({ length: CLIENT_RUN_PAGE_LIMIT + 2 }, (_value, index) => ({
1525
+ runId: `run-newer-${index.toString().padStart(3, "0")}`,
1526
+ sessionId: "user:primary:newer",
1527
+ })),
1528
+ ]);
1529
+ const contribution = createShellBotBackendContribution({
1530
+ state: { storage } as unknown as DurableObjectState,
1531
+ env: {} as never,
1532
+ });
1533
+
1534
+ const page = await contribution.listRuns({
1535
+ schemaVersion: 1,
1536
+ conversationId: "user:primary:older",
1537
+ });
1538
+
1539
+ expect(page.runs.map((run) => run.runId)).toEqual(["run-older"]);
1540
+ expect(page.page.truncated).toBe(false);
1541
+ });
1542
+
1543
+ test("keeps the visible chat reachable behind a page of automation", async () => {
1544
+ const storage = new MemoryStorage();
1545
+ await writeRunHistory(storage, [
1546
+ { runId: "run-chat", sessionId: "user:primary" },
1547
+ ...Array.from({ length: CLIENT_RUN_PAGE_LIMIT + 2 }, (_value, index) => ({
1548
+ runId: `run-automation-${index.toString().padStart(3, "0")}`,
1549
+ sessionId: "user:primary",
1550
+ turnType: "automation" as const,
1551
+ })),
1552
+ ]);
1553
+ const contribution = createShellBotBackendContribution({
1554
+ state: { storage } as unknown as DurableObjectState,
1555
+ env: {} as never,
1556
+ });
1557
+
1558
+ const page = await contribution.listRuns({
1559
+ schemaVersion: 1,
1560
+ conversationId: "user:primary",
1561
+ });
1562
+
1563
+ expect(page.runs.map((run) => run.runId)).toEqual(["run-chat"]);
1564
+ });
1565
+
1566
+ test("offers a continuation when a scanned page selects nothing", async () => {
1567
+ const storage = new MemoryStorage();
1568
+ const buried = CLIENT_RUN_SCAN_LIMIT + CLIENT_RUN_PAGE_LIMIT;
1569
+ await writeRunHistory(storage, [
1570
+ { runId: "run-older", sessionId: "user:primary:older" },
1571
+ ...Array.from({ length: buried }, (_value, index) => ({
1572
+ runId: `run-newer-${index.toString().padStart(4, "0")}`,
1573
+ sessionId: "user:primary:newer",
1574
+ })),
1575
+ ]);
1576
+ const contribution = createShellBotBackendContribution({
1577
+ state: { storage } as unknown as DurableObjectState,
1578
+ env: {} as never,
1579
+ });
1580
+
1581
+ let page = await contribution.listRuns({
1582
+ schemaVersion: 1,
1583
+ conversationId: "user:primary:older",
1584
+ });
1585
+ // The budget stops the scan short of the match, so the page must be empty
1586
+ // *and* say where to resume. Answering `truncated: false` here is the
1587
+ // defect: it told the client the conversation had nothing in it.
1588
+ expect(page.runs).toEqual([]);
1589
+ expect(page.page.truncated).toBe(true);
1590
+ expect(page.page.nextCursor).toBeDefined();
1591
+
1592
+ const seen = new Set<string>();
1593
+ let requests = 0;
1594
+ while (page.runs.length === 0 && page.page.nextCursor) {
1595
+ if (seen.has(page.page.nextCursor)) {
1596
+ throw new Error("run list cursor stopped advancing");
1597
+ }
1598
+ seen.add(page.page.nextCursor);
1599
+ requests += 1;
1600
+ if (requests > 20) throw new Error("run list did not converge");
1601
+ page = await contribution.listRuns({
1602
+ schemaVersion: 1,
1603
+ conversationId: "user:primary:older",
1604
+ before: page.page.nextCursor,
1605
+ });
1606
+ }
1607
+
1608
+ expect(page.runs.map((run) => run.runId)).toEqual(["run-older"]);
1609
+ expect(page.page.truncated).toBe(false);
1610
+ });
1611
+
1478
1612
  test("pages large run history with bounded indexed reads and wire bytes", async () => {
1479
1613
  const storage = new MemoryStorage();
1480
1614
  const baseTime = Date.parse("2026-08-28T00:00:00.000Z");
package/src/backend.ts CHANGED
@@ -404,6 +404,7 @@ import {
404
404
  import {
405
405
  CLIENT_RUN_LIST_MAX_BYTES,
406
406
  CLIENT_RUN_PAGE_LIMIT,
407
+ CLIENT_RUN_SCAN_LIMIT,
407
408
  clientRunListWireBytes,
408
409
  createClientRunListV1,
409
410
  createClientRunStopReceiptV1,
@@ -5833,7 +5834,7 @@ export class ShellBotBackendContribution {
5833
5834
 
5834
5835
  const selected = new Map<string, { cursor?: string; run: ClientRunV1 }>();
5835
5836
  if (activeRunId) {
5836
- const active = await this.authority.readStoredRun(activeRunId);
5837
+ const active = await this.authority.readStoredRunForDisplay(activeRunId);
5837
5838
  // An automation firing occupies the object like any other run, and is
5838
5839
  // still not part of the conversation: the visible transcript never
5839
5840
  // shows one, running or settled.
@@ -5842,60 +5843,110 @@ export class ShellBotBackendContribution {
5842
5843
  run: projectClientRunOrDegradedV1(active),
5843
5844
  });
5844
5845
  }
5845
- const available = candidates.slice(0, CLIENT_RUN_PAGE_LIMIT);
5846
+ // The run index is global and the transcript is one conversation, so a
5847
+ // page of candidates is not a page of answers: 33 Turns of another
5848
+ // conversation, or of automation, used to come back as an empty,
5849
+ // *untruncated* page that told the client there was nothing older. The
5850
+ // scan cursor now advances over every candidate this call consumed,
5851
+ // whether or not it was kept, so a filtered-out page still says where to
5852
+ // resume; and the scan keeps reading batches, up to a budget, so the
5853
+ // common case answers in one request rather than making the client walk
5854
+ // the history a page at a time. Filtering reads run records only —
5855
+ // hydrating a Turn's journal is what selection costs, not what the scan
5856
+ // costs.
5846
5857
  let stoppedEarly = false;
5847
- for (const candidate of available) {
5848
- if (selected.has(candidate.runId)) {
5849
- const current = selected.get(candidate.runId)!;
5850
- selected.set(candidate.runId, { ...current, cursor: candidate.cursor });
5851
- continue;
5852
- }
5853
- const stored = await this.authority.readStoredRun(candidate.runId);
5854
- if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
5855
- continue;
5856
- const projected = projectClientRunOrDegradedV1(stored);
5857
- const tentative = [
5858
- ...selected.values(),
5859
- { cursor: candidate.cursor, run: projected },
5860
- ];
5861
- const ordered = tentative
5862
- .map((entry) => entry.run)
5863
- .sort(
5864
- (left, right) =>
5865
- left.admittedAt.localeCompare(right.admittedAt) ||
5866
- left.runId.localeCompare(right.runId),
5858
+ let exhausted = false;
5859
+ let scanCursor: string | undefined;
5860
+ let scanned = 0;
5861
+ let batch = candidates;
5862
+ for (;;) {
5863
+ const available = batch.slice(0, CLIENT_RUN_PAGE_LIMIT);
5864
+ const hasMore = batch.length > CLIENT_RUN_PAGE_LIMIT;
5865
+ for (const candidate of available) {
5866
+ if (selected.has(candidate.runId)) {
5867
+ const current = selected.get(candidate.runId)!;
5868
+ selected.set(candidate.runId, {
5869
+ ...current,
5870
+ cursor: candidate.cursor,
5871
+ });
5872
+ scanCursor = candidate.cursor;
5873
+ continue;
5874
+ }
5875
+ const header = await this.authority.readRunHeader(candidate.runId);
5876
+ if (!header || !isVisibleRunV1(header) || !inConversation(header)) {
5877
+ scanCursor = candidate.cursor;
5878
+ continue;
5879
+ }
5880
+ const stored = await this.authority.readStoredRunForDisplay(
5881
+ candidate.runId,
5867
5882
  );
5868
- const tentativePage = createClientRunListV1(ordered, {
5869
- truncated: true,
5870
- nextCursor: candidate.cursor,
5883
+ if (!stored) {
5884
+ scanCursor = candidate.cursor;
5885
+ continue;
5886
+ }
5887
+ const projected = projectClientRunOrDegradedV1(stored);
5888
+ const tentative = [
5889
+ ...selected.values(),
5890
+ { cursor: candidate.cursor, run: projected },
5891
+ ];
5892
+ const ordered = tentative
5893
+ .map((entry) => entry.run)
5894
+ .sort(
5895
+ (left, right) =>
5896
+ left.admittedAt.localeCompare(right.admittedAt) ||
5897
+ left.runId.localeCompare(right.runId),
5898
+ );
5899
+ const tentativePage = createClientRunListV1(ordered, {
5900
+ truncated: true,
5901
+ nextCursor: candidate.cursor,
5902
+ });
5903
+ const isNewestTerminal =
5904
+ ![...selected.values()].some(
5905
+ (entry) =>
5906
+ entry.run.status === "completed" || entry.run.status === "failed",
5907
+ ) &&
5908
+ (projected.status === "completed" || projected.status === "failed");
5909
+ if (
5910
+ selected.size >= CLIENT_RUN_PAGE_LIMIT ||
5911
+ (!isNewestTerminal &&
5912
+ clientRunListWireBytes(tentativePage) > CLIENT_RUN_LIST_MAX_BYTES)
5913
+ ) {
5914
+ stoppedEarly = true;
5915
+ break;
5916
+ }
5917
+ selected.set(stored.runId, {
5918
+ cursor: candidate.cursor,
5919
+ run: projected,
5920
+ });
5921
+ scanCursor = candidate.cursor;
5922
+ }
5923
+ scanned += available.length;
5924
+ if (stoppedEarly) break;
5925
+ if (!hasMore) {
5926
+ exhausted = true;
5927
+ break;
5928
+ }
5929
+ if (selected.size >= CLIENT_RUN_PAGE_LIMIT) break;
5930
+ if (scanned >= CLIENT_RUN_SCAN_LIMIT || scanCursor === undefined) break;
5931
+ batch = await this.authority.listRunIndex({
5932
+ limit: CLIENT_RUN_PAGE_LIMIT + 1,
5933
+ before: scanCursor,
5871
5934
  });
5872
- const isNewestTerminal =
5873
- ![...selected.values()].some(
5874
- (entry) =>
5875
- entry.run.status === "completed" || entry.run.status === "failed",
5876
- ) &&
5877
- (projected.status === "completed" || projected.status === "failed");
5878
- if (
5879
- selected.size >= CLIENT_RUN_PAGE_LIMIT ||
5880
- (!isNewestTerminal &&
5881
- clientRunListWireBytes(tentativePage) > CLIENT_RUN_LIST_MAX_BYTES)
5882
- ) {
5883
- stoppedEarly = true;
5935
+ if (batch.length === 0) {
5936
+ exhausted = true;
5884
5937
  break;
5885
5938
  }
5886
- selected.set(stored.runId, { cursor: candidate.cursor, run: projected });
5887
5939
  }
5888
5940
  const orderedEntries = [...selected.values()].sort(
5889
5941
  (left, right) =>
5890
5942
  left.run.admittedAt.localeCompare(right.run.admittedAt) ||
5891
5943
  left.run.runId.localeCompare(right.run.runId),
5892
5944
  );
5893
- const oldestCursor = orderedEntries.find((entry) => entry.cursor)?.cursor;
5894
- const truncated = stoppedEarly || candidates.length > CLIENT_RUN_PAGE_LIMIT;
5945
+ const truncated = !exhausted;
5895
5946
  const page = createClientRunListV1(
5896
5947
  orderedEntries.map((entry) => entry.run),
5897
- truncated && oldestCursor
5898
- ? { truncated: true, nextCursor: oldestCursor }
5948
+ truncated && scanCursor
5949
+ ? { truncated: true, nextCursor: scanCursor }
5899
5950
  : { truncated: false },
5900
5951
  // Announcements belong to the Session, not to a page of Turns, so only
5901
5952
  // the newest page carries them.
@@ -22,6 +22,11 @@ import {
22
22
  appletSourceFingerprintV1,
23
23
  mostRecentlyChangedFileV1,
24
24
  } from "./applets-client.js";
25
+ import {
26
+ appletIsBeingBuiltV1,
27
+ appletProgressToolsV1,
28
+ appletProgressV1,
29
+ } from "./applet-progress.js";
25
30
  import { packageIframePagesForSlotV1 } from "./package-iframe-entries.js";
26
31
  import PackageIframeHost from "./PackageIframeHost.vue";
27
32
 
@@ -46,6 +51,26 @@ const isRunning = computed(() => Boolean(web.value.activeRunId));
46
51
  const canvasState = computed(() => web.value.appletCanvas);
47
52
  const failure = computed(() => web.value.appletCanvasError);
48
53
 
54
+ /*
55
+ * What the Bot is doing to this Applet, in one line.
56
+ *
57
+ * A publish is minutes away from a create, and for all of it there is nothing
58
+ * to run. The panel used to answer that with one fixed line about the Applet
59
+ * not being live and a file list, which never changed and never said whether
60
+ * anything was happening. This is the sentence in between, projected from what
61
+ * the thread already shows: no new read, no new durable state.
62
+ */
63
+ const progress = computed(() =>
64
+ appletProgressV1({
65
+ applet: applet.value ?? null,
66
+ source: source.value,
67
+ build: build.value,
68
+ tools: appletProgressToolsV1(web.value.messages),
69
+ running: isRunning.value,
70
+ }),
71
+ );
72
+ const beingBuilt = computed(() => appletIsBeingBuiltV1(progress.value));
73
+
49
74
  /** The Applets Package's right-panel page, when its Composition carries one. */
50
75
  const panelPage = computed(
51
76
  () => packageIframePagesForSlotV1(web.value.packageUi, RIGHT_PANEL_SLOT)[0],
@@ -212,8 +237,11 @@ async function clearFocus(): Promise<void> {
212
237
  /></span>
213
238
  <div class="applet-canvas-title">
214
239
  <strong>{{ applet?.displayName ?? "Applet" }}</strong>
240
+ <!--
241
+ While it is being built the line under the name is the pinned
242
+ building view below, not a copy of it here.
243
+ -->
215
244
  <small v-if="viewer">{{ generationLabel(viewer.generationId) }}</small>
216
- <small v-else>Not published yet</small>
217
245
  </div>
218
246
  <div
219
247
  v-if="canShowApp"
@@ -255,6 +283,39 @@ async function clearFocus(): Promise<void> {
255
283
  />
256
284
  </header>
257
285
 
286
+ <!--
287
+ Where the work has got to, pinned under the header while the Applet is
288
+ still being built: one line a person can read, the reason it stopped when
289
+ it stopped, and the tail of whatever the check or the build printed —
290
+ which is the only thing that helps when the Bot is going round in circles
291
+ on a type error. It sits outside the scrolling column deliberately: a
292
+ person reading the source still wants to know what is happening to it.
293
+ -->
294
+ <section
295
+ v-if="applet && !loading && progress && beingBuilt"
296
+ class="applet-canvas-progress"
297
+ :class="{ 'applet-canvas-progress-wrong': Boolean(progress.failure) }"
298
+ data-testid="applet-canvas-progress"
299
+ role="status"
300
+ >
301
+ <p class="applet-canvas-progress-line">
302
+ <span
303
+ v-if="progress.working"
304
+ class="applet-canvas-progress-dot"
305
+ aria-hidden="true"
306
+ />
307
+ <UiIcon v-else-if="progress.failure" name="close" size="sm" />
308
+ <span>{{ progress.label }}</span>
309
+ </p>
310
+ <p v-if="progress.failure" class="applet-canvas-progress-failure">
311
+ {{ progress.failure }}
312
+ </p>
313
+ <pre
314
+ v-if="progress.output"
315
+ class="applet-canvas-progress-output"
316
+ ><code>{{ progress.output.join("\n") }}</code></pre>
317
+ </section>
318
+
258
319
  <div class="applet-canvas-body">
259
320
  <!-- Loading: the shape of what is coming, with a caption that says so. -->
260
321
  <div v-if="loading" class="applet-canvas-loading">
@@ -297,21 +358,6 @@ async function clearFocus(): Promise<void> {
297
358
  <span>This is taking longer than it should.</span>
298
359
  <button type="button" @click="retry">Try again</button>
299
360
  </div>
300
- <p
301
- v-if="build && build.status !== 'unknown'"
302
- class="applet-canvas-build"
303
- :class="`applet-canvas-build-${build.status}`"
304
- >
305
- <UiIcon
306
- :name="build.status === 'passed' ? 'check' : 'close'"
307
- size="sm"
308
- />
309
- <span
310
- >{{ build.command === "build" ? "Build" : "Check" }}
311
- {{ build.status
312
- }}{{ build.summary ? `: ${build.summary}` : "" }}</span
313
- >
314
- </p>
315
361
  <div v-if="sortedFiles.length > 0" class="applet-canvas-files">
316
362
  <button
317
363
  v-for="file in sortedFiles"
@@ -573,28 +619,76 @@ async function clearFocus(): Promise<void> {
573
619
  white-space: pre;
574
620
  }
575
621
 
576
- .applet-canvas-build {
622
+ /* Where the work has got to: the panel's own header for the building state. */
623
+ .applet-canvas-progress {
624
+ display: flex;
625
+ flex: 0 0 auto;
626
+ flex-direction: column;
627
+ gap: 8px;
628
+ padding: 10px 12px;
629
+ border-bottom: 1px solid var(--frock-border);
630
+ background: var(--frock-surface-subtle);
631
+ }
632
+
633
+ .applet-canvas-progress-wrong {
634
+ border-bottom-color: var(--frock-danger-border);
635
+ background: var(--frock-danger-surface);
636
+ }
637
+
638
+ .applet-canvas-progress-line {
577
639
  display: flex;
578
640
  align-items: center;
579
- gap: 6px;
641
+ gap: 8px;
580
642
  margin: 0;
581
- padding: 6px 10px;
582
- border: 1px solid var(--frock-border);
583
- border-radius: var(--frock-radius-control);
584
- color: var(--frock-text-muted);
585
- font-size: var(--frock-text-xs);
643
+ color: var(--frock-text);
644
+ font-size: var(--frock-text-sm);
645
+ font-weight: 600;
646
+ }
647
+
648
+ .applet-canvas-progress-wrong .applet-canvas-progress-line {
649
+ color: var(--frock-danger-text);
650
+ }
651
+
652
+ /* The same "something is happening" the thread uses, at the size of a word. */
653
+ .applet-canvas-progress-dot {
654
+ width: 8px;
655
+ height: 8px;
656
+ flex: 0 0 auto;
657
+ border-radius: 999px;
658
+ background: var(--frock-action-primary);
659
+ animation: applet-progress-pulse 1.4s ease-in-out infinite;
586
660
  }
587
661
 
588
- .applet-canvas-build-passed {
589
- border-color: var(--frock-success-border);
590
- color: var(--frock-success);
591
- background: var(--frock-success-surface);
662
+ @keyframes applet-progress-pulse {
663
+ 0%,
664
+ 100% {
665
+ opacity: 0.35;
666
+ }
667
+
668
+ 50% {
669
+ opacity: 1;
670
+ }
592
671
  }
593
672
 
594
- .applet-canvas-build-failed {
595
- border-color: var(--frock-danger-border);
673
+ .applet-canvas-progress-failure {
674
+ margin: 0;
596
675
  color: var(--frock-danger-text);
597
- background: var(--frock-danger-surface);
676
+ font-size: var(--frock-text-sm);
677
+ }
678
+
679
+ .applet-canvas-progress-output {
680
+ max-height: 180px;
681
+ margin: 0;
682
+ overflow: auto;
683
+ padding: 8px 10px;
684
+ border: 1px solid var(--frock-border);
685
+ border-radius: var(--frock-radius-control);
686
+ color: var(--frock-text-muted);
687
+ background: var(--frock-surface);
688
+ font-family: var(--frock-font-mono);
689
+ font-size: var(--frock-text-xs);
690
+ line-height: var(--frock-leading-snug);
691
+ white-space: pre;
598
692
  }
599
693
 
600
694
  .applet-canvas-failure {
@@ -686,5 +780,9 @@ async function clearFocus(): Promise<void> {
686
780
  .applet-app-leave-active {
687
781
  transition: none;
688
782
  }
783
+
784
+ .applet-canvas-progress-dot {
785
+ animation: none;
786
+ }
689
787
  }
690
788
  </style>