@frockbot/plugin-shell 0.3.2 → 0.3.4

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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * What a superseded Turn does to the durable state the Shell owns: how its
3
+ * unsettled effects are classified, what it leaves for the Turn that replaced
4
+ * it, and what a Stop arriving afterwards is allowed to touch.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { SessionEvent } from "@frockbot/kernel-contracts";
8
+ import {
9
+ initializeBotSettingsV1,
10
+ type UserSettingsViewV1,
11
+ } from "@frockbot/configuration-core";
12
+ import {
13
+ createShellBotBackendContribution,
14
+ type ShellBotBackendHost,
15
+ } from "./backend.js";
16
+ import {
17
+ botTurnCommandFingerprintV1,
18
+ type StoredRun,
19
+ } from "./backend-contracts.js";
20
+ import { planInterruptedRunRecoveryV1 } from "./backend-recovery.js";
21
+ import { projectClientRunV1 } from "./run-protocol.js";
22
+ import { TaskStore } from "@frockbot/plugin-subagents/store";
23
+
24
+ class MemoryStorage {
25
+ readonly values = new Map<string, unknown>();
26
+ alarmAt: number | undefined;
27
+
28
+ get<T>(key: string): Promise<T | undefined> {
29
+ return Promise.resolve(
30
+ structuredClone(this.values.get(key)) as T | undefined,
31
+ );
32
+ }
33
+
34
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
35
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
36
+ else {
37
+ for (const [entry, item] of Object.entries(key)) {
38
+ this.values.set(entry, structuredClone(item));
39
+ }
40
+ }
41
+ return Promise.resolve();
42
+ }
43
+
44
+ delete(key: string): Promise<boolean> {
45
+ return Promise.resolve(this.values.delete(key));
46
+ }
47
+
48
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
49
+ return Promise.resolve(
50
+ new Map(
51
+ [...this.values.entries()].filter(([key]) =>
52
+ key.startsWith(options.prefix ?? ""),
53
+ ) as Array<[string, T]>,
54
+ ),
55
+ );
56
+ }
57
+
58
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
59
+ return callback(this);
60
+ }
61
+
62
+ setAlarm(timestamp: number): Promise<void> {
63
+ this.alarmAt = timestamp;
64
+ return Promise.resolve();
65
+ }
66
+
67
+ deleteAlarm(): Promise<void> {
68
+ this.alarmAt = undefined;
69
+ return Promise.resolve();
70
+ }
71
+ }
72
+
73
+ const user: UserSettingsViewV1 = {
74
+ schemaVersion: 1,
75
+ revision: 0,
76
+ profile: { name: "User" },
77
+ packages: [],
78
+ connections: [],
79
+ };
80
+
81
+ function host(storage: MemoryStorage): ShellBotBackendHost {
82
+ return {
83
+ state: { storage } as unknown as DurableObjectState,
84
+ env: {
85
+ USER_CONFIGURATIONS: {
86
+ idFromName: () => "user-id",
87
+ get: () => ({ readConfiguration: () => Promise.resolve(user) }),
88
+ },
89
+ } as unknown as ShellBotBackendHost["env"],
90
+ };
91
+ }
92
+
93
+ const identity = { userId: "user-1", botId: "primary" };
94
+ const turn = {
95
+ ...identity,
96
+ runId: "run-1",
97
+ sessionId: "user-1:primary",
98
+ acceptedAt: "2026-09-03T00:00:00.000Z",
99
+ text: "hello",
100
+ };
101
+ const timestamp = "2026-09-03T00:00:01.000Z";
102
+
103
+ function events(...inputs: Record<string, unknown>[]): SessionEvent[] {
104
+ return inputs.map(
105
+ (event, seq) => ({ ...event, seq, timestamp }) as SessionEvent,
106
+ );
107
+ }
108
+
109
+ /** A Turn that had dispatched a model request and asked a tool to run. */
110
+ function toolIntentEvents(): SessionEvent[] {
111
+ return events(
112
+ { type: "session/created", createdAt: timestamp },
113
+ { type: "turn/start", turn: 1 },
114
+ { type: "step/start", turn: 1, step: 1 },
115
+ {
116
+ type: "model/request",
117
+ turn: 1,
118
+ step: 1,
119
+ request: {
120
+ requestId: "request-1",
121
+ provider: "foundation",
122
+ model: "foundation-model",
123
+ system: "system",
124
+ messages: [{ role: "user", content: "hello" }],
125
+ tools: [],
126
+ },
127
+ },
128
+ {
129
+ type: "assistant/message",
130
+ turn: 1,
131
+ step: 1,
132
+ requestId: "request-1",
133
+ text: "on it",
134
+ toolCalls: [{ id: "provider-call", name: "effect", input: {} }],
135
+ },
136
+ {
137
+ type: "tool/call",
138
+ turn: 1,
139
+ step: 1,
140
+ occurrenceId: "tool:1:1:0",
141
+ name: "effect",
142
+ input: {},
143
+ },
144
+ );
145
+ }
146
+
147
+ function storedRun(overrides: Partial<StoredRun> = {}): StoredRun {
148
+ return {
149
+ runId: turn.runId,
150
+ commandFingerprint: botTurnCommandFingerprintV1(turn),
151
+ sessionId: turn.sessionId,
152
+ acceptedAt: turn.acceptedAt,
153
+ input: turn.text,
154
+ events: [],
155
+ effectAdmissions: [],
156
+ status: "running",
157
+ phase: "executing",
158
+ compositionGenerationId: "generation-1",
159
+ configurationSnapshot: initializeBotSettingsV1(identity.botId),
160
+ previousEventCount: 0,
161
+ ...overrides,
162
+ } as StoredRun;
163
+ }
164
+
165
+ async function fixture(run: StoredRun = storedRun()): Promise<{
166
+ storage: MemoryStorage;
167
+ contribution: ReturnType<typeof createShellBotBackendContribution>;
168
+ }> {
169
+ const storage = new MemoryStorage();
170
+ const contribution = createShellBotBackendContribution(host(storage));
171
+ await contribution.materializeSettings(identity, { name: "Primary" });
172
+ storage.values.set(`run:${run.runId}`, structuredClone(run));
173
+ storage.values.set("active-run", run.runId);
174
+ return { storage, contribution };
175
+ }
176
+
177
+ describe("a superseded run settles its effects exactly as a stopped one does", () => {
178
+ test("a tool effect that was never admitted is interrupted, never re-run", () => {
179
+ const run = storedRun({
180
+ events: toolIntentEvents(),
181
+ supersededAt: timestamp,
182
+ supersededBy: "run-2",
183
+ effectAdmissions: [
184
+ { kind: "model", effectId: "request-1", outcome: "admitted" },
185
+ { kind: "tool", effectId: "tool:1:1:0", outcome: "fenced" },
186
+ ],
187
+ });
188
+
189
+ const plan = planInterruptedRunRecoveryV1(run, run.events);
190
+
191
+ expect(plan.kind).toBe("cancel");
192
+ if (plan.kind !== "cancel") throw new Error("expected cancellation");
193
+ expect(
194
+ plan.events.find((event) => event.type === "tool/result"),
195
+ ).toMatchObject({ status: "interrupted", isError: true });
196
+ expect(plan.events.at(-1)).toMatchObject({
197
+ type: "turn/end",
198
+ outcome: "interrupted",
199
+ });
200
+ });
201
+
202
+ test("a tool effect that was admitted reconciles rather than settling", () => {
203
+ const run = storedRun({
204
+ events: toolIntentEvents(),
205
+ supersededAt: timestamp,
206
+ supersededBy: "run-2",
207
+ effectAdmissions: [
208
+ { kind: "model", effectId: "request-1", outcome: "admitted" },
209
+ { kind: "tool", effectId: "tool:1:1:0", outcome: "admitted" },
210
+ ],
211
+ });
212
+
213
+ // Identical to Stop: an effect that may already have run is retrieved, not
214
+ // assumed away, and the Turn that replaced it waits for the answer.
215
+ expect(planInterruptedRunRecoveryV1(run, run.events)).toEqual({
216
+ kind: "reconcile",
217
+ });
218
+ });
219
+
220
+ test("a run carrying neither intent is refused a plan", () => {
221
+ expect(() =>
222
+ planInterruptedRunRecoveryV1(
223
+ storedRun({ events: toolIntentEvents() }),
224
+ toolIntentEvents(),
225
+ ),
226
+ ).toThrow(`run "${turn.runId}" has no durable stop or supersede intent`);
227
+ });
228
+ });
229
+
230
+ describe("Stop and supersede on the same Turn", () => {
231
+ test("Stop still records its intent on a superseded Turn", async () => {
232
+ const { storage, contribution } = await fixture(
233
+ storedRun({
234
+ events: toolIntentEvents(),
235
+ supersededAt: timestamp,
236
+ supersededBy: "run-2",
237
+ }),
238
+ );
239
+
240
+ const receipt = await contribution.stopRun(identity, {
241
+ schemaVersion: 1,
242
+ action: "stop",
243
+ commandId: "stop-1",
244
+ runId: turn.runId,
245
+ });
246
+
247
+ expect(receipt.run.stopRequestedAt).toBeString();
248
+ const stored = storage.values.get(`run:${turn.runId}`) as StoredRun;
249
+ expect(stored.stopRequestedAt).toBeString();
250
+ // Both intents stand. Stop is the outcome the settlement writes, because
251
+ // the User asked for this Turn to stop and a later message does not turn
252
+ // their cancellation into something else.
253
+ expect(stored.supersededAt).toBe(timestamp);
254
+ });
255
+
256
+ test("Stop leaves a Turn already admitted as the next one alone", async () => {
257
+ const { storage, contribution } = await fixture(
258
+ storedRun({ events: toolIntentEvents(), supersededAt: timestamp }),
259
+ );
260
+ const queued = storedRun({
261
+ runId: "run-2",
262
+ acceptedAt: "2026-09-03T00:00:02.000Z",
263
+ input: "second",
264
+ phase: "queued",
265
+ previousEventCount: 0,
266
+ });
267
+ storage.values.set("run:run-2", structuredClone(queued));
268
+ storage.values.set("pending-run", "run-2");
269
+
270
+ await contribution.stopRun(identity, {
271
+ schemaVersion: 1,
272
+ action: "stop",
273
+ commandId: "stop-1",
274
+ runId: turn.runId,
275
+ });
276
+
277
+ expect(storage.values.get("pending-run")).toBe("run-2");
278
+ expect((storage.values.get("run:run-2") as StoredRun).status).toBe(
279
+ "running",
280
+ );
281
+ });
282
+ });
283
+
284
+ describe("background work outlives the Turn that dispatched it", () => {
285
+ test("a subagent of a superseded Turn still settles, and is recorded", async () => {
286
+ const { storage, contribution } = await fixture(
287
+ storedRun({
288
+ events: [
289
+ ...toolIntentEvents(),
290
+ ...events({
291
+ type: "task/dispatched",
292
+ turn: 1,
293
+ step: 1,
294
+ occurrenceId: "tool:1:1:0",
295
+ taskId: "tk-1",
296
+ taskType: "executor",
297
+ description: "Read the release notes",
298
+ model: "foundation/foundation-model",
299
+ background: true,
300
+ }),
301
+ ],
302
+ supersededAt: timestamp,
303
+ supersededBy: "run-2",
304
+ }),
305
+ );
306
+ const tasks = new TaskStore(
307
+ storage as unknown as ConstructorParameters<typeof TaskStore>[0],
308
+ );
309
+ const admitted = await tasks.admit({
310
+ taskId: "tk-1",
311
+ type: "executor",
312
+ description: "Read the release notes",
313
+ promptDigest: "a".repeat(64),
314
+ model: {
315
+ binding: {
316
+ packageId: "provider-foundation",
317
+ capabilityId: "foundation",
318
+ connectionId: "cn-1",
319
+ provider: "foundation",
320
+ providerModelId: "foundation-model",
321
+ },
322
+ slug: "provider-foundation/foundation-model",
323
+ },
324
+ compositionGenerationId: "generation-1",
325
+ background: true,
326
+ attachments: [],
327
+ dispatch: {
328
+ runId: turn.runId,
329
+ turnId: turn.runId,
330
+ sessionId: turn.sessionId,
331
+ },
332
+ now: new Date(timestamp),
333
+ });
334
+ expect(admitted.status).toBe("admitted");
335
+
336
+ // The parent Turn was superseded; nothing asked the child to stop, and its
337
+ // settlement is written exactly as it would have been.
338
+ const settled = await contribution.settleTask(identity, "tk-1", {
339
+ status: "completed",
340
+ settledAt: "2026-09-03T00:00:09.000Z",
341
+ summary: "The notes mention two breaking changes.",
342
+ });
343
+
344
+ expect(settled.status).toBe("settled");
345
+ expect(await tasks.read("tk-1")).toMatchObject({
346
+ taskId: "tk-1",
347
+ status: "completed",
348
+ });
349
+ });
350
+ });
351
+
352
+ describe("the projection tells the three states apart", () => {
353
+ test("queued, running, and superseded each project distinctly", () => {
354
+ const queued = projectClientRunV1(
355
+ storedRun({ runId: "run-2", phase: "queued", input: "second" }),
356
+ );
357
+ expect(queued).toMatchObject({ status: "running", queued: true });
358
+
359
+ const running = projectClientRunV1(storedRun({ phase: "executing" }));
360
+ expect(running.status).toBe("running");
361
+ expect(running.queued).toBeUndefined();
362
+
363
+ const superseded = projectClientRunV1(
364
+ storedRun({
365
+ status: "superseded",
366
+ supersededAt: timestamp,
367
+ supersededBy: "run-2",
368
+ events: [],
369
+ }),
370
+ );
371
+ expect(superseded).toMatchObject({
372
+ status: "superseded",
373
+ outcome: { type: "superseded" },
374
+ });
375
+ expect(superseded.queued).toBeUndefined();
376
+ });
377
+ });
package/src/backend.ts CHANGED
@@ -119,7 +119,7 @@ import {
119
119
  eventsForFailedRun,
120
120
  latestModelRequestJournalState,
121
121
  planBotRunRecovery,
122
- planStoppedRunRecovery,
122
+ planInterruptedRunRecoveryV1,
123
123
  } from "./backend-recovery.js";
