@frockbot/plugin-flock 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-flock",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,16 +27,16 @@
27
27
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
28
28
  },
29
29
  "dependencies": {
30
- "@frockbot/client-core": "0.3.9",
31
- "@frockbot/client-ui": "0.3.9",
32
- "@frockbot/configuration-core": "0.3.9",
33
- "@frockbot/kernel-contracts": "0.3.9",
34
- "@frockbot/plugin-shell": "0.3.9",
30
+ "@frockbot/client-core": "0.3.11",
31
+ "@frockbot/client-ui": "0.3.11",
32
+ "@frockbot/configuration-core": "0.3.11",
33
+ "@frockbot/kernel-contracts": "0.3.11",
34
+ "@frockbot/plugin-shell": "0.3.11",
35
35
  "cordis": "4.0.0-rc.8",
36
36
  "vue": "3.5.41"
37
37
  },
38
38
  "devDependencies": {
39
- "@frockbot/plugin-testkit": "0.3.9",
39
+ "@frockbot/plugin-testkit": "0.3.11",
40
40
  "@types/bun": "1.4.0",
41
41
  "@vitejs/plugin-vue": "6.0.8",
42
42
  "css-tree": "2.3.1",
@@ -1,20 +1,52 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * What a Bot looks like. The Flock's generated sheep recipe is the avatar.
4
+ *
5
+ * `working` draws the same activity ring the thread puts on the Bot it is
6
+ * talking to, so a person reading one conversation can see another Bot still
7
+ * going — on a list row and on a pinned tile alike. The sidebar knows only that
8
+ * a Turn is running; the step count is the open conversation's to show, so a
9
+ * ring out here pulses and does not tick.
4
10
  */
11
+ import { UiActivityRing } from "@frockbot/client-ui";
5
12
  import SheepAvatar from "./SheepAvatar.vue";
6
13
  import type { SheepRecipeV1 } from "../shared.js";
7
14
 
8
- const props = withDefaults(
15
+ withDefaults(
9
16
  defineProps<{
10
17
  botId: string;
11
18
  sheep: SheepRecipeV1;
12
19
  label?: string;
13
- size?: "mini" | "small" | "large";
20
+ size?: "mini" | "small" | "tile" | "large";
21
+ working?: boolean;
14
22
  }>(),
15
- { label: "Bot avatar", size: "small" },
23
+ { label: "Bot avatar", size: "small", working: false },
16
24
  );
17
25
  </script>
18
26
  <template>
19
- <SheepAvatar :sheep="sheep" :label="label" :size="size" />
27
+ <span class="flock-avatar-slot">
28
+ <SheepAvatar :sheep="sheep" :label="label" :size="size" />
29
+ <Transition name="flock-avatar-ring">
30
+ <UiActivityRing v-if="working" :progress="0" />
31
+ </Transition>
32
+ </span>
20
33
  </template>
34
+
35
+ <style scoped>
36
+ .flock-avatar-slot {
37
+ position: relative;
38
+ display: inline-grid;
39
+ flex: 0 0 auto;
40
+ place-items: center;
41
+ }
42
+
43
+ .flock-avatar-ring-enter-active,
44
+ .flock-avatar-ring-leave-active {
45
+ transition: opacity 420ms ease-out;
46
+ }
47
+
48
+ .flock-avatar-ring-enter-from,
49
+ .flock-avatar-ring-leave-to {
50
+ opacity: 0;
51
+ }
52
+ </style>
@@ -3,7 +3,11 @@ import { UiSkeleton } from "@frockbot/client-ui";
3
3
  import { computed, inject, onMounted } from "vue";
4
4
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
5
5
  import { flockWebDataKey } from "./state.js";
6
- import { formatSidebarMessageTimeV1, groupSidebarBotsV1 } from "./sidebar.js";
6
+ import {
7
+ formatSidebarMessageTimeV1,
8
+ groupSidebarBotsV1,
9
+ partitionPinnedSidebarBotsV1,
10
+ } from "./sidebar.js";
7
11
  import BotAvatar from "./BotAvatar.vue";
8
12
 
9
13
  const injectedFlock = inject(flockWebDataKey);
@@ -33,8 +37,16 @@ const listedBots = computed(() =>
33
37
  const visibleBots = computed(() =>
34
38
  listedBots.value.filter((bot) => !isHidden(bot.botId)),
35
39
  );
40
+ /*
41
+ * A pinned Bot is a tile above the list instead of a row inside it, never
42
+ * both: the tile is the row, moved, so grouping runs over what is left.
43
+ */
44
+ const partitionedBots = computed(() =>
45
+ partitionPinnedSidebarBotsV1(visibleBots.value, flock.value.profiles),
46
+ );
47
+ const pinnedBots = computed(() => partitionedBots.value.pinned);
36
48
  const groupedVisibleBots = computed(() =>
37
- groupSidebarBotsV1(visibleBots.value, flock.value.profiles),
49
+ groupSidebarBotsV1(partitionedBots.value.rest, flock.value.profiles),
38
50
  );
39
51
  const hiddenBots = computed(() =>
40
52
  listedBots.value.filter((bot) => isHidden(bot.botId)),
@@ -65,6 +77,18 @@ function previewTime(botId: string): string {
65
77
  function isUnread(botId: string): boolean {
66
78
  return flock.value.unread[botId]?.unread === true;
67
79
  }
80
+ /**
81
+ * Whether the row draws an activity ring.
82
+ *
83
+ * The open Bot's own Turn is the Shell's — it is projecting the run into the
84
+ * conversation and knows about it a poll sooner — so that row reads the Shell.
85
+ * Every other row reads the unread fan-out, which is the only thing that knows
86
+ * a Bot in another conversation is working.
87
+ */
88
+ function isWorking(botId: string): boolean {
89
+ if (botId === active.value) return shell.value.activeRunId !== undefined;
90
+ return flock.value.unread[botId]?.working === true;
91
+ }
68
92
  function unreadLabel(botId: string): string | undefined {
69
93
  const view = flock.value.unread[botId];
70
94
  if (!view?.unread || view.count === 0) return undefined;
@@ -109,6 +133,50 @@ onMounted(() => void flock.value.load());
109
133
  No Bots yet. Add your first sheep.
110
134
  </p>
111
135
  <template v-else>
136
+ <!--
137
+ Pinned Bots: large tiles above the labelled groups, in pin order. The
138
+ tile is the row moved, so clicking it selects exactly as a row does.
139
+ -->
140
+ <TransitionGroup
141
+ v-if="pinnedBots.length"
142
+ name="flock-row"
143
+ tag="div"
144
+ class="flock-pinned"
145
+ >
146
+ <button
147
+ v-for="bot in pinnedBots"
148
+ :key="bot.botId"
149
+ type="button"
150
+ class="flock-pinned-tile"
151
+ :class="{
152
+ active: active === bot.botId,
153
+ archived: flock.lifecycles[bot.botId] === 'archived',
154
+ unread: isUnread(bot.botId),
155
+ }"
156
+ :disabled="flock.lifecycles[bot.botId] === 'archived'"
157
+ :aria-current="active === bot.botId ? 'true' : undefined"
158
+ @click="flock.select(bot.botId)"
159
+ >
160
+ <span class="flock-pinned-art">
161
+ <BotAvatar
162
+ :bot-id="bot.botId"
163
+ :sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
164
+ size="tile"
165
+ :label="`${botName(bot.botId, bot.initialName)} avatar`"
166
+ :working="isWorking(bot.botId)"
167
+ />
168
+ <i
169
+ v-if="isUnread(bot.botId)"
170
+ class="flock-pinned-dot"
171
+ role="img"
172
+ :aria-label="`${botName(bot.botId, bot.initialName)} has unread`"
173
+ />
174
+ </span>
175
+ <span class="flock-pinned-name">{{
176
+ botName(bot.botId, bot.initialName)
177
+ }}</span>
178
+ </button>
179
+ </TransitionGroup>
112
180
  <div class="flock-groups">
113
181
  <section
114
182
  v-for="group in groupedVisibleBots.groups"
@@ -140,6 +208,7 @@ onMounted(() => void flock.value.load());
140
208
  :bot-id="bot.botId"
141
209
  :sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
142
210
  :label="`${botName(bot.botId, bot.initialName)} avatar`"
211
+ :working="isWorking(bot.botId)"
143
212
  />
144
213
  <span class="flock-bot-copy">
145
214
  <span class="flock-bot-primary">
@@ -238,6 +307,7 @@ onMounted(() => void flock.value.load());
238
307
  :bot-id="bot.botId"
239
308
  :sheep="flock.identities[bot.botId]?.sheep ?? bot.sheep"
240
309
  :label="`${botName(bot.botId, bot.initialName)} avatar`"
310
+ :working="isWorking(bot.botId)"
241
311
  />
242
312
  <span class="flock-bot-copy">
243
313
  <span class="flock-bot-primary">
@@ -5,7 +5,7 @@ const props = withDefaults(
5
5
  defineProps<{
6
6
  sheep: SheepRecipeV1;
7
7
  label?: string;
8
- size?: "mini" | "small" | "large";
8
+ size?: "mini" | "small" | "tile" | "large";
9
9
  }>(),
10
10
  { label: "Bot sheep", size: "small" },
11
11
  );
Binary file
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { formatSidebarMessageTimeV1, groupSidebarBotsV1 } from "./sidebar.js";
2
+ import {
3
+ formatSidebarMessageTimeV1,
4
+ groupSidebarBotsV1,
5
+ partitionPinnedSidebarBotsV1,
6
+ } from "./sidebar.js";
3
7
 
4
8
  const bots = [
5
9
  { botId: "alpha" },
@@ -8,6 +12,48 @@ const bots = [
8
12
  { botId: "delta" },
9
13
  ];
10
14
 
15
+ describe("pinned sidebar Bots", () => {
16
+ test("orders pinned Bots by pin time and leaves the rest in list order", () => {
17
+ expect(
18
+ partitionPinnedSidebarBotsV1(bots, {
19
+ alpha: { pinnedAt: "2026-09-02T09:00:00.000Z" },
20
+ beta: {},
21
+ gamma: { pinnedAt: "2026-08-30T09:00:00.000Z" },
22
+ }),
23
+ ).toEqual({
24
+ pinned: [{ botId: "gamma" }, { botId: "alpha" }],
25
+ rest: [{ botId: "beta" }, { botId: "delta" }],
26
+ });
27
+ });
28
+
29
+ test("a pinned Bot leaves the labelled groups entirely", () => {
30
+ const profiles: Record<string, { label?: string; pinnedAt?: string }> = {
31
+ alpha: { label: "Work", pinnedAt: "2026-09-02T09:00:00.000Z" },
32
+ beta: { label: "Work" },
33
+ };
34
+ const { pinned, rest } = partitionPinnedSidebarBotsV1(
35
+ bots.slice(0, 2),
36
+ profiles,
37
+ );
38
+ expect(pinned.map((bot) => bot.botId)).toEqual(["alpha"]);
39
+ expect(
40
+ groupSidebarBotsV1(rest, profiles).groups.map((group) => ({
41
+ label: group.label,
42
+ bots: group.bots.map((bot) => bot.botId),
43
+ })),
44
+ ).toEqual([{ label: "Work", bots: ["beta"] }]);
45
+ });
46
+
47
+ test("treats a blank pin instant as unpinned", () => {
48
+ expect(
49
+ partitionPinnedSidebarBotsV1(bots.slice(0, 2), {
50
+ alpha: { pinnedAt: " " },
51
+ beta: {},
52
+ }).pinned,
53
+ ).toEqual([]);
54
+ });
55
+ });
56
+
11
57
  describe("sidebar Bot groups", () => {
12
58
  test("renders one plain list until a visible Bot has a label", () => {
13
59
  expect(groupSidebarBotsV1(bots.slice(0, 2), {})).toEqual({
@@ -1,3 +1,40 @@
1
+ export interface PinnedSidebarBotsV1<T> {
2
+ /** Pinned Bots, earliest pin first. Rendered as tiles above the list. */
3
+ pinned: T[];
4
+ /** Everything else, in the order it arrived. */
5
+ rest: T[];
6
+ }
7
+
8
+ /**
9
+ * Splits the pinned Bots out of the sidebar list. A pinned Bot is a tile at
10
+ * the top instead of a row below, never both, so the list this returns is what
11
+ * {@link groupSidebarBotsV1} then groups by label.
12
+ *
13
+ * Order is by pin time — earliest first — because the tile row is a place a
14
+ * User builds up over time and a Bot pinned today must not displace the one
15
+ * they pinned last month. Ties and unparseable instants keep list order.
16
+ */
17
+ export function partitionPinnedSidebarBotsV1<T extends { botId: string }>(
18
+ bots: readonly T[],
19
+ profiles: Readonly<Record<string, { pinnedAt?: string } | undefined>>,
20
+ ): PinnedSidebarBotsV1<T> {
21
+ const pinned: Array<{ bot: T; at: number; index: number }> = [];
22
+ const rest: T[] = [];
23
+ for (const [index, bot] of bots.entries()) {
24
+ const pinnedAt = profiles[bot.botId]?.pinnedAt?.trim();
25
+ if (!pinnedAt) {
26
+ rest.push(bot);
27
+ continue;
28
+ }
29
+ const at = Date.parse(pinnedAt);
30
+ pinned.push({ bot, at: Number.isFinite(at) ? at : 0, index });
31
+ }
32
+ pinned.sort((left, right) =>
33
+ left.at === right.at ? left.index - right.index : left.at - right.at,
34
+ );
35
+ return { pinned: pinned.map((entry) => entry.bot), rest };
36
+ }
37
+
1
38
  export interface SidebarBotGroupV1<T> {
2
39
  key: string;
3
40
  label: string;
@@ -16,6 +16,13 @@
16
16
  border-radius: 9px;
17
17
  }
18
18
 
19
+ /* The pinned sidebar tile: round, so a pin reads as a destination not a row. */
20
+ .flock-avatar--tile {
21
+ width: var(--frock-avatar-lg);
22
+ height: var(--frock-avatar-lg);
23
+ border-radius: 50%;
24
+ }
25
+
19
26
  .flock-avatar--large {
20
27
  width: 188px;
21
28
  height: 188px;
@@ -63,6 +70,88 @@
63
70
  color: var(--frock-text);
64
71
  }
65
72
 
73
+ /*
74
+ * Pinned Bots. A wrapping row of large round tiles above the labelled groups,
75
+ * each one a whole Bot rather than a line of text about it.
76
+ */
77
+ .flock-pinned {
78
+ display: flex;
79
+ flex-wrap: wrap;
80
+ gap: 6px;
81
+ padding: 4px 6px 10px;
82
+ border-bottom: 1px solid var(--frock-border);
83
+ margin-bottom: 8px;
84
+ }
85
+
86
+ .flock-pinned-tile {
87
+ display: flex;
88
+ width: 72px;
89
+ flex: 0 0 auto;
90
+ flex-direction: column;
91
+ align-items: center;
92
+ gap: 6px;
93
+ padding: 6px 2px;
94
+ border: 0;
95
+ border-radius: var(--frock-radius-card);
96
+ background: transparent;
97
+ color: inherit;
98
+ font: inherit;
99
+ cursor: pointer;
100
+ transition: background-color var(--frock-motion-fast);
101
+ }
102
+
103
+ .flock-pinned-tile:hover:not(:disabled) {
104
+ background: var(--frock-fill-hover);
105
+ }
106
+
107
+ .flock-pinned-tile.active {
108
+ background: var(--frock-fill-pressed);
109
+ }
110
+
111
+ .flock-pinned-tile.archived {
112
+ opacity: 0.72;
113
+ cursor: default;
114
+ }
115
+
116
+ .flock-pinned-tile:focus-visible {
117
+ outline: 2px solid var(--frock-focus-ring);
118
+ outline-offset: 2px;
119
+ }
120
+
121
+ .flock-pinned-art {
122
+ position: relative;
123
+ display: inline-flex;
124
+ }
125
+
126
+ /* The tile has no preview line, so unread is a dot rather than bold text. */
127
+ .flock-pinned-dot {
128
+ position: absolute;
129
+ right: 0;
130
+ bottom: 2px;
131
+ width: 10px;
132
+ height: 10px;
133
+ border-radius: 999px;
134
+ background: var(--frock-action-primary);
135
+ box-shadow: 0 0 0 2px var(--frock-surface);
136
+ }
137
+
138
+ .flock-pinned-name {
139
+ max-width: 100%;
140
+ overflow: hidden;
141
+ color: var(--frock-text-muted);
142
+ font-size: var(--frock-text-xs);
143
+ line-height: var(--frock-leading-tight);
144
+ text-align: center;
145
+ text-overflow: ellipsis;
146
+ white-space: nowrap;
147
+ }
148
+
149
+ .flock-pinned-tile.active .flock-pinned-name,
150
+ .flock-pinned-tile.unread .flock-pinned-name {
151
+ color: var(--frock-text);
152
+ font-weight: 600;
153
+ }
154
+
66
155
  .flock-groups,
67
156
  .flock-group,
68
157
  .flock-rows {
@@ -184,6 +184,28 @@ describe("Flock v1 contracts", () => {
184
184
  }),
185
185
  ).toThrow("duplicate IDs");
186
186
  });
187
+
188
+ test("carries the pin instant, and refuses one that is not an instant", () => {
189
+ const identity = {
190
+ schemaVersion: 1 as const,
191
+ botId: "alpha",
192
+ name: "Atlas",
193
+ namedBy: "user" as const,
194
+ hiddenFromSidebar: false,
195
+ pinnedAt: "2026-09-03T10:15:00.000Z",
196
+ };
197
+ expect(decodeBotIdentityViewV1(structuredClone(identity))).toEqual(
198
+ identity,
199
+ );
200
+ // Absent stays absent: every identity written before pinning existed.
201
+ const { pinnedAt: _pinnedAt, ...unpinned } = identity;
202
+ expect(decodeBotIdentityViewV1(structuredClone(unpinned))).toEqual(
203
+ unpinned,
204
+ );
205
+ expect(() =>
206
+ decodeBotIdentityViewV1({ ...identity, pinnedAt: "someday" }),
207
+ ).toThrow("pinnedAt is invalid");
208
+ });
187
209
  });
188
210
 
189
211
  describe("stored Bot directory migration", () => {
package/src/shared.ts CHANGED
@@ -137,6 +137,12 @@ export interface BotIdentityViewV1 {
137
137
  /** Purely organisational sidebar group; never part of Bot instructions. */
138
138
  label?: string;
139
139
  title?: string;
140
+ /**
141
+ * When the User pinned this Bot, as an ISO 8601 instant. A pinned Bot is
142
+ * shown as a tile above the list instead of a row inside it, earliest pin
143
+ * first. Absent means not pinned.
144
+ */
145
+ pinnedAt?: string;
140
146
  }
141
147
 
142
148
  export interface BotIdentityDirectoryViewV1 {
@@ -600,7 +606,7 @@ export function decodeBotIdentityViewV1(input: unknown): BotIdentityViewV1 {
600
606
  exact(
601
607
  value,
602
608
  ["schemaVersion", "botId", "name", "namedBy", "hiddenFromSidebar"],
603
- ["label", "title"],
609
+ ["label", "title", "pinnedAt"],
604
610
  );
605
611
  if (value.schemaVersion !== 1 || typeof value.hiddenFromSidebar !== "boolean")
606
612
  throw new FlockDecodeError("Bot identity is invalid");
@@ -616,9 +622,20 @@ export function decodeBotIdentityViewV1(input: unknown): BotIdentityViewV1 {
616
622
  ...(value.title === undefined
617
623
  ? {}
618
624
  : { title: boundedText(value.title, "title", 120) }),
625
+ ...(value.pinnedAt === undefined
626
+ ? {}
627
+ : { pinnedAt: timestampText(value.pinnedAt, "pinnedAt") }),
619
628
  };
620
629
  }
621
630
 
631
+ /** An ISO 8601 instant. The sidebar orders pinned Bots by it. */
632
+ function timestampText(value: unknown, label: string): string {
633
+ const candidate = boundedText(value, label, 64);
634
+ if (!Number.isFinite(Date.parse(candidate)))
635
+ throw new FlockDecodeError(`${label} is invalid`);
636
+ return candidate;
637
+ }
638
+
622
639
  export function decodeBotIdentityDirectoryViewV1(
623
640
  input: unknown,
624
641
  ): BotIdentityDirectoryViewV1 {