@antelopejs/dms-ai 0.2.1 → 0.4.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 (87) hide show
  1. package/README.md +64 -205
  2. package/dist/constants/frontend-module.d.ts +2 -0
  3. package/dist/constants/frontend-module.js +6 -1
  4. package/dist/constants/frontend-module.js.map +1 -1
  5. package/dist/index.js +4 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/lifecycle/skill-sources.d.ts +1 -1
  8. package/dist/lifecycle/skill-sources.js +29 -13
  9. package/dist/lifecycle/skill-sources.js.map +1 -1
  10. package/frontend-vue/app/chat/components/BlockPreviewCard.vue +172 -0
  11. package/frontend-vue/app/chat/components/BlockPreviewModal.vue +324 -0
  12. package/frontend-vue/app/chat/components/BlockPreviewThumb.vue +141 -0
  13. package/frontend-vue/app/chat/components/MessageList.vue +20 -0
  14. package/frontend-vue/app/chat/components/SketchFrame.vue +59 -0
  15. package/frontend-vue/app/chat/composables/conversation-events.ts +23 -1
  16. package/frontend-vue/app/chat/composables/useBlockPreviewComponent.ts +15 -0
  17. package/frontend-vue/app/chat/composables/useDashboardScheme.ts +42 -0
  18. package/frontend-vue/app/chat/constants/block-preview.ts +27 -0
  19. package/frontend-vue/app/chat/constants/conversation.ts +1 -0
  20. package/frontend-vue/app/chat/constants/protocol.ts +1 -0
  21. package/frontend-vue/app/chat/constants/sketch.ts +105 -0
  22. package/frontend-vue/app/chat/types/conversation.ts +11 -1
  23. package/frontend-vue/app/chat/types/protocol.ts +56 -0
  24. package/frontend-vue/app/chat/utils/block-preview.ts +145 -0
  25. package/frontend-vue/app/chat/utils/conversation-messages.ts +23 -1
  26. package/frontend-vue/app/chat/utils/render-items.ts +13 -2
  27. package/frontend-vue/app/chat/utils/sketch.ts +56 -0
  28. package/frontend-vue/app/chat/utils/tool-lexicon.ts +7 -0
  29. package/frontend-vue/app/components/ActivityDetail.vue +11 -13
  30. package/frontend-vue/app/components/ChangesView.vue +56 -49
  31. package/frontend-vue/app/plugins/ai.client.ts +16 -6
  32. package/frontend-vue/app/runtime/assistant-activity.ts +38 -21
  33. package/frontend-vue/app/runtime/sidecar-status.ts +78 -56
  34. package/frontend-vue/app/views/ChangeSetPanel.vue +13 -18
  35. package/frontend-vue/app/views/UnavailableState.vue +1 -1
  36. package/frontend-vue/app/views/composables/useAiData.ts +116 -0
  37. package/frontend-vue/app/views/composables/useAssistant.ts +8 -2
  38. package/frontend-vue/app/views/composables/useChangeSetActions.ts +7 -6
  39. package/frontend-vue/app/views/utils/change-sets.ts +0 -7
  40. package/frontend-vue/i18n/locales/dms-ai-panel-en-GB.json +23 -1
  41. package/frontend-vue/i18n/locales/dms-ai-panel-fr-FR.json +23 -1
  42. package/frontend-vue/package.json +1 -1
  43. package/frontend-vue/tests/assistant-activity.test.ts +69 -0
  44. package/frontend-vue/tests/chat/block-preview.test.ts +448 -0
  45. package/frontend-vue/tests/sidecar-guest.test.ts +1 -1
  46. package/frontend-vue/tests/sidecar-status.test.ts +93 -15
  47. package/frontend-vue/tests/support/dms-frontend-module.ts +9 -0
  48. package/frontend-vue/tests/views/api-data-reads.test.ts +445 -0
  49. package/frontend-vue/tests/views/use-assistant.test.ts +53 -0
  50. package/frontend-vue/vitest.config.ts +10 -0
  51. package/package.json +20 -6
  52. package/sidecar/dist/constants/mcp.js +2 -0
  53. package/sidecar/dist/constants/mcp.js.map +1 -1
  54. package/sidecar/dist/constants/preview.d.ts +39 -0
  55. package/sidecar/dist/constants/preview.js +55 -0
  56. package/sidecar/dist/constants/preview.js.map +1 -0
  57. package/sidecar/dist/constants/tool-kinds.js +2 -0
  58. package/sidecar/dist/constants/tool-kinds.js.map +1 -1
  59. package/sidecar/dist/mcp/tool-definitions.js +5 -0
  60. package/sidecar/dist/mcp/tool-definitions.js.map +1 -1
  61. package/sidecar/dist/mcp/tools/preview-blocks.d.ts +65 -0
  62. package/sidecar/dist/mcp/tools/preview-blocks.js +176 -0
  63. package/sidecar/dist/mcp/tools/preview-blocks.js.map +1 -0
  64. package/sidecar/dist/mcp/types.d.ts +3 -0
  65. package/sidecar/dist/prompts/agent.d.ts +1 -1
  66. package/sidecar/dist/prompts/agent.js +3 -2
  67. package/sidecar/dist/prompts/agent.js.map +1 -1
  68. package/sidecar/dist/protocol/events.d.ts +313 -0
  69. package/sidecar/dist/protocol/events.js +9 -0
  70. package/sidecar/dist/protocol/events.js.map +1 -1
  71. package/sidecar/dist/protocol/preview.d.ts +130 -0
  72. package/sidecar/dist/protocol/preview.js +91 -0
  73. package/sidecar/dist/protocol/preview.js.map +1 -0
  74. package/sidecar/dist/server/chat-events.d.ts +3 -0
  75. package/sidecar/dist/server/chat-events.js +21 -0
  76. package/sidecar/dist/server/chat-events.js.map +1 -1
  77. package/sidecar/dist/server/ws.js +3 -1
  78. package/sidecar/dist/server/ws.js.map +1 -1
  79. package/sidecar/dist/state/types.d.ts +3 -1
  80. package/sidecar/dist/state/types.js +1 -0
  81. package/sidecar/dist/state/types.js.map +1 -1
  82. package/sidecar/package.json +1 -1
  83. package/skills/dms-ai/SKILL.md +30 -0
  84. package/skills/dms-ai-code-mode/SKILL.md +51 -0
  85. package/skills/dms-builder-safe/SKILL.md +9 -2
  86. package/frontend-vue/app/views/composables/useAiRequest.ts +0 -89
  87. package/skills/dms-builder-page/SKILL.md +0 -80