124
124
  import {
125
125
  bootstrapCompositionGeneration,
@@ -379,7 +379,10 @@ import {
379
379
  projectPackageIframeCompositionV1,
380
380
  } from "./composition-views.js";
381
381
  import { executeBotTurn, executeDirectToolTurn } from "./backend-runner.js";
382
- import { shellTerminalRecordsV1 } from "./terminal-records.js";
382
+ import {
383
+ shellTerminalRecordsV1,
384
+ supersededTurnRecordsV1,
385
+ } from "./terminal-records.js";
383
386
  import {
384
387
  CLIENT_RUN_LIST_MAX_BYTES,
385
388
  CLIENT_RUN_PAGE_LIMIT,
@@ -472,7 +475,10 @@ interface StoredStopReceipt {
472
475
 
473
476
  function isTerminalStoredRunStatus(status: StoredRunStatus): boolean {
474
477
  return (
475
- status === "completed" || status === "failed" || status === "cancelled"
478
+ status === "completed" ||
479
+ status === "failed" ||
480
+ status === "cancelled" ||
481
+ status === "superseded"
476
482
  );
477
483
  }
478
484
 
@@ -678,7 +684,8 @@ export class ShellBotBackendContribution {
678
684
  subagentRole?: string;
679
685
  mounted: ShellMountedComposition;
680
686
  signal: AbortSignal;
681
- cancel(): void;
687
+ /** `detail` is recorded on the Turn's `turn/end`, never interpreted. */
688
+ cancel(detail?: string): void;
682
689
  }
683
690
  | undefined;
684
691
  /**
@@ -762,6 +769,9 @@ export class ShellBotBackendContribution {
762
769
  notification: (snapshot, result) =>
763
770
  this.createNotification(snapshot, result),
764
771
  terminalRecords: (input) => this.terminalPackageRecords(input),
772
+ supersededRecords: (input) => this.supersededPackageRecords(input),
773
+ interruptTurn: (runId, reason) =>
774
+ this.interruptActiveTurn(runId, reason),
765
775
  scheduledDeadlines: (transaction) =>
766
776
  this.scheduledDeadlines(transaction),
767
777
  scheduledWorkInFlight: () =>
@@ -1639,9 +1649,9 @@ export class ShellBotBackendContribution {
1639
1649
  : {}),
1640
1650
  mounted: activation.mounted,
1641
1651
  signal: controller.signal,
1642
- cancel: () => {
1652
+ cancel: (detail?: string) => {
1643
1653
  controller.abort("user");
1644
- activation.mounted.runtime.agent.agent.cancel("user");
1654
+ activation.mounted.runtime.agent.agent.cancel("user", detail);
1645
1655
  },
1646
1656
  };
1647
1657
  this.activeTurn = active;
@@ -1750,6 +1760,7 @@ export class ShellBotBackendContribution {
1750
1760
  private cancelActiveTurn(cancellation: {
1751
1761
  sessionId: string;
1752
1762
  runId: string;
1763
+ detail?: string;
1753
1764
  }): boolean {
1754
1765
  const active = this.activeTurn;
1755
1766
  if (
@@ -1759,10 +1770,24 @@ export class ShellBotBackendContribution {
1759
1770
  ) {
1760
1771
  return false;
1761
1772
  }
1762
- active.cancel();
1773
+ active.cancel(cancellation.detail);
1763
1774
  return true;
1764
1775
  }
1765
1776
 
1777
+ /**
1778
+ * The kernel's advisory interrupt, bound to this object's resident Agent.
1779
+ *
1780
+ * It runs only after the durable intent that justifies it is written, and it
1781
+ * changes nothing durable itself: a Turn whose Agent is no longer resident
1782
+ * is stopped by the effect fence on its next external effect instead, which
1783
+ * is the same outcome by a slower road.
1784
+ */
1785
+ private interruptActiveTurn(runId: string, reason: string): void {
1786
+ const active = this.activeTurn;
1787
+ if (!active || active.runId !== runId) return;
1788
+ active.cancel(reason);
1789
+ }
1790
+
1766
1791
  /** The visible half of failing closed, through the Bot's notifications. */
1767
1792
  private async recordCompositionFailureNotification(
1768
1793
  settings: BotSettingsViewV1,
@@ -5296,6 +5321,27 @@ export class ShellBotBackendContribution {
5296
5321
  });
5297
5322
  }
5298
5323
 
5324
+ /**
5325
+ * What a superseded Turn leaves for the Turn that replaced it.
5326
+ *
5327
+ * One durable input, drained once by the next conversational Turn. The
5328
+ * session log already carries what the Turn sent and what its tools
5329
+ * returned; this is the part that is not in the log — that it was cut off,
5330
+ * that nothing in flight completed, and that a subagent it dispatched is
5331
+ * still working. "A firing's outcome is delivered to the Bot's next
5332
+ * conversational Turn as durable input" and a superseded Turn's is too.
5333
+ */
5334
+ private supersededPackageRecords(input: {
5335
+ run: StoredRun;
5336
+ read<T>(key: string): Promise<T | undefined>;
5337
+ }): Promise<Record<string, unknown>> {
5338
+ return supersededTurnRecordsV1({
5339
+ run: input.run,
5340
+ now: new Date().toISOString(),
5341
+ read: input.read,
5342
+ });
5343
+ }
5344
+
5299
5345
  async alarm(): Promise<void> {
5300
5346
  // One alarm: the kernel defers while work is in flight, settles Package
5301
5347
  // scheduled work, and recovers the active run. A run left
@@ -5953,7 +5999,12 @@ export class ShellBotBackendContribution {
5953
5999
  `effect admission "${effect.effectId}" does not match durable intent`,
5954
6000
  );
5955
6001
  }
5956
- const outcome = run.stopRequestedAt ? "fenced" : "admitted";
6002
+ // Supersede fences exactly as Stop does. It is what makes an interrupt
6003
+ // durable rather than advisory: a Turn whose Agent never got the signal
6004
+ // — because the object was evicted and resumed — still starts no new
6005
+ // provider call or tool effect once the intent is recorded.
6006
+ const outcome =
6007
+ run.stopRequestedAt || run.supersededAt ? "fenced" : "admitted";
5957
6008
  const next = requireStoredRunV1({
5958
6009
  ...run,
5959
6010
  effectAdmissions: [
@@ -267,19 +267,56 @@ const macDesktop =
267
267
  typeof navigator !== "undefined" &&
268
268
  /Electron/u.test(navigator.userAgent) &&
269
269
  /Mac/u.test(navigator.platform);
270
- const botName = computed(
271
- () => state.value.botSettings?.profile.name ?? "Barebones",
272
- );
273
- const isRunning = computed(() => Boolean(state.value.activeRunId));
270
+ // The Bot's own name, or nothing: an account with no Bot, and a Bot whose
271
+ // settings have not arrived yet, must never be given a made-up name.
272
+ const botName = computed(() => state.value.botSettings?.profile.name ?? "");
273
+ const hasBot = computed(() => Boolean(state.value.activeBotId));
274
+ /**
275
+ * The greeting, the composer placeholder and the not-ready line all read off
276
+ * the same two facts: whether a Bot is open, and what the model resolver said.
277
+ * `state.modelLabel` already carries the resolver's own repairable failure
278
+ * sentence when the binding failed, so the surface repeats it rather than
279
+ * inventing "Model unavailable" of its own.
280
+ */
281
+ const threadHeading = computed(() => {
282
+ if (!hasBot.value) return "No Bots yet.";
283
+ if (!botName.value) return state.value.modelReady ? "Ready." : "Not ready.";
284
+ return state.value.modelReady
285
+ ? `${botName.value} is ready.`
286
+ : `${botName.value} isn't ready.`;
287
+ });
288
+ const threadHint = computed(() => {
289
+ if (!hasBot.value) return "Add your first sheep to start a conversation.";
290
+ if (state.value.modelReady) {
291
+ return "Start with a conversation. Cordis plugins can add the rest.";
292
+ }
293
+ return state.value.modelLabel;
294
+ });
295
+ /** A Turn is executing. The composer stays open; only Stop depends on this. */
296
+ const isRunning = computed(() => Boolean(state.value.runningRunId));
274
297
  const isConnecting = computed(() => state.value.connection !== "ready");
298
+ /**
299
+ * Sending while the Bot is working is the point: the message supersedes the
300
+ * running Turn. So the only things that close the composer are the ones that
301
+ * would make any message impossible.
302
+ */
303
+ const composerPlaceholder = computed(() => {
304
+ if (isConnecting.value) return "Connecting…";
305
+ if (!state.value.modelReady) return state.value.modelLabel;
306
+ return botName.value ? `Message ${botName.value}` : "Message";
307
+ });
275
308
  const canSend = computed(
276
309
  () =>
277
310
  state.value.connection === "ready" &&
278
311
  state.value.modelReady &&
279
312
  Boolean(state.value.activeBotId) &&
280
- !isRunning.value &&
281
313
  draft.value.trim().length > 0,
282
314
  );
315
+ /**
316
+ * Stop takes the button only while there is nothing to send. The moment the
317
+ * User has typed something, sending it is what they mean by interrupting.
318
+ */
319
+ const showStop = computed(() => isRunning.value && !canSend.value);
283
320
 
284
321
  /*
285
322
  * Tool activity is internal to the Turn. A Turn that produced only tool calls
@@ -295,8 +332,15 @@ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
295
332
  }
296
333
 
297
334
  function iframeEntriesFor(tool: WebToolActivity) {
298
- const slot = `frockbot.tool-result:${tool.name}`;
335
+ const separator = tool.name.indexOf("/");
336
+ const namespace = separator < 0 ? undefined : tool.name.slice(0, separator);
337
+ const toolName = separator < 0 ? tool.name : tool.name.slice(separator + 1);
338
+ const slot = `frockbot.tool-result:${toolName}`;
299
339
  return (state.value.packageUi?.contributions ?? [])
340
+ .filter(
341
+ (contribution) =>
342
+ namespace === undefined || contribution.packageId === namespace,
343
+ )
300
344
  .flatMap((contribution) =>
301
345
  contribution.pages.flatMap((page) =>
302
346
  page.mounts
@@ -761,7 +805,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
761
805
  <span class="bot-identity"
762
806
  ><k-slot name="frockbot.bot-identity"
763
807
  /></span>
764
- <div class="workspace-title">
808
+ <div v-if="hasBot" class="workspace-title">
765
809
  <strong>{{ botName }}</strong>
766
810
  <small>{{ state.modelLabel }}</small>
767
811
  </div>
@@ -776,20 +820,8 @@ function handleComposerKeydown(event: KeyboardEvent): void {
776
820
  >
777
821
  <div v-if="messages.length === 0" class="empty-thread">
778
822
  <div class="empty-mark"><UiIcon name="sparkle" size="lg" /></div>
779
- <h1>
780
- {{
781
- state.modelReady
782
- ? `${botName} is ready.`
783
- : `${botName} isn't ready.`
784
- }}
785
- </h1>
786
- <p>
787
- {{
788
- state.modelReady
789
- ? "Start with a conversation. Cordis plugins can add the rest."
790
- : "Check this Bot's model Connection."
791
- }}
792
- </p>
823
+ <h1>{{ threadHeading }}</h1>
824
+ <p>{{ threadHint }}</p>
793
825
  </div>
794
826
  <article
795
827
  v-for="message in messages"
@@ -797,7 +829,10 @@ function handleComposerKeydown(event: KeyboardEvent): void {
797
829
  :id="turnAnchors.get(message.id)"
798
830
  :key="message.id"
799
831
  class="message"
800
- :class="`message-${message.role}`"
832
+ :class="[
833
+ `message-${message.role}`,
834
+ { 'message-pending': message.pending },
835
+ ]"
801
836
  >
802
837
  <p v-if="message.role === 'system'" class="message-system-line">
803
838
  {{ message.text }}
@@ -955,7 +990,12 @@ function handleComposerKeydown(event: KeyboardEvent): void {
955
990
  </div>
956
991
  </Transition>
957
992
 
993
+ <!--
994
+ No Bot, no composer. A disabled input under a made-up Bot name reads
995
+ as a broken Bot; the first-run pane above points at making one.
996
+ -->
958
997
  <form
998
+ v-if="hasBot"
959
999
  class="composer"
960
1000
  :class="{ 'composer-busy': isRunning }"
961
1001
  @submit.prevent="sendMessage"
@@ -1018,14 +1058,8 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1018
1058
  ref="composerInput"
1019
1059
  v-model="draft"
1020
1060
  aria-label="Message"
1021
- :placeholder="
1022
- isConnecting
1023
- ? 'Connecting…'
1024
- : !state.modelReady
1025
- ? 'Model unavailable'
1026
- : `Message ${botName}`
1027
- "
1028
- :disabled="isConnecting || !state.modelReady || isRunning"
1061
+ :placeholder="composerPlaceholder"
1062
+ :disabled="isConnecting || !state.modelReady"
1029
1063
  rows="1"
1030
1064
  role="combobox"
1031
1065
  :aria-expanded="skillPopoverOpen"
@@ -1038,7 +1072,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1038
1072
  />
1039
1073
  </div>
1040
1074
  <UiIconButton
1041
- v-if="isRunning"
1075
+ v-if="showStop"
1042
1076
  class="stop-button"
1043
1077
  icon="stop"
1044
1078
  label="Stop generating"