@frockbot/plugin-shell 0.0.0 → 0.1.0

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.
Files changed (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,1026 @@
1
+ <script setup lang="ts">
2
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
+ import {
4
+ announceUiAnchor,
5
+ UiButton,
6
+ UiIcon,
7
+ UiIconButton,
8
+ UiMarkdown,
9
+ UiSidebarOverlay,
10
+ } from "@frockbot/client-ui";
11
+ import {
12
+ computed,
13
+ inject,
14
+ nextTick,
15
+ onBeforeUnmount,
16
+ onMounted,
17
+ ref,
18
+ watch,
19
+ } from "vue";
20
+ import { decodeSettingsLinkV1 } from "../settings-links.js";
21
+ import {
22
+ frockBotWebDataKey,
23
+ type FrockBotWebData,
24
+ type WebChatMessage,
25
+ type WebTaskChip,
26
+ type WebToolAttachment,
27
+ } from "../shared.js";
28
+ import { ComposerDraftStore } from "./composer-draft.js";
29
+ import SendPayloadView from "./SendPayloadView.vue";
30
+ import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
31
+ import {
32
+ nextSkillHighlightV1,
33
+ rankSkillCandidatesV1,
34
+ SkillAttachmentStore,
35
+ skillPopoverForV1,
36
+ textWithoutSkillTriggerV1,
37
+ type SkillPopoverStateV1,
38
+ } from "./skill-invocation.js";
39
+
40
+ const injectedWeb = inject(frockBotWebDataKey);
41
+ if (!injectedWeb) throw new Error("shell client data was not provided");
42
+ const web = injectedWeb;
43
+ const surfaces = inject(clientSurfaceRegistryKey);
44
+ if (!surfaces) throw new Error("client surface registry was not provided");
45
+ const activeSurface = surfaces.active;
46
+ /*
47
+ * A registered surface either floats over the workspace or takes the right
48
+ * panel's place. A panel-placed surface swaps the panel's content and forces
49
+ * the panel open; closing it hands the panel back to its plugins.
50
+ */
51
+ const panelSurface = computed(() =>
52
+ activeSurface.value?.placement === "panel" ? activeSurface.value : undefined,
53
+ );
54
+ const overlaySurface = computed(() =>
55
+ activeSurface.value?.placement === "panel" ? undefined : activeSurface.value,
56
+ );
57
+ const state = computed(() => web.value);
58
+ const composerContext = computed(
59
+ () =>
60
+ state.value.composerContext ?? state.value.botSettings?.botId ?? "default",
61
+ );
62
+ const draftStore = new ComposerDraftStore();
63
+ const draft = ref(draftStore.draftFor(composerContext.value));
64
+
65
+ /*
66
+ * The phone layout.
67
+ *
68
+ * The same bundle serves every platform, so a phone is this shell at a narrow
69
+ * viewport rather than a second client. Below the breakpoint there is room for
70
+ * one column: the Bot list and the right panel become drawers over the
71
+ * conversation, and only one of them is ever open.
72
+ *
73
+ * The width is matched here as well as in `styles.css` because the layout
74
+ * decides behaviour, not only appearance — the right panel is open by default
75
+ * on a desktop and must not be on a phone, where it would cover the
76
+ * conversation the User just opened. The query is the one in the stylesheet;
77
+ * the two are kept in step deliberately.
78
+ */
79
+ const PHONE_LAYOUT_QUERY = "(max-width: 640px)";
80
+ const phoneLayoutMedia =
81
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
82
+ ? window.matchMedia(PHONE_LAYOUT_QUERY)
83
+ : undefined;
84
+ const phoneLayout = ref(phoneLayoutMedia?.matches ?? false);
85
+ const navOpen = ref(false);
86
+ const rightPanelOpen = ref(!phoneLayout.value);
87
+
88
+ function onPhoneLayoutChange(event: MediaQueryListEvent): void {
89
+ phoneLayout.value = event.matches;
90
+ }
91
+
92
+ /*
93
+ * Crossing the breakpoint resets both drawers to what that layout means by
94
+ * open: a desktop shows the right panel beside the conversation, a phone shows
95
+ * neither over it.
96
+ */
97
+ watch(phoneLayout, (phone) => {
98
+ navOpen.value = false;
99
+ if (!panelSurface.value) rightPanelOpen.value = !phone;
100
+ });
101
+
102
+ function openNav(): void {
103
+ // One drawer at a time: two half-covered columns is the layout this replaced.
104
+ rightPanelOpen.value = false;
105
+ navOpen.value = true;
106
+ }
107
+
108
+ function closeNav(): void {
109
+ navOpen.value = false;
110
+ }
111
+
112
+ function toggleRightPanel(): void {
113
+ if (!rightPanelOpen.value) navOpen.value = false;
114
+ rightPanelOpen.value = !rightPanelOpen.value;
115
+ }
116
+
117
+ /**
118
+ * Give the conversation back.
119
+ *
120
+ * A hosted surface holds the panel's place and covers the whole window, so
121
+ * there is no scrim to tap while one is open; the panel is left alone in that
122
+ * case rather than closed out from under it.
123
+ */
124
+ function closeDrawers(): void {
125
+ navOpen.value = false;
126
+ if (!panelSurface.value) rightPanelOpen.value = false;
127
+ }
128
+
129
+ function onRootKeydown(event: KeyboardEvent): void {
130
+ if (event.key !== "Escape" || !navOpen.value) return;
131
+ event.preventDefault();
132
+ closeNav();
133
+ }
134
+ /*
135
+ * Skill invocation. `/` or `@` at a word boundary opens a popover over the
136
+ * Bot's catalog; choosing one attaches a ref chip and removes the trigger from
137
+ * the message. The Skill's text is never pasted: the backend resolves the ref
138
+ * against the generation the Turn loads, so what runs is what the instruction
139
+ * root holds, not what the composer once showed.
140
+ */
141
+ const composerInput = ref<HTMLTextAreaElement | undefined>(undefined);
142
+ const skillStore = new SkillAttachmentStore();
143
+ const attachedSkills = ref<readonly ClientSkillCatalogEntryV1[]>([]);
144
+ const skillPopover = ref<SkillPopoverStateV1 | undefined>(undefined);
145
+ const skillHighlight = ref(0);
146
+ const skillCandidates = computed(() =>
147
+ skillPopover.value
148
+ ? rankSkillCandidatesV1(
149
+ state.value.skillCatalog,
150
+ skillPopover.value.query,
151
+ {
152
+ exclude: skillStore.refs(),
153
+ },
154
+ )
155
+ : [],
156
+ );
157
+ const skillPopoverOpen = computed(
158
+ () => Boolean(skillPopover.value) && skillCandidates.value.length > 0,
159
+ );
160
+ // The macOS desktop shell hides its title bar, so the window's traffic lights
161
+ // sit inside the sidebar's top row and the wordmark has to clear them.
162
+ const macDesktop =
163
+ typeof navigator !== "undefined" &&
164
+ /Electron/u.test(navigator.userAgent) &&
165
+ /Mac/u.test(navigator.platform);
166
+ const botName = computed(
167
+ () => state.value.botSettings?.profile.name ?? "Barebones",
168
+ );
169
+ const isRunning = computed(() => Boolean(state.value.activeRunId));
170
+ const isConnecting = computed(() => state.value.connection !== "ready");
171
+ const needsModel = computed(() => state.value.modelSource === "none");
172
+ const hasConnectedModelProvider = computed(() => {
173
+ const user = state.value.userSettings;
174
+ if (!user) return false;
175
+ return user.connections.some((connection) => {
176
+ if (connection.state !== "ready") return false;
177
+ const installed = user.packages.some(
178
+ (pkg) =>
179
+ pkg.packageId === connection.packageId && pkg.state === "installed",
180
+ );
181
+ const pkg = state.value.pluginCatalog.find(
182
+ (candidate) => candidate.packageId === connection.packageId,
183
+ );
184
+ const connectionType = pkg?.connectionTypes.find(
185
+ (candidate) => candidate.id === connection.connectionTypeId,
186
+ );
187
+ return Boolean(
188
+ installed &&
189
+ pkg?.capabilities.some(
190
+ (capability) =>
191
+ capability.kind === "model" &&
192
+ connectionType?.capabilities.includes(capability.id) &&
193
+ capability.connectionTypes.includes(connectionType.id),
194
+ ),
195
+ );
196
+ });
197
+ });
198
+ const canSend = computed(
199
+ () =>
200
+ state.value.connection === "ready" &&
201
+ state.value.modelReady &&
202
+ Boolean(state.value.activeBotId) &&
203
+ !isRunning.value &&
204
+ draft.value.trim().length > 0,
205
+ );
206
+
207
+ /*
208
+ * Tool activity is internal to the Turn. A Turn that produced only tool calls
209
+ * shows the Bot avatar while it runs and nothing once it finishes with no
210
+ * text, so an empty bubble never appears in the thread.
211
+ */
212
+ /**
213
+ * The binaries this Turn's tools filed, in call order. Read off the tool
214
+ * activity rather than the text so a result that is JSON stays JSON.
215
+ */
216
+ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
217
+ return message.tools.flatMap((tool) => tool.attachments ?? []);
218
+ }
219
+
220
+ /** The Workspace read route for one encoded `WorkspacePathV1`. */
221
+ function workspaceFileUrl(path: string): string {
222
+ const botId = state.value.activeBotId ?? "";
223
+ return `/api/bots/${encodeURIComponent(botId)}/workspace/file?path=${encodeURIComponent(path)}`;
224
+ }
225
+
226
+ /**
227
+ * One dispatched subagent, merged with what it currently is.
228
+ *
229
+ * The chip's identity — type, description, model — is the durable dispatch on
230
+ * the run, which never changes. Its status and its summary come from the Bot's
231
+ * task list, because a background subagent settles long after the Turn that
232
+ * dispatched it is over. The backend is the authority for both halves; this
233
+ * only joins them.
234
+ */
235
+ function taskChipsOf(message: WebChatMessage): Array<{
236
+ chip: WebTaskChip;
237
+ status: string;
238
+ summary?: string;
239
+ }> {
240
+ return (message.tasks ?? []).map((chip) => {
241
+ const record = state.value.tasks.find(
242
+ (candidate) => candidate.taskId === chip.taskId,
243
+ );
244
+ return {
245
+ chip,
246
+ status: record?.status ?? "queued",
247
+ ...(record?.summary === undefined
248
+ ? record?.failure === undefined
249
+ ? {}
250
+ : { summary: record.failure }
251
+ : { summary: record.summary }),
252
+ };
253
+ });
254
+ }
255
+
256
+ /** Which chips the User has opened. Local, and per chip. */
257
+ const expandedTasks = ref(new Set<string>());
258
+
259
+ function toggleTask(taskId: string): void {
260
+ const next = new Set(expandedTasks.value);
261
+ if (!next.delete(taskId)) next.add(taskId);
262
+ expandedTasks.value = next;
263
+ }
264
+
265
+ /** A subagent still live is one the User may stop; a settled one is not. */
266
+ function isTaskLive(status: string): boolean {
267
+ return status === "queued" || status === "running";
268
+ }
269
+
270
+ function stopTask(taskId: string): void {
271
+ void web.value.stopTask(taskId);
272
+ }
273
+
274
+ function isVisible(message: WebChatMessage): boolean {
275
+ if (message.role === "user") return message.text.length > 0;
276
+ if (message.role === "system") return message.text.length > 0;
277
+ // A Turn the Bot ended with a widget writes no assistant text at all, so a
278
+ // send is on its own enough to draw the line — and so is a Turn whose only
279
+ // visible act was dispatching a subagent.
280
+ return (
281
+ message.text.length > 0 ||
282
+ message.sends.length > 0 ||
283
+ (message.tasks?.length ?? 0) > 0 ||
284
+ message.status === "streaming"
285
+ );
286
+ }
287
+ /*
288
+ * System lines happen between Turns, so the thread orders by when each line
289
+ * happened. A line with no timestamp is treated as arriving now, so an
290
+ * incomplete projection can never jump above the durable history.
291
+ */
292
+ const messages = computed(() => {
293
+ const missingAt = new Date().toISOString();
294
+ return state.value.messages
295
+ .filter(isVisible)
296
+ .map((message, index) => ({ message, index }))
297
+ .sort(
298
+ (left, right) =>
299
+ (left.message.at ?? missingAt).localeCompare(
300
+ right.message.at ?? missingAt,
301
+ ) || left.index - right.index,
302
+ )
303
+ .map((entry) => entry.message);
304
+ });
305
+
306
+ /*
307
+ * One anchor per Turn, on its first visible line, so a deep link resolves to
308
+ * exactly one element. A Turn shows as two lines — the prompt and the reply —
309
+ * and giving both the same id would put the same anchor in the document
310
+ * twice.
311
+ */
312
+ const turnAnchors = computed(() => {
313
+ const anchors = new Map<string, string>();
314
+ const seen = new Set<string>();
315
+ for (const message of messages.value) {
316
+ if (seen.has(message.runId)) continue;
317
+ seen.add(message.runId);
318
+ anchors.set(message.id, `turn-${message.runId}`);
319
+ }
320
+ return anchors;
321
+ });
322
+
323
+ /*
324
+ * The thread follows new content only while the reader is already at the
325
+ * bottom. Someone reading back through history is never yanked forward; the
326
+ * jump control tells them there is something newer.
327
+ */
328
+ const thread = ref<HTMLElement>();
329
+ const pinnedToLatest = ref(true);
330
+ const hasUnseenBelow = ref(false);
331
+ const nearBottomThreshold = 80;
332
+ const prefersReducedMotion =
333
+ typeof window !== "undefined" &&
334
+ typeof window.matchMedia === "function" &&
335
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
336
+
337
+ function onThreadScroll(): void {
338
+ const element = thread.value;
339
+ if (!element) return;
340
+ pinnedToLatest.value =
341
+ element.scrollHeight - element.scrollTop - element.clientHeight <=
342
+ nearBottomThreshold;
343
+ if (pinnedToLatest.value) hasUnseenBelow.value = false;
344
+ }
345
+
346
+ async function scrollToLatest(
347
+ behavior: ScrollBehavior = "smooth",
348
+ ): Promise<void> {
349
+ await nextTick();
350
+ const element = thread.value;
351
+ if (!element) return;
352
+ element.scrollTo({
353
+ top: element.scrollHeight,
354
+ behavior: prefersReducedMotion ? "auto" : behavior,
355
+ });
356
+ pinnedToLatest.value = true;
357
+ hasUnseenBelow.value = false;
358
+ }
359
+
360
+ /*
361
+ * Settings deep links. `?settings=<surface>#<anchor>` names a registered
362
+ * surface or the default Bot panel and one row inside it; the shell opens it and announces the
363
+ * anchor, and the anchored row highlights itself. The row is deliberately not
364
+ * hunted for here: a panel loads its state after it mounts, so `UiAnchor` also
365
+ * reads the fragment on its own mount and the two paths cover a link followed
366
+ * cold and a link followed while the panel is already open.
367
+ */
368
+ const applySettingsDeepLink = (): void => {
369
+ const target = decodeSettingsLinkV1(window.location.href);
370
+ if (!target) return;
371
+ if (target.surface === "bot-panel") {
372
+ surfaces.close();
373
+ rightPanelOpen.value = true;
374
+ } else {
375
+ if (!surfaces.has(target.surface)) return;
376
+ if (surfaces.activeId.value !== target.surface)
377
+ surfaces.open(target.surface);
378
+ }
379
+ const anchor = target.anchor;
380
+ if (anchor) void nextTick(() => announceUiAnchor(anchor));
381
+ };
382
+
383
+ function openModelSetup(): void {
384
+ const registry = surfaces;
385
+ if (!registry) return;
386
+ if (hasConnectedModelProvider.value && registry.has("user-settings")) {
387
+ registry.open("user-settings");
388
+ void nextTick(() => announceUiAnchor("user-default-model"));
389
+ return;
390
+ }
391
+ if (registry.has("plugins")) registry.open("plugins");
392
+ }
393
+
394
+ onMounted(() => {
395
+ void web.value.loadPluginCatalog();
396
+ void scrollToLatest("auto");
397
+ void nextTick(syncComposerHeight);
398
+ applySettingsDeepLink();
399
+ window.addEventListener("popstate", applySettingsDeepLink);
400
+ window.addEventListener("hashchange", applySettingsDeepLink);
401
+ phoneLayoutMedia?.addEventListener("change", onPhoneLayoutChange);
402
+ });
403
+
404
+ onBeforeUnmount(() => {
405
+ window.removeEventListener("popstate", applySettingsDeepLink);
406
+ window.removeEventListener("hashchange", applySettingsDeepLink);
407
+ phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
408
+ });
409
+
410
+ watch(
411
+ // Message count moves on a new Turn; the last message's length moves on
412
+ // every streamed delta.
413
+ () =>
414
+ [messages.value.length, messages.value.at(-1)?.text.length ?? 0] as const,
415
+ ([count], [previousCount]) => {
416
+ if (!pinnedToLatest.value) {
417
+ hasUnseenBelow.value = true;
418
+ return;
419
+ }
420
+ // Streamed deltas jump instantly so the smooth scroll never falls behind.
421
+ void scrollToLatest(count === previousCount ? "auto" : "smooth");
422
+ },
423
+ );
424
+ watch(
425
+ () => state.value.activeBotId,
426
+ () => {
427
+ // Choosing a Bot is what the drawer is for, so it closes behind the choice
428
+ // rather than covering the conversation it just opened.
429
+ closeNav();
430
+ pinnedToLatest.value = true;
431
+ void scrollToLatest("auto");
432
+ // A Skill belongs to one Bot's instruction root, so a switch drops both
433
+ // the attached refs and the catalog they came from.
434
+ skillStore.take();
435
+ syncAttachedSkills();
436
+ closeSkillPopover();
437
+ void web.value.loadSkillCatalog();
438
+ },
439
+ { immediate: true },
440
+ );
441
+
442
+ watch(
443
+ composerContext,
444
+ (current, previous) => {
445
+ draftStore.setDraft(previous, draft.value);
446
+ draft.value = draftStore.draftFor(current);
447
+ },
448
+ { flush: "sync" },
449
+ );
450
+ watch(draft, (value) => draftStore.setDraft(composerContext.value, value), {
451
+ flush: "sync",
452
+ });
453
+ watch(
454
+ draft,
455
+ () => {
456
+ void nextTick(syncComposerHeight);
457
+ },
458
+ { flush: "post" },
459
+ );
460
+ watch(panelSurface, (surface) => {
461
+ if (surface) rightPanelOpen.value = true;
462
+ });
463
+ /*
464
+ * A surface is the thing the User asked for, and on a phone it fills the
465
+ * window. The drawer that offered it has served its purpose either way.
466
+ */
467
+ watch(
468
+ () => surfaces.activeId.value,
469
+ (surface) => {
470
+ if (surface) closeNav();
471
+ },
472
+ );
473
+
474
+ /** Keep the textarea at its content height until its CSS maximum takes over. */
475
+ function syncComposerHeight(): void {
476
+ const input = composerInput.value;
477
+ if (!input) return;
478
+ input.style.height = "auto";
479
+ const maxHeight = Number.parseFloat(getComputedStyle(input).maxHeight);
480
+ const contentHeight = input.scrollHeight;
481
+ const height = Number.isFinite(maxHeight)
482
+ ? Math.min(contentHeight, maxHeight)
483
+ : contentHeight;
484
+ input.style.height = `${height}px`;
485
+ input.style.overflowY =
486
+ Number.isFinite(maxHeight) && contentHeight > maxHeight ? "auto" : "hidden";
487
+ }
488
+
489
+ function syncAttachedSkills(): void {
490
+ attachedSkills.value = [...skillStore.attached()];
491
+ }
492
+
493
+ function closeSkillPopover(): void {
494
+ skillPopover.value = undefined;
495
+ skillHighlight.value = 0;
496
+ }
497
+
498
+ function refreshSkillPopover(): void {
499
+ const element = composerInput.value;
500
+ if (!element) return closeSkillPopover();
501
+ const open = skillPopoverForV1(draft.value, element.selectionStart ?? 0);
502
+ skillPopover.value = open;
503
+ skillHighlight.value = 0;
504
+ }
505
+
506
+ function attachSkill(entry: ClientSkillCatalogEntryV1): void {
507
+ const open = skillPopover.value;
508
+ if (!open) return;
509
+ const element = composerInput.value;
510
+ const caret = element?.selectionStart ?? draft.value.length;
511
+ const trimmed = textWithoutSkillTriggerV1(draft.value, open, caret);
512
+ // Attaching a ref, not pasting a body: the message keeps only what the User
513
+ // typed, and the Skill travels beside it.
514
+ skillStore.attach(entry);
515
+ syncAttachedSkills();
516
+ draft.value = trimmed.text;
517
+ closeSkillPopover();
518
+ void nextTick(() => {
519
+ const input = composerInput.value;
520
+ if (!input) return;
521
+ input.focus();
522
+ input.setSelectionRange(trimmed.caret, trimmed.caret);
523
+ });
524
+ }
525
+
526
+ function detachSkill(ref: string): void {
527
+ skillStore.detach(ref);
528
+ syncAttachedSkills();
529
+ }
530
+
531
+ async function sendMessage(): Promise<void> {
532
+ const text = draft.value.trim();
533
+ if (!text || !canSend.value) return;
534
+ closeSkillPopover();
535
+ const attached = [...skillStore.attached()];
536
+ const skills = skillStore.take();
537
+ syncAttachedSkills();
538
+ const submission = draftStore.begin(composerContext.value, text);
539
+ draft.value = "";
540
+ void nextTick(syncComposerHeight);
541
+ // Sending is an explicit request to follow along again.
542
+ pinnedToLatest.value = true;
543
+ void scrollToLatest();
544
+ const result = await web.value.sendPrompt(
545
+ text,
546
+ skills.length > 0 ? skills : undefined,
547
+ );
548
+ // A Turn may have written a Skill, and an edit is visible on the next Turn,
549
+ // so the popover reads the catalog again rather than aging.
550
+ void web.value.loadSkillCatalog();
551
+ if (!result.accepted) {
552
+ // A refused submission gives the Skills back too: the User attached them
553
+ // deliberately and should not have to find them again.
554
+ skillStore.restore(attached);
555
+ syncAttachedSkills();
556
+ const restored = draftStore.reject(submission);
557
+ if (
558
+ restored !== undefined &&
559
+ composerContext.value === submission.context
560
+ ) {
561
+ draft.value = restored;
562
+ }
563
+ }
564
+ }
565
+
566
+ function handleComposerKeydown(event: KeyboardEvent): void {
567
+ if (skillPopoverOpen.value) {
568
+ const count = skillCandidates.value.length;
569
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
570
+ event.preventDefault();
571
+ skillHighlight.value = nextSkillHighlightV1(
572
+ skillHighlight.value,
573
+ count,
574
+ event.key === "ArrowDown" ? 1 : -1,
575
+ );
576
+ return;
577
+ }
578
+ if (event.key === "Escape") {
579
+ event.preventDefault();
580
+ closeSkillPopover();
581
+ return;
582
+ }
583
+ if (event.key === "Enter" || event.key === "Tab") {
584
+ const candidate = skillCandidates.value[skillHighlight.value];
585
+ if (candidate) {
586
+ event.preventDefault();
587
+ attachSkill(candidate.entry);
588
+ return;
589
+ }
590
+ }
591
+ }
592
+ if (event.key !== "Enter" || event.shiftKey) return;
593
+ event.preventDefault();
594
+ void sendMessage();
595
+ }
596
+ </script>
597
+
598
+ <template>
599
+ <div class="frockbot-root" @keydown="onRootKeydown">
600
+ <div
601
+ class="app-shell"
602
+ :class="{
603
+ 'panel-open': rightPanelOpen,
604
+ 'panel-surface': Boolean(panelSurface),
605
+ 'mac-desktop': macDesktop,
606
+ 'phone-layout': phoneLayout,
607
+ 'nav-open': navOpen,
608
+ }"
609
+ >
610
+ <aside
611
+ class="sidebar"
612
+ :aria-hidden="phoneLayout && !navOpen"
613
+ :inert="phoneLayout && !navOpen"
614
+ >
615
+ <div class="brand" aria-hidden="true">
616
+ <span class="brand-mark">FrockBot</span>
617
+ </div>
618
+ <div class="sidebar-top">
619
+ <k-slot name="frockbot.sidebar-top" />
620
+ </div>
621
+ <div class="bot-list">
622
+ <k-slot name="frockbot.sidebar-bots" />
623
+ </div>
624
+ <div class="sidebar-computer">
625
+ <k-slot name="frockbot.sidebar-computer" />
626
+ </div>
627
+
628
+ <div class="sidebar-bottom">
629
+ <k-slot name="frockbot.sidebar-actions" />
630
+ <k-slot name="frockbot.user-profile" />
631
+ </div>
632
+ </aside>
633
+
634
+ <!--
635
+ The dimmed conversation behind an open drawer, and the way back to it.
636
+ It sits under both drawers and over the workspace, so a tap anywhere on
637
+ what is still visible of the conversation closes what covers it.
638
+ -->
639
+ <Transition name="scrim">
640
+ <button
641
+ v-if="phoneLayout && (navOpen || rightPanelOpen)"
642
+ type="button"
643
+ class="nav-scrim"
644
+ aria-label="Close drawer"
645
+ @click="closeDrawers"
646
+ />
647
+ </Transition>
648
+
649
+ <main class="workspace">
650
+ <header class="topbar">
651
+ <!--
652
+ The way back to the Bot list on a phone, where the sidebar is a
653
+ drawer. At desktop widths the column is simply there and a control
654
+ that opens it would be a control that does nothing.
655
+ -->
656
+ <UiIconButton
657
+ v-if="phoneLayout"
658
+ class="nav-toggle"
659
+ icon="menu"
660
+ label="Show navigation"
661
+ @click="openNav"
662
+ />
663
+ <span class="bot-identity"
664
+ ><k-slot name="frockbot.bot-identity"
665
+ /></span>
666
+ <div class="workspace-title">
667
+ <strong>{{ botName }}</strong>
668
+ <small v-if="!needsModel">{{ state.modelLabel }}</small>
669
+ <button
670
+ v-else
671
+ type="button"
672
+ class="model-setup-link"
673
+ @click="openModelSetup"
674
+ >
675
+ Choose a model
676
+ </button>
677
+ </div>
678
+ <k-slot name="frockbot.header-actions" />
679
+ </header>
680
+
681
+ <section
682
+ ref="thread"
683
+ class="thread"
684
+ aria-live="polite"
685
+ @scroll.passive="onThreadScroll"
686
+ >
687
+ <div v-if="messages.length === 0" class="empty-thread">
688
+ <div class="empty-mark"><UiIcon name="sparkle" size="lg" /></div>
689
+ <h1>
690
+ {{
691
+ state.modelReady
692
+ ? `${botName} is ready.`
693
+ : needsModel
694
+ ? `${botName} needs a model.`
695
+ : `${botName} isn't ready.`
696
+ }}
697
+ </h1>
698
+ <p>
699
+ {{
700
+ state.modelReady
701
+ ? "Start with a conversation. Cordis plugins can add the rest."
702
+ : needsModel
703
+ ? "Pick a default to start chatting."
704
+ : "Check this Bot's model Connection."
705
+ }}
706
+ </p>
707
+ <UiButton
708
+ v-if="needsModel"
709
+ variant="primary"
710
+ @click="openModelSetup"
711
+ >
712
+ Choose a model
713
+ </UiButton>
714
+ </div>
715
+ <article
716
+ v-for="message in messages"
717
+ v-else
718
+ :id="turnAnchors.get(message.id)"
719
+ :key="message.id"
720
+ class="message"
721
+ :class="`message-${message.role}`"
722
+ >
723
+ <p v-if="message.role === 'system'" class="message-system-line">
724
+ {{ message.text }}
725
+ </p>
726
+ <template v-else-if="message.role === 'assistant'">
727
+ <!--
728
+ The Bot's own avatar comes from whichever Package owns Bot
729
+ identity. When no Package fills the slot the sparkle tile is
730
+ the only child and shows through.
731
+ -->
732
+ <div
733
+ class="bot-avatar"
734
+ :class="{
735
+ 'bot-avatar-live': message.status === 'streaming',
736
+ 'bot-avatar-waiting':
737
+ message.status === 'streaming' && !message.text,
738
+ }"
739
+ >
740
+ <span class="bot-avatar-fallback" aria-hidden="true"
741
+ ><UiIcon name="sparkle" size="sm"
742
+ /></span>
743
+ <k-slot name="frockbot.bot-avatar" />
744
+ </div>
745
+ <div v-if="message.text" class="message-bubble">
746
+ <UiMarkdown :text="message.text" />
747
+ </div>
748
+ <!--
749
+ A binary a tool filed in a durable root, drawn from the
750
+ Workspace read route. The thread carries the path, never the
751
+ bytes, so a long conversation costs paths and the image is
752
+ fetched only when it is on screen.
753
+ -->
754
+ <div
755
+ v-if="attachmentsOf(message).length > 0"
756
+ class="message-attachments"
757
+ >
758
+ <a
759
+ v-for="attachment in attachmentsOf(message)"
760
+ :key="attachment.contentHash"
761
+ :href="workspaceFileUrl(attachment.path)"
762
+ target="_blank"
763
+ rel="noreferrer"
764
+ >
765
+ <img
766
+ :src="workspaceFileUrl(attachment.path)"
767
+ :alt="`Attachment from ${message.runId}`"
768
+ loading="lazy"
769
+ />
770
+ </a>
771
+ </div>
772
+ <!--
773
+ Sends sit beside the derived text rather than inside it: each
774
+ payload is its own block, and a widget-ended Turn has no text
775
+ bubble at all.
776
+ -->
777
+ <div v-if="message.sends.length > 0" class="message-sends">
778
+ <SendPayloadView
779
+ v-for="(send, sendIndex) in message.sends"
780
+ :key="sendIndex"
781
+ :send="send"
782
+ />
783
+ </div>
784
+ <!--
785
+ The subagents this Turn dispatched. The child's own Session is
786
+ never in this transcript, so the chip is the whole of what the
787
+ conversation says about it; opening one shows the summary the
788
+ child handed back and nothing else.
789
+ -->
790
+ <div v-if="taskChipsOf(message).length > 0" class="message-tasks">
791
+ <button
792
+ v-for="entry in taskChipsOf(message)"
793
+ :key="entry.chip.taskId"
794
+ type="button"
795
+ class="task-chip"
796
+ :class="`task-chip-${entry.status}`"
797
+ :aria-expanded="expandedTasks.has(entry.chip.taskId)"
798
+ @click="toggleTask(entry.chip.taskId)"
799
+ >
800
+ <span class="task-chip-type">{{ entry.chip.taskType }}</span>
801
+ <span class="task-chip-description">{{
802
+ entry.chip.description
803
+ }}</span>
804
+ <span class="task-chip-status">{{ entry.status }}</span>
805
+ <span class="task-chip-model">{{ entry.chip.model }}</span>
806
+ <span
807
+ v-if="expandedTasks.has(entry.chip.taskId)"
808
+ class="task-chip-summary"
809
+ >{{
810
+ entry.summary ??
811
+ "This subagent has not reported a summary yet."
812
+ }}</span
813
+ >
814
+ <!--
815
+ Cancellation is the User's, explicit and authenticated. It
816
+ is offered only while the subagent is live: a settled one
817
+ has an outcome, and stopping it would rewrite it.
818
+ -->
819
+ <span
820
+ v-if="
821
+ expandedTasks.has(entry.chip.taskId) &&
822
+ isTaskLive(entry.status)
823
+ "
824
+ class="task-chip-stop"
825
+ role="button"
826
+ tabindex="0"
827
+ @click.stop="stopTask(entry.chip.taskId)"
828
+ @keydown.enter.stop.prevent="stopTask(entry.chip.taskId)"
829
+ @keydown.space.stop.prevent="stopTask(entry.chip.taskId)"
830
+ >Stop this subagent</span
831
+ >
832
+ </button>
833
+ </div>
834
+ </template>
835
+ <div v-else class="message-bubble">{{ message.text }}</div>
836
+ </article>
837
+ </section>
838
+
839
+ <Transition name="banner">
840
+ <UiIconButton
841
+ v-if="hasUnseenBelow && !state.error && !state.activeRun"
842
+ class="jump-latest"
843
+ icon="arrow-down"
844
+ label="Jump to latest"
845
+ variant="outlined"
846
+ size="sm"
847
+ @click="scrollToLatest()"
848
+ />
849
+ </Transition>
850
+
851
+ <Transition name="banner">
852
+ <div
853
+ v-if="state.error || state.activeRun"
854
+ class="error-banner"
855
+ :role="state.error && !state.activeRun ? 'alert' : 'status'"
856
+ >
857
+ <span>{{ state.activeRun?.message ?? state.error }}</span>
858
+ <button
859
+ v-if="state.activeRun?.canResume"
860
+ type="button"
861
+ @click="web.resumeRun(state.activeRun.runId)"
862
+ >
863
+ Resolve Turn
864
+ </button>
865
+ </div>
866
+ </Transition>
867
+
868
+ <form
869
+ class="composer"
870
+ :class="{ 'composer-busy': isRunning }"
871
+ @submit.prevent="sendMessage"
872
+ >
873
+ <ul
874
+ v-if="skillPopoverOpen"
875
+ id="skill-popover"
876
+ class="skill-popover"
877
+ role="listbox"
878
+ aria-label="Skills"
879
+ >
880
+ <li
881
+ v-for="(candidate, index) in skillCandidates"
882
+ :key="candidate.entry.ref"
883
+ class="skill-option"
884
+ :class="{ 'skill-option-active': index === skillHighlight }"
885
+ role="option"
886
+ :aria-selected="index === skillHighlight"
887
+ @mousedown.prevent="attachSkill(candidate.entry)"
888
+ @mousemove="skillHighlight = index"
889
+ >
890
+ <span class="skill-option-name">{{ candidate.entry.name }}</span>
891
+ <span class="skill-option-ref">{{ candidate.entry.ref }}</span>
892
+ <span class="skill-option-description">
893
+ {{ candidate.entry.description }}
894
+ </span>
895
+ </li>
896
+ </ul>
897
+ <UiButton
898
+ v-if="!isRunning && !isConnecting && needsModel"
899
+ type="button"
900
+ class="composer-model-setup"
901
+ variant="primary"
902
+ @click="openModelSetup"
903
+ >
904
+ Choose a model
905
+ </UiButton>
906
+ <div v-else class="composer-body">
907
+ <ul v-if="attachedSkills.length > 0" class="skill-chips">
908
+ <li v-for="entry in attachedSkills" :key="entry.ref">
909
+ <button
910
+ type="button"
911
+ class="skill-chip"
912
+ :title="entry.description"
913
+ :aria-label="`Remove Skill ${entry.name}`"
914
+ @click="detachSkill(entry.ref)"
915
+ >
916
+ <span class="skill-chip-name">{{ entry.name }}</span>
917
+ <UiIcon name="close" size="sm" />
918
+ </button>
919
+ </li>
920
+ </ul>
921
+ <textarea
922
+ ref="composerInput"
923
+ v-model="draft"
924
+ aria-label="Message"
925
+ :placeholder="
926
+ isConnecting
927
+ ? 'Connecting…'
928
+ : !state.modelReady
929
+ ? 'Model unavailable'
930
+ : `Message ${botName}`
931
+ "
932
+ :disabled="isConnecting || !state.modelReady || isRunning"
933
+ rows="1"
934
+ role="combobox"
935
+ :aria-expanded="skillPopoverOpen"
936
+ aria-controls="skill-popover"
937
+ @input="syncComposerHeight"
938
+ @keydown="handleComposerKeydown"
939
+ @keyup="refreshSkillPopover"
940
+ @click="refreshSkillPopover"
941
+ @blur="closeSkillPopover"
942
+ />
943
+ </div>
944
+ <UiIconButton
945
+ v-if="isRunning"
946
+ class="stop-button"
947
+ icon="stop"
948
+ label="Stop generating"
949
+ variant="primary"
950
+ @click="web.stopRun()"
951
+ />
952
+ <UiIconButton
953
+ v-else-if="!needsModel || isConnecting"
954
+ type="submit"
955
+ icon="arrow-up"
956
+ label="Send message"
957
+ variant="primary"
958
+ :disabled="!canSend"
959
+ />
960
+ </form>
961
+ </main>
962
+
963
+ <aside
964
+ class="right-panel"
965
+ :aria-hidden="!rightPanelOpen"
966
+ :inert="!rightPanelOpen"
967
+ >
968
+ <!--
969
+ Both layers live in one stack so panel plugins keep their state
970
+ while a surface holds their place.
971
+ -->
972
+ <div class="right-panel-stack">
973
+ <Transition name="panel-swap">
974
+ <div v-show="!panelSurface" class="right-panel-content">
975
+ <header class="right-panel-header">
976
+ <k-slot name="frockbot.bot-actions" />
977
+ </header>
978
+ <div class="right-panel-body">
979
+ <k-slot name="frockbot.right-panel" />
980
+ </div>
981
+ </div>
982
+ </Transition>
983
+ <Transition name="panel-swap">
984
+ <section
985
+ v-if="panelSurface"
986
+ class="panel-surface-view"
987
+ :aria-label="panelSurface.title"
988
+ >
989
+ <header class="panel-surface-header">
990
+ <UiIconButton
991
+ icon="chevron-left"
992
+ label="Back to Bot panel"
993
+ size="sm"
994
+ @click="surfaces.close()"
995
+ />
996
+ <h2>{{ panelSurface.title }}</h2>
997
+ </header>
998
+ <div class="panel-surface-content">
999
+ <component :is="panelSurface.component" />
1000
+ </div>
1001
+ </section>
1002
+ </Transition>
1003
+ </div>
1004
+ </aside>
1005
+
1006
+ <div class="window-actions">
1007
+ <UiIconButton
1008
+ class="panel-toggle"
1009
+ :icon="rightPanelOpen ? 'chevrons-right' : 'chevrons-left'"
1010
+ :label="rightPanelOpen ? 'Hide side panel' : 'Show side panel'"
1011
+ @click="toggleRightPanel"
1012
+ />
1013
+ </div>
1014
+ </div>
1015
+
1016
+ <k-slot name="frockbot.overlays" />
1017
+
1018
+ <UiSidebarOverlay
1019
+ :open="Boolean(overlaySurface)"
1020
+ :title="overlaySurface?.title ?? ''"
1021
+ @close="surfaces.close()"
1022
+ >
1023
+ <component :is="overlaySurface.component" v-if="overlaySurface" />
1024
+ </UiSidebarOverlay>
1025
+ </div>
1026
+ </template>