@@ -0,0 +1,56 @@
1
+ import {
2
+ SKETCH_BASE_CSS,
3
+ SKETCH_CSP,
4
+ SKETCH_TOKENS,
5
+ type SketchScheme,
6
+ type SketchToken,
7
+ UNSAFE_TOKEN_VALUE,
8
+ } from "../constants/sketch";
9
+ import type { PreviewSketch } from "../types/protocol";
10
+
11
+ /** Reads a dashboard variable; unset or unsafe values give the token's default. */
12
+ export type TokenReader = (variable: string) => string;
13
+
14
+ function tokenValue(
15
+ token: SketchToken,
16
+ scheme: SketchScheme,
17
+ read: TokenReader,
18
+ ): string {
19
+ const value = read(token.source).trim();
20
+ if (value === "" || UNSAFE_TOKEN_VALUE.test(value)) return token[scheme];
21
+ return value;
22
+ }
23
+
24
+ /** The `:root` rule giving a sandboxed sketch the dashboard's theme. */
25
+ export function sketchTokensCss(
26
+ scheme: SketchScheme,
27
+ read: TokenReader,
28
+ ): string {
29
+ const declarations = SKETCH_TOKENS.map(
30
+ (token) => `--sketch-${token.name}:${tokenValue(token, scheme, read)};`,
31
+ );
32
+ return `:root{--sketch-scheme:${scheme};${declarations.join("")}}`;
33
+ }
34
+
35
+ export function computedStyleReader(element: Element): TokenReader {
36
+ const style = getComputedStyle(element);
37
+ return (variable) => style.getPropertyValue(variable);
38
+ }
39
+
40
+ /**
41
+ * The standalone document a sketch is drawn in. The CSP comes first in the
42
+ * head, before anything the agent wrote, so no later markup can loosen it.
43
+ */
44
+ export function buildSketchDocument(
45
+ sketch: PreviewSketch,
46
+ tokensCss: string,
47
+ ): string {
48
+ return [
49
+ "<!doctype html><html><head>",
50
+ `<meta http-equiv="Content-Security-Policy" content="${SKETCH_CSP}">`,
51
+ '<meta charset="utf-8">',
52
+ `<style>${tokensCss}\n${SKETCH_BASE_CSS}</style>`,
53
+ `<style>${sketch.css ?? ""}</style>`,
54
+ `</head><body>${sketch.html}</body></html>`,
55
+ ].join("");
56
+ }
@@ -138,6 +138,7 @@ const ICONS = {
138
138
  LOGS: "i-ph-scroll",
139
139
  SKILL: "i-ph-books",
140
140
  PLAN: "i-ph-list-checks",
141
+ PREVIEW: "i-ph-frame-corners",
141
142
  REFRESH: "i-ph-arrows-clockwise",
142
143
  CATEGORY: "i-ph-folders",
143
144
  } as const;
