@frockbot/plugin-shell 0.0.0 → 0.1.1
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 +68 -0
- package/package.json +87 -6
- package/src/agent.test.ts +372 -0
- package/src/agent.ts +335 -0
- package/src/approvals.test.ts +224 -0
- package/src/approvals.ts +530 -0
- package/src/backend-assignment.test.ts +161 -0
- package/src/backend-assignment.ts +274 -0
- package/src/backend-authoring.test.ts +518 -0
- package/src/backend-authoring.ts +531 -0
- package/src/backend-bot-identity.test.ts +215 -0
- package/src/backend-completion.test.ts +289 -0
- package/src/backend-completion.ts +95 -0
- package/src/backend-composition.ts +242 -0
- package/src/backend-computer.ts +76 -0
- package/src/backend-configuration.test.ts +1757 -0
- package/src/backend-contracts.test.ts +189 -0
- package/src/backend-contracts.ts +44 -0
- package/src/backend-debug.test.ts +202 -0
- package/src/backend-execution.ts +55 -0
- package/src/backend-flock.ts +96 -0
- package/src/backend-image.test.ts +115 -0
- package/src/backend-image.ts +180 -0
- package/src/backend-isolate.test.ts +238 -0
- package/src/backend-isolate.ts +409 -0
- package/src/backend-machine.ts +144 -0
- package/src/backend-memory.ts +89 -0
- package/src/backend-recovery-integration.test.ts +1575 -0
- package/src/backend-recovery.ts +106 -0
- package/src/backend-routines.ts +375 -0
- package/src/backend-runner.ts +251 -0
- package/src/backend-skills.test.ts +126 -0
- package/src/backend-skills.ts +198 -0
- package/src/backend-stop.test.ts +356 -0
- package/src/backend-subagents.ts +459 -0
- package/src/backend.ts +6035 -0
- package/src/client/FrockBotApp.vue +1026 -0
- package/src/client/SendPayloadView.vue +337 -0
- package/src/client/composer-draft.test.ts +31 -0
- package/src/client/composer-draft.ts +35 -0
- package/src/client/cordis-client-shim.d.ts +15 -0
- package/src/client/index.test.ts +2548 -0
- package/src/client/index.ts +2346 -0
- package/src/client/model-presentation.test.ts +35 -0
- package/src/client/model-presentation.ts +19 -0
- package/src/client/notify.test.ts +89 -0
- package/src/client/notify.ts +101 -0
- package/src/client/skill-invocation.test.ts +143 -0
- package/src/client/skill-invocation.ts +175 -0
- package/src/client/styles.css +1043 -0
- package/src/composition-views.ts +118 -0
- package/src/debug-protocol.test.ts +80 -0
- package/src/debug-protocol.ts +165 -0
- package/src/env.d.ts +10 -0
- package/src/history.test.ts +163 -0
- package/src/history.ts +108 -0
- package/src/host.ts +20 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/run-cursor.ts +28 -0
- package/src/run-protocol.test.ts +1281 -0
- package/src/run-protocol.ts +1417 -0
- package/src/settings-links.test.ts +106 -0
- package/src/settings-links.ts +289 -0
- package/src/shared.ts +338 -0
- package/src/skill-protocol.ts +117 -0
- package/src/terminal-records.test.ts +217 -0
- package/src/terminal-records.ts +150 -0
- package/src/unread.test.ts +362 -0
- package/src/unread.ts +675 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +32 -0
- 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>
|