@@ -285,6 +286,12 @@ const ASSISTANT_TOOLS: Record<string, ToolLexiconEntry> = {
285
286
  target: countOf("questions"),
286
287
  isReadOnly: true,
287
288
  },
289
+ PreviewBlocks: {
290
+ verb: "show_preview",
291
+ icon: ICONS.PREVIEW,
292
+ target: firstOf("title"),
293
+ isReadOnly: true,
294
+ },
288
295
  Typecheck: {
289
296
  verb: "typecheck",
290
297
  icon: ICONS.CHECK,
@@ -1,11 +1,11 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import DecisionLabel from "../views/DecisionLabel.vue";
4
4
  import DiffView from "../views/DiffView.vue";
5
5
  import RowStepper from "../views/RowStepper.vue";
6
6
  import ResultPill from "../views/ResultPill.vue";
7
7
  import UnavailableState from "../views/UnavailableState.vue";
8
- import { useAiRequest } from "../views/composables/useAiRequest";
8
+ import { useAiData } from "../views/composables/useAiData";
9
9
  import { useAssistant } from "../views/composables/useAssistant";
10
10
  import { useViewFormat } from "../views/composables/useViewFormat";
11
11
  import { AI_PAGES, AI_ROUTES, CHANGE_SET_QUERY_KEY } from "../views/constants";
@@ -30,29 +30,27 @@ interface Props {
30
30
  const props = defineProps<Props>();
31
31
 
32
32
  const { t } = useI18n();
33
- const { $authFetch } = useAuthFetch();
34
33
  const { moment, toolLabel, clock } = useViewFormat();
35
34
  const assistant = useAssistant();
36
- const detail = useAiRequest<ActivityDetailPayload>();
37
35
  const isRawShown = ref(false);
38
36
 
39
37
  const OUTPUT_MAX_LINES = 14;
40
38
  const RAW_MAX_LINES = 20;
41
39
 
42
40
  const rowId = computed(() => props.rowData?.id ?? props.rowData?._id);
41
+ // Read with the page: a deep link (`?record=<id>`) renders the entry on the
42
+ // server and hands it over. The drawer opened again shows the entry read
43
+ // before and reads it again, as a pending call may have ended since.
44
+ const detail = useAiData<ActivityDetailPayload>(() =>
45
+ rowId.value
46
+ ? `${AI_ROUTES.activity}/${encodeURIComponent(rowId.value)}`
47
+ : null,
48
+ );
43
49
 
44
50
  function load(): void {
45
- const id = rowId.value;
46
- if (!id) return;
47
- void detail.run(() =>
48
- $authFetch<ActivityDetailPayload>(
49
- `${AI_ROUTES.activity}/${encodeURIComponent(id)}`,
50
- ),
51
- );
51
+ void detail.refresh();
52
52
  }
53
53
 
54
- watch(rowId, load, { immediate: true });
55
-
56
54
  const row = computed(() => props.rowData);
57
55
  const eyebrow = computed(() => {
58
56
  const current = row.value;
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, ref, watch } from "vue";
2
+ import { computed, ref, watch } from "vue";
3
3
  import { useDmsRoute } from "#dms/frontend-module";
4
4
  import ChangeSetPanel from "../views/ChangeSetPanel.vue";
5
5
  import UnavailableState from "../views/UnavailableState.vue";
6
- import { useAiRequest } from "../views/composables/useAiRequest";
6
+ import { useAiData, useAiPagedData } from "../views/composables/useAiData";
7
7
  import { useAssistant } from "../views/composables/useAssistant";
8
8
  import { useViewFormat } from "../views/composables/useViewFormat";
9
9
  import {
@@ -20,34 +20,18 @@ import type {
20
20
  import {
21
21
  changeSetIcon,
22
22
  matchesSearch,
23
- readSelectedSet,
24
23
  writeSelectedSet,
25
24
  } from "../views/utils/change-sets";
26
25
 
27
26
  type ScopeFilter = ScopeId | "all";
28
27
 
29
28
  const { t } = useI18n();
30
- const { $authFetch } = useAuthFetch();
31
29
  const { moment } = useViewFormat();
32
30
  const assistant = useAssistant();
33
31
  const route = useDmsRoute();
34
32
 
35
- const list = useAiRequest<ListPayload<ChangeSetSummary>>();
36
- const status = useAiRequest<AssistantStatus>();
37
- const changeSets = ref<ChangeSetSummary[]>([]);
38
- const total = ref(0);
39
33
  const scope = ref<ScopeFilter>("all");
40
34
  const search = ref("");
41
- const selectedId = ref<string | undefined>(readSelectedSet());
42
- // The list loads on the client only: until its first answer the server and
43
- // the hydrating client both draw the skeleton, never a premature empty state.
44
- const hasLoaded = ref(false);
45
-
46
- const SCOPE_ITEMS = computed(() => [
47
- { value: "all", label: t("dms_ai.views.changes.filter_all") },
48
- { value: "safe", label: t("dms_ai.views.changes.filter_safe") },
49
- { value: "vibe", label: t("dms_ai.views.changes.filter_code") },
50
- ]);
51
35
 
52
36
  function listQuery(offset: number): Record<string, string | number> {
53
37
  const query: Record<string, string | number> = {
@@ -58,46 +42,69 @@ function listQuery(offset: number): Record<string, string | number> {
58
42
  return { ...query, scope: scope.value, filter_scope: `is:${scope.value}` };
59
43
  }
60
44
 
61
- async function loadPage(offset: number): Promise<void> {
62
- const page = await list.run(() =>
63
- $authFetch<ListPayload<ChangeSetSummary>>(AI_ROUTES.changes, {
64
- query: listQuery(offset),
45
+ type ChangesPage = ListPayload<ChangeSetSummary>;
46
+
47
+ /** The sets the pages hold, in order. */
48
+ function loadedCount(pages: ChangesPage[]): number {
49
+ return pages.reduce((count, page) => count + page.results.length, 0);
50
+ }
51
+
52
+ // The status and the list's first page are read with the page: a server
53
+ // render shows them, and the selected set's panel, at once. "Load more"
54
+ // appends a page under the scope's key; the page's refreshes read every
55
+ // page loaded again.
56
+ const status = useAiData<AssistantStatus>(AI_ROUTES.status);
57
+ const list = useAiPagedData<ChangesPage, number>(
58
+ () => `dms-ai:changes:${scope.value}`,
59
+ ({ $authFetch, cursor, signal }) =>
60
+ $authFetch<ChangesPage>(AI_ROUTES.changes, {
61
+ query: listQuery(cursor ?? 0),
62
+ signal,
65
63
  }),
66
- );
67
- hasLoaded.value = true;
68
- if (!page) return;
69
- changeSets.value =
70
- offset === 0 ? page.results : [...changeSets.value, ...page.results];
71
- total.value = page.total;
72
- if (selectedId.value === undefined) selectedId.value = page.results[0]?.id;
64
+ {
65
+ nextCursor: (page, pages) => {
66
+ const loaded = loadedCount(pages);
67
+ return page.results.length > 0 && loaded < page.total ? loaded : null;
68
+ },
69
+ },
70
+ );
71
+ const changeSets = computed(() =>
72
+ list.pages.value.flatMap((page) => page.results),
73
+ );
74
+ const total = computed(() => list.pages.value.at(-1)?.total ?? 0);
75
+
76
+ function selectedFromRoute(): string | undefined {
77
+ const id = route.query[CHANGE_SET_QUERY_KEY];
78
+ return typeof id === "string" && id ? id : undefined;
73
79
  }
74
80
 
81
+ /** The set picked here or named by the URL (`?set=`); the first one until then. */
82
+ const pickedId = ref<string | undefined>(selectedFromRoute());
83
+ const selectedId = computed(() => pickedId.value ?? changeSets.value[0]?.id);
84
+
85
+ const SCOPE_ITEMS = computed(() => [
86
+ { value: "all", label: t("dms_ai.views.changes.filter_all") },
87
+ { value: "safe", label: t("dms_ai.views.changes.filter_safe") },
88
+ { value: "vibe", label: t("dms_ai.views.changes.filter_code") },
89
+ ]);
90
+
75
91
  function reload(): Promise<void> {
76
- return loadPage(0);
92
+ return list.refresh();
77
93
  }
78
94
 
95
+ /** Appends the next page; a failure keeps the sets listed and the button. */
79
96
  function loadMore(): Promise<void> {
80
- return loadPage(changeSets.value.length);
97
+ return list.loadMore();
81
98
  }
82
99
 
83
- onMounted(() => {
84
- void status.run(() => $authFetch<AssistantStatus>(AI_ROUTES.status));
85
- void reload();
100
+ watch(selectedFromRoute, (id) => {
101
+ if (id) pickedId.value = id;
86
102
  });
87
103
 
88
- watch(scope, reload);
89
-
90
- watch(
91
- () => route.query[CHANGE_SET_QUERY_KEY],
92
- (id) => {
93
- if (id) selectedId.value = id;
94
- },
95
- );
96
-
97
104
  function select(id: string | number | undefined): void {
98
105
  if (id === undefined) return;
99
- selectedId.value = String(id);
100
- writeSelectedSet(selectedId.value);
106
+ pickedId.value = String(id);
107
+ writeSelectedSet(pickedId.value);
101
108
  }
102
109
 
103
110
  function sublabel(set: ChangeSetSummary): string {
@@ -134,9 +141,10 @@ const isCheckpointOff = computed(
134
141
  );
135
142
  const isFirstLoad = computed(
136
143
  () =>
137
- (!hasLoaded.value || list.isLoading.value) && changeSets.value.length === 0,
144
+ changeSets.value.length === 0 &&
145
+ (list.pending.value || list.status.value === "idle"),
138
146
  );
139
- const hasMore = computed(() => changeSets.value.length < total.value);
147
+ const hasMore = computed(() => list.hasMore.value);
140
148
  const listLabel = computed(() =>
141
149
  t("dms_ai.views.changes.list_label", { count: total.value }),
142
150
  );
@@ -230,7 +238,6 @@ const emptyActions = computed(() =>
230
238
  v-if="selectedId"
231
239
  :key="selectedId"
232
240
  :change-set-id="selectedId"
233
- @changed="reload"
234
241
  />
235
242
  </DmsMasterDetail>
236
243
  <div v-if="hasMore" class="flex lg:w-[380px] lg:justify-center">
@@ -239,7 +246,7 @@ const emptyActions = computed(() =>
239
246
  variant="outline"
240
247
  size="sm"
241
248
  icon="i-ph-caret-down"
242
- :loading="list.isLoading.value"
249
+ :loading="list.loadingMore.value"
243
250
  :label="t('dms_ai.views.common.load_more')"
244
251
  @click="loadMore"
245
252
  />
@@ -70,6 +70,7 @@ import { runWhenLoggedIn } from "../runtime/session-gate";
70
70
  import { installToggleShortcut } from "../runtime/shortcuts";
71
71
  import {
72
72
  createSidecarStatusController,
73
+ type SidecarInfoPayload,
73
74
  type SidecarStatus,
74
75
  type SidecarStatusController,
75
76
  } from "../runtime/sidecar-status";
@@ -395,8 +396,13 @@ function createRuntime(
395
396
  const holds = createStreamHolds();
396
397
  const sidecarStatus = followSidecarStatus(controller, client);
397
398
  const activity = createAssistantActivity({
398
- fetchStatus: () =>
399
- deps.$authFetch(STATUS_PATH) as Promise<AssistantStatusPayload>,
399
+ // The application's read: polled for as long as the app runs, while
400
+ // the interval says so.
401
+ readStatus: (interval) =>
402
+ useApiData<AssistantStatusPayload>(STATUS_PATH, {
403
+ refreshInterval: interval,
404
+ refreshOnPageRefresh: false,
405
+ }),
400
406
  chat: chat.transport,
401
407
  shouldPoll: () =>
402
408
  !panel.isOpen.value ||
@@ -413,7 +419,6 @@ function createRuntime(
413
419
  activity,
414
420
  currentPage,
415
421
  });
416
- void activity.refresh();
417
422
  const stops = [
418
423
  installToggleShortcut({ onToggle: panel.toggle }),
419
424
  followPanel({ panel, activity, holds }, client),
@@ -462,8 +467,13 @@ async function startAssistant(
462
467
  devReload: useDevReload(),
463
468
  router: useDmsRouter(),
464
469
  };
465
- const controller = createSidecarStatusController(() =>
466
- deps.$authFetch(SIDECAR_INFO_PATH),
470
+ // Read once the gate opens, then polled while the sidecar comes back.
471
+ const controller = createSidecarStatusController((interval) =>
472
+ useApiData<SidecarInfoPayload>(SIDECAR_INFO_PATH, {
473
+ immediate: false,
474
+ refreshInterval: interval,
475
+ refreshOnPageRefresh: false,
476
+ }),
467
477
  );
468
478
  if (!(await controller.init())) {
469
479
  context.runWithContext(() => unregisterSidePanel(SIDE_PANEL_ID));
@@ -496,7 +506,7 @@ export default defineDmsPlugin((context) => {
496
506
  const appMounted = new Promise<void>((resolve) =>
497
507
  context.hook("app:mounted", resolve),
498
508
  );
499
- const { loggedIn } = useUserSession();
509
+ const { loggedIn } = useDmsUserSession();
500
510
  const stopGate = runWhenLoggedIn(
501
511
  () => loggedIn.value,
502
512
  () =>
@@ -1,4 +1,4 @@
1
- import { computed, type ComputedRef, type Ref, ref } from "vue";
1
+ import { computed, type ComputedRef, type Ref, ref, watch } from "vue";
2
2
  import type { ChatTransport } from "./chat-transport";
3
3
  import { STATUS_POLL_MS } from "./constants";
4
4
  import { isTypedMessage } from "./typed-message";
@@ -35,10 +35,24 @@ export interface AssistantActivity {
35
35
  stop: () => void;
36
36
  }
37
37
 
38
+ /** The read of the backend's status: `useApiData`, polled every `interval`. */
39
+ export interface AssistantStatusRead {
40
+ data: Readonly<Ref<AssistantStatusPayload | null>>;
41
+ refresh: () => Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Opens the status read, polled every `interval` ms while it is above 0 and
46
+ * not polled while it is 0.
47
+ */
48
+ export type OpenAssistantStatusRead = (
49
+ interval: () => number,
50
+ ) => AssistantStatusRead;
51
+
38
52
  export interface AssistantActivityOptions {
39
- fetchStatus: () => Promise<AssistantStatusPayload>;
53
+ readStatus: OpenAssistantStatusRead;
40
54
  chat: ChatTransport;
41
- /** Whether polling is useful now: the tab shows and the panel is closed. */
55
+ /** Whether polling is useful now: the panel is closed, or its stream is down. */
42
56
  shouldPoll: () => boolean;
43
57
  }
44
58
 
@@ -55,8 +69,9 @@ function sum(
55
69
 
56
70
  /**
57
71
  * What the tab knows of the assistant's work across chats, from the backend's
58
- * status (polled while the panel is closed) and from the conversation lists
59
- * the sidecar broadcasts while the stream is open.
72
+ * status (polled while the panel is closed or its stream down: the polling
73
+ * follows `shouldPoll`) and from the conversation lists the sidecar
74
+ * broadcasts while the stream is open.
60
75
  */
61
76
  export function createAssistantActivity(
62
77
  options: AssistantActivityOptions,
@@ -65,16 +80,21 @@ export function createAssistantActivity(
65
80
  const workingConversations = ref(0);
66
81
  const lastError = ref<string | null>(null);
67
82
 
68
- const refresh = async (): Promise<void> => {
69
- try {
70
- const status = await options.fetchStatus();
71
- pendingApprovals.value = status.pendingApprovals ?? 0;
72
- workingConversations.value = status.workingConversations ?? 0;
73
- lastError.value = status.lastError ?? null;
74
- } catch {
75
- return;
76
- }
77
- };
83
+ const isStopped = ref(false);
84
+ const status = options.readStatus(() =>
85
+ !isStopped.value && options.shouldPoll() ? STATUS_POLL_MS : 0,
86
+ );
87
+ // A failed read keeps the last answer: the counts stay as they were.
88
+ const stopFollowing = watch(
89
+ status.data,
90
+ (payload) => {
91
+ if (!payload) return;
92
+ pendingApprovals.value = payload.pendingApprovals ?? 0;
93
+ workingConversations.value = payload.workingConversations ?? 0;
94
+ lastError.value = payload.lastError ?? null;
95
+ },
96
+ { immediate: true },
97
+ );
78
98
 
79
99
  const stopListening = options.chat.onMessage((msg) => {
80
100
  if (!isListFrame(msg)) return;
@@ -85,10 +105,6 @@ export function createAssistantActivity(
85
105
  );
86
106
  });
87
107
 
88
- const timer = setInterval(() => {
89
- if (options.shouldPoll()) void refresh();
90
- }, STATUS_POLL_MS);
91
-
92
108
  return {
93
109
  pendingApprovals,
94
110
  workingConversations,
@@ -96,9 +112,10 @@ export function createAssistantActivity(
96
112
  () => pendingApprovals.value > 0 || workingConversations.value > 0,
97
113
  ),
98
114
  lastError,
99
- refresh,
115
+ refresh: () => status.refresh(),
100
116
  stop: () => {
101
- clearInterval(timer);
117
+ isStopped.value = true;
118
+ stopFollowing();
102
119
  stopListening();
103
120
  },
104
121
  };
@@ -1,3 +1,4 @@
1
+ import { type Ref, readonly, ref, watch } from "vue";
1
2
  import { createBackoff, nextDelay, resetBackoff } from "./backoff";
2
3
  import {
3
4
  SIDECAR_POLL_DELAYS_MS,
@@ -38,31 +39,52 @@ export interface SidecarStatusController {
38
39
  dispose: () => void;
39
40
  }
40
41
 
41
- export type FetchSidecarInfo = () => Promise<Partial<SidecarInfo>>;
42
-
43
- async function fetchInfo(
44
- fetchDetails: FetchSidecarInfo,
45
- ): Promise<SidecarInfo | null> {
46
- try {
47
- const body = await fetchDetails();
48
- return {
49
- isRunning: body.isRunning === true,
50
- hasGivenUp: body.hasGivenUp === true,
51
- disabled: body.disabled === true,
52
- };
53
- } catch {
54
- return null;
55
- }
42
+ /**
43
+ * The read of the sidecar's info the controller follows: `useApiData` in the
44
+ * dashboard, opened with `immediate: false` and polled every `interval`.
45
+ */
46
+ /** The backend's sidecar info, as far as the tab reads it. */
47
+ export type SidecarInfoPayload = Partial<SidecarInfo>;
48
+
49
+ export interface SidecarInfoRead {
50
+ data: Readonly<Ref<SidecarInfoPayload | null>>;
51
+ error: Readonly<Ref<unknown>>;
52
+ pending: Readonly<Ref<boolean>>;
53
+ /** The first read. */
54
+ execute: () => Promise<void>;
55
+ /** Reads the info again now. */
56
+ refresh: () => Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * Opens the info read, polled every `interval` ms while it is above 0: the
61
+ * controller raises it while the sidecar comes back, and sets it to 0 once
62
+ * the sidecar answers or gives up.
63
+ */
64
+ export type OpenSidecarInfoRead = (
65
+ interval: Readonly<Ref<number>>,
66
+ ) => SidecarInfoRead;
67
+
68
+ /** The info of the read's last answer, or null when it failed. */
69
+ function readInfo(read: SidecarInfoRead): SidecarInfo | null {
70
+ const body = read.data.value;
71
+ if (read.error.value || !body) return null;
72
+ return {
73
+ isRunning: body.isRunning === true,
74
+ hasGivenUp: body.hasGivenUp === true,
75
+ disabled: body.disabled === true,
76
+ };
56
77
  }
57
78
 
58
79
  export function createSidecarStatusController(
59
- fetchDetails: FetchSidecarInfo,
80
+ openRead: OpenSidecarInfoRead,
60
81
  ): SidecarStatusController {
61
82
  const backoff = createBackoff();
62
83
  const subscribers = new Set<(status: SidecarStatus) => void>();
84
+ const pollInterval = ref(0);
63
85
  let status: SidecarStatus = SIDECAR_STATUS_CONNECTING;
64
- let pollTimer: ReturnType<typeof setTimeout> | null = null;
65
86
  let disposed = false;
87
+ const read = openRead(readonly(pollInterval));
66
88
 
67
89
  function notify(): void {
68
90
  for (const cb of subscribers) cb(status);
@@ -74,18 +96,8 @@ export function createSidecarStatusController(
74
96
  notify();
75
97
  }
76
98
 
77
- function schedulePoll(): void {
78
- if (disposed || pollTimer !== null) return;
79
- const delay = nextDelay(backoff, SIDECAR_POLL_DELAYS_MS);
80
- pollTimer = setTimeout(() => {
81
- pollTimer = null;
82
- void poll();
83
- }, delay);
84
- }
85
-
86
- async function poll(): Promise<void> {
87
- if (disposed) return;
88
- handleInfo(await fetchInfo(fetchDetails));
99
+ function keepPolling(): void {
100
+ pollInterval.value = nextDelay(backoff, SIDECAR_POLL_DELAYS_MS);
89
101
  }
90
102
 
91
103
  function handleInfo(info: SidecarInfo | null): void {
@@ -93,31 +105,56 @@ export function createSidecarStatusController(
93
105
  // Probe itself failed (backend momentarily unreachable): keep waiting.
94
106
  if (info === null) {
95
107
  setStatus(SIDECAR_STATUS_REVIVING);
96
- schedulePoll();
108
+ keepPolling();
97
109
  return;
98
110
  }
99
111
  if (info.hasGivenUp) {
112
+ pollInterval.value = 0;
100
113
  setStatus(SIDECAR_STATUS_UNAVAILABLE);
101
114
  return;
102
115
  }
103
116
  if (info.isRunning) {
104
117
  resetBackoff(backoff);
118
+ pollInterval.value = 0;
105
119
  setStatus(SIDECAR_STATUS_CONNECTED);
106
120
  return;
107
121
  }
108
122
  // Down but not terminal — the probe already asked the backend to respawn;
109
123
  // keep polling until it reports the sidecar running.
110
124
  setStatus(SIDECAR_STATUS_REVIVING);
111
- schedulePoll();
125
+ keepPolling();
126
+ }
127
+
128
+ // Each answer, a probe's or the polling's, moves the status.
129
+ const stopFollowing = watch(
130
+ read.pending,
131
+ (isPending, wasPending) => {
132
+ if (wasPending && !isPending) handleInfo(readInfo(read));
133
+ },
134
+ { flush: "sync" },
135
+ );
136
+
137
+ function dispose(): void {
138
+ disposed = true;
139
+ pollInterval.value = 0;
140
+ stopFollowing();
141
+ subscribers.clear();
142
+ }
143
+
144
+ function probeAgain(): void {
145
+ if (disposed) return;
146
+ resetBackoff(backoff);
147
+ setStatus(SIDECAR_STATUS_REVIVING);
148
+ void read.refresh();
112
149
  }
113
150
 
114
151
  return {
115
152
  init: async (): Promise<boolean> => {
116
- const info = await fetchInfo(fetchDetails);
117
- if (info === null) return false;
118
- if (info.disabled) return false;
119
- handleInfo(info);
120
- return true;
153
+ await read.execute();
154
+ const info = readInfo(read);
155
+ if (info !== null && !info.disabled) return true;
156
+ dispose();
157
+ return false;
121
158
  },
122
159
  getStatus: () => status,
123
160
  subscribe: (cb) => {
@@ -128,29 +165,14 @@ export function createSidecarStatusController(
128
165
  };
129
166
  },
130
167
  reportChannelDown: () => {
131
- if (disposed) return;
132
168
  if (status !== SIDECAR_STATUS_CONNECTED) return;
133
169
  // Surface the drop right away (amber dot + spinner) instead of waiting on
134
170
  // the re-probe: that probe awaits a respawn and can take a second or two,
135
- // during which the icon would otherwise look healthy. poll() flips back
136
- // to connected once the sidecar answers again.
137
- resetBackoff(backoff);
138
- setStatus(SIDECAR_STATUS_REVIVING);
139
- void poll();
140
- },
141
- retry: () => {
142
- if (disposed) return;
143
- resetBackoff(backoff);
144
- setStatus(SIDECAR_STATUS_REVIVING);
145
- void poll();
146
- },
147
- dispose: () => {
148
- disposed = true;
149
- if (pollTimer !== null) {
150
- clearTimeout(pollTimer);
151
- pollTimer = null;
152
- }
153
- subscribers.clear();
171
+ // during which the icon would otherwise look healthy. The answer flips
172
+ // back to connected once the sidecar answers again.
173
+ probeAgain();
154
174
  },
175
+ retry: probeAgain,
176
+ dispose,
155
177
  };
156
178
  }