@streamoid/agent 0.6.53 → 0.6.54

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/README.md CHANGED
@@ -63,3 +63,127 @@ back to `meta.outcome_summary`, then `meta.expected_output` (which stays the
63
63
  agent's spec), optionally followed by authored `meta.ceiling_minutes`
64
64
  and `meta.estimated_credits`. Unknown estimates are omitted. Scoped suggestions
65
65
  use `deliverable` and `ceilingMinutes` from the host's outcome catalogue.
66
+
67
+
68
+ ## The Ask CXO side pane — `@streamoid/agent/pane`
69
+
70
+ Every product app shows the chat the same way: rail | **pane** | page. The
71
+ shell for it ships here, so the apps stop hand-building it around
72
+ `AgentPanel` (they had drifted on width, unmount-on-close, workspace switch,
73
+ Escape, mobile and persistence).
74
+
75
+ `@streamoid/agent/pane` is a **light entry** (~16kB, React and
76
+ `@streamoid/ui` are yours). It never imports the 5MB chat statically — the
77
+ chat is `import()`ed when the pane first opens (or on `prefetch`), so import
78
+ the pane eagerly in your shell.
79
+
80
+ ```tsx
81
+ import { useMemo } from "react";
82
+ import { AgentSidePane, useAgentPaneController, type AgentSidePaneAdapters } from "@streamoid/agent/pane";
83
+
84
+ export function Shell() {
85
+ const pane = useAgentPaneController({
86
+ workspaceId, // persisted per workspace
87
+ legacyWidthKey: "artifax.assistant.width", // your old width key, migrated once
88
+ // persistKey: "artifax-assistant-session", // keep an existing session key
89
+ // bindShortcut: false, // if ⌘K is bound elsewhere
90
+ });
91
+
92
+ const adapters = useMemo<AgentSidePaneAdapters>(() => ({
93
+ auth, workspace: { current: { id: workspaceId, name } }, theme: { mode },
94
+ config: { apiBase, hide: { sidepane: true, newBrief: true, notifications: true }, widgetMode: "inline_ask" },
95
+ }), [/* … */]); // memoise it
96
+
97
+ return (
98
+ <div style={{ display: "flex", height: "100vh" }}>
99
+ <Rail
100
+ askActive={pane.open} // the trigger's `active`
101
+ onAsk={pane.askCxo} // open + focus the composer (never a toggle)
102
+ onAskIntent={pane.prefetch} // hover / focus
103
+ onHelp={() => pane.requestSupport({ currentPage })}
104
+ />
105
+ <AgentSidePane controller={pane} adapters={adapters}
106
+ hostContext={hostContext} scope={scope} support={{ appVersion }} canvas={canvas} />
107
+ {/* An artefact takes the page: hide main (display:none, not unmount). */}
108
+ <main style={{ flex: 1, minWidth: 0, display: pane.surface === "fullscreen" ? "none" : undefined }}>
109
+ <Outlet context={{ askAbout: pane.askAbout }} />
110
+ </main>
111
+ </div>
112
+ );
113
+ }
114
+ ```
115
+
116
+ What the pane does, identically in every app:
117
+
118
+ - **Geometry.** Docked at ≥768px: in flow beside the page, width
119
+ `SC_ASSISTANT_PANEL_WIDTH` (**510px**, from `@streamoid/ui`) within
120
+ `SC_ASSISTANT_PANEL_BOUNDS` (360–900), resizable with `ScPanelResizeHandle`,
121
+ persisted under `streamoid.askPane.width`. Below 768px: a full-screen sheet
122
+ (`role="dialog"`, focus trap, siblings `inert`, focus restored on close).
123
+ Geometry is inline style, so no utility class from either stylesheet can
124
+ move it.
125
+ - **Fullscreen.** `onSurfaceChange` reporting a non-text artefact widens the
126
+ pane over the page (`controller.surface === "fullscreen"` — hide your
127
+ `main`). Escape returns to the side pane and that artefact is not widened
128
+ again while it stays open (also across a close and reopen); a different one
129
+ still is. A pane closed over a live artefact — or one an artefact opened in
130
+ while closed — reopens fullscreen. No expand button.
131
+ - **Stays mounted** after the first open; closing hides it, so an answer still
132
+ streaming keeps going. Before the first open nothing loads.
133
+ - **Persistence.** Open state and the conversation, per workspace, survive a
134
+ reload (fullscreen comes back as the side pane). A workspace change closes
135
+ the pane, drops the old conversation, **remounts the chat** (no draft,
136
+ upload or selection chip carries over) and loads the new workspace's.
137
+ - **Escape** (bubble phase): docked, it closes only when focus is inside the
138
+ pane, no nested dialog holds the key, and nothing `preventDefault`ed it.
139
+ Fullscreen collapses to the side pane first. On the sheet, focus need not be
140
+ inside (the page is inert).
141
+ - **Back button** (phone): opening the sheet pushes one history entry (same
142
+ URL, your router's state kept); Back closes the sheet. Closing with × or
143
+ Escape removes the entry, so no stray Back is left behind.
144
+ - **Errors.** A pane-local error boundary: "Ask CXO couldn’t load" + Retry
145
+ (re-imports). Loading shows "Loading Ask CXO…".
146
+ - **Header** is the chat's own (title + context, New task, History, ×);
147
+ × calls `controller.close`.
148
+
149
+ Controller calls: `reveal()` / `close()` / `toggle()`, `askCxo()`,
150
+ `askAbout(prompt)` (new conversation + send, via `enqueueChatPrompt`),
151
+ `requestSupport({ currentPage }?)` (Get help home via `showAgentSupport`),
152
+ `prefetch()`, `width` / `setWidth` / `commitWidth`, and
153
+ `selectionSnapshot` — the selection the pane **holds** (its snapshot of
154
+ `scope.selection`: taken on open, on New task and on `askCxo` with no chat;
155
+ cleared when a chat opens or the chip is removed). Count or split suggestions
156
+ on that, with the pane's own rule: `paneSuggestionCount(sections,
157
+ pane.selectionSnapshot)` / `deriveScopeSuggestions(...)`, both exported here.
158
+ A host mounting `AgentPanel` directly gets the same value from
159
+ `adapters.onSelectionSnapshot`.
160
+
161
+ **Stylesheet.** Import the chat's CSS into its own cascade layer so its
162
+ compiled utilities cannot beat yours (the Catalogix / Photogenix pattern):
163
+
164
+ ```css
165
+ @import "@streamoid/agent/style.css" layer(streamoid-agent);
166
+ ```
167
+
168
+ ### Migrating each app (do it in the app's `@streamoid/agent` 0.6.54 bump PR)
169
+
170
+ **Artifax** (`apps/artifax/src/components/{AppShell,AssistantPanel}.tsx`)
171
+ - `useAgentPaneController({ workspaceId: workspaceID, persistKey: "artifax-assistant-session", legacyWidthKey: "artifax.assistant.width" })` in `AppShell`; render `<AgentSidePane>` in place of `AssistantPanel`, keeping its adapters/hostContext/scope.
172
+ - Delete `lib/assistantSurface.ts` (reducer + tests) and `lib/assistantSession.ts`, the shell's Escape effect, `prefetchAgentChunk`, `askCxo`, `requestSupport`, the `useStreamoidSearchShortcut` call. `main` hides on `pane.surface === "fullscreen"` (as now). `onAskCxoRequest(pane.askCxo)` stays.
173
+ - Delete `usePaneSelectionSnapshot` / `noteAssistantComposerFocus` (`lib/assistantScope.ts`) and the test copy `src/test/paneSuggestions.ts`; use `pane.selectionSnapshot` and `paneSuggestionCount` / `deriveScopeSuggestions`.
174
+ - Default width stays 510; a stored width migrates.
175
+
176
+ **Tactix** (`apps/tactix/src/components/{TactixShell,AssistantPanel}.tsx`)
177
+ - `useAgentPaneController({ workspaceId, legacyWidthKey: "tactix.assistant.width" })` in `TactixShell`; `<AgentSidePane>` replaces `AssistantPanel`. Outlet context `askCxoAbout` becomes `pane.askAbout`.
178
+ - Delete the shell's `askCxo` / `askCxoAbout` / `requestSupport` / `prefetchAgent` and the panel's unguarded Escape effect. Hide `main` on fullscreen (new: artefacts now widen). Gains: mobile sheet, persistence, workspace reset.
179
+ - Default width 480 → 510 (DS constant) for users who never resized.
180
+
181
+ **Catalogix** (`dashboard/app/containers/AskCxo/`)
182
+ - `useAgentPaneController({ workspaceId, legacyWidthKey: "catalogix.ask.width" })` where `useAppStore`'s ask slice is read; `<AgentSidePane>` replaces `AskCxo/index.jsx` + `AskCxoPanel.jsx`. The rail's trigger/⌘K/Help call the controller (pass `bindShortcut: false` if ⌘K stays bound in `LeftMenu`).
183
+ - Delete `askOpen` / `askChatId` from `useAppStore` (and the workspace-switch reset on its setter — the controller does it), `askCxo.js`'s `importAgent` / `prefetchAgent` / `askCxo`. Hide the page on fullscreen (new). Gains: persistence, Back button.
184
+ - Default width 480 → 510.
185
+
186
+ **Photogenix** (`dashboard/client/src/features/ask/`)
187
+ - `useAgentPaneController({ workspaceId, legacyWidthKey: "photogenix.ask.width" })` in the shell; `<AgentSidePane>` replaces `AskPane.tsx` + `AskCxoPanel.tsx`; `useAskConversation.ts` goes (the controller owns `chatId`).
188
+ - Delete the pane's own sheet/focus-trap/Escape code. Hide the page on fullscreen (new). Gains: stays mounted (no SSE abort on close), persistence, Back button.
189
+ - Default width 480 → 510.
@@ -0,0 +1,114 @@
1
+ import { resolveStreamoidProduct as b, streamoidProductLabel as k } from "@streamoid/ui";
2
+ function a(e) {
3
+ if (typeof e != "string" || !e.trim()) return null;
4
+ const n = b(e.trim());
5
+ return n && n !== "cxo" ? n : null;
6
+ }
7
+ function d(e) {
8
+ return e && k(e) || "All apps";
9
+ }
10
+ function $(e, n) {
11
+ if (e === null || e !== a(n?.app)) return null;
12
+ const i = (Array.isArray(n?.breadcrumbs) ? n.breadcrumbs.filter((t) => typeof t == "string" && !!t.trim()).map((t) => t.trim()) : []).join(" › ") || (typeof n?.page == "string" ? n.page.trim() : "");
13
+ return i ? `${d(e)} · ${i}` : d(e);
14
+ }
15
+ function p(e, n) {
16
+ return !e.kinds || e.kinds.length === 0 || n === void 0 ? !0 : e.kinds.map((r) => r.toLowerCase()).includes(n.toLowerCase());
17
+ }
18
+ function h(e) {
19
+ return e.charAt(0).toUpperCase() + e.slice(1);
20
+ }
21
+ function m(e, n) {
22
+ const r = e.instruction?.includes("{subject}") ? e.instruction : `${e.label}: {subject}`;
23
+ return h(r.replace(/\{subject\}/g, n).trim());
24
+ }
25
+ function w(e, n) {
26
+ const r = [];
27
+ for (const i of e ?? []) {
28
+ if (!i?.outcomes?.length) continue;
29
+ const t = (i.samples ?? []).find(
30
+ (u) => i.outcomes.some((f) => p(f, u.kind))
31
+ ), c = (u, f) => i.outcomes.find((l) => l.key === f && p(l, u)) ?? i.outcomes.find((l) => p(l, u)), s = n ? c(n.kind, t?.primaryOutcomeKey) : void 0;
32
+ if (n && s) {
33
+ r.push({
34
+ id: `${i.key}:${s.key}:selection`,
35
+ sectionKey: i.key,
36
+ outcomeKey: s.key,
37
+ subject: "selection",
38
+ text: m(s, n.subject || n.label),
39
+ ceilingMinutes: s.ceilingMinutes,
40
+ deliverable: s.deliverable
41
+ });
42
+ continue;
43
+ }
44
+ if (!t) continue;
45
+ const o = c(t.kind, t.primaryOutcomeKey);
46
+ o && r.push({
47
+ id: `${i.key}:${o.key}:${t.id}`,
48
+ sectionKey: i.key,
49
+ outcomeKey: o.key,
50
+ sampleId: t.id,
51
+ sampleTitle: t.subject || t.title,
52
+ subject: "sample",
53
+ text: m(o, t.subject || t.title),
54
+ ceilingMinutes: o.ceilingMinutes,
55
+ deliverable: o.deliverable,
56
+ imageUrl: t.imageUrl ?? null
57
+ });
58
+ }
59
+ return r;
60
+ }
61
+ function O(e, n) {
62
+ return n ? e.length === 0 ? !1 : e.every((r) => a(r) === n) : !0;
63
+ }
64
+ const v = 3;
65
+ function A(e, n, r = v) {
66
+ const i = (s) => (s.value ?? s.title).trim().toLowerCase(), t = e.slice(0, r), c = new Set(t.map(i));
67
+ for (const s of n) {
68
+ if (t.length >= r) break;
69
+ const o = i(s);
70
+ c.has(o) || (c.add(o), t.push(s));
71
+ }
72
+ return t;
73
+ }
74
+ function S(e) {
75
+ return ["headline", "subline", "placeholder"].filter(
76
+ (n) => !e?.[n]?.trim()
77
+ );
78
+ }
79
+ let g = !1;
80
+ function C() {
81
+ try {
82
+ return process.env.NODE_ENV === "development";
83
+ } catch {
84
+ return !1;
85
+ }
86
+ }
87
+ function L(e, n, r = C()) {
88
+ if (!r || g || !e) return !1;
89
+ const i = S(n);
90
+ return i.length === 0 ? !1 : (g = !0, console.warn(
91
+ `[@streamoid/agent] adapters.scope for "${e}" has no ${i.join(", ")}; the Ask CXO panel falls back to generic copy. Pass an app-specific question (headline), helper line (subline) and composer hint (placeholder).`
92
+ ), !0);
93
+ }
94
+ function y(e) {
95
+ return a(e.meta?.origin_app);
96
+ }
97
+ function K(e) {
98
+ const n = e.meta?.scope;
99
+ return n === "all" ? null : a(n) ?? y(e);
100
+ }
101
+ function M(e, n) {
102
+ return n ? e.filter((r) => y(r) === n) : [...e];
103
+ }
104
+ export {
105
+ M as a,
106
+ O as b,
107
+ K as c,
108
+ w as d,
109
+ $ as e,
110
+ A as f,
111
+ a as h,
112
+ d as s,
113
+ L as w
114
+ };
package/dist/index.d.ts CHANGED
@@ -164,6 +164,19 @@ export interface AgentChatShellAdapters {
164
164
  * Fires only on change, and only for the conversation on screen.
165
165
  */
166
166
  onSurfaceChange?: (state: AgentSurfaceState) => void;
167
+ /**
168
+ * The selection the pane HOLDS — its snapshot of `scope.selection`, which its
169
+ * suggestions and composer chip are built from — reported whenever it
170
+ * changes, and once on mount.
171
+ *
172
+ * The pane does not follow `scope.selection` live: it snapshots it when it
173
+ * mounts, when the conversation goes back to none (New task), and on
174
+ * `focusAgentComposer()` while no conversation is open; it clears it when an
175
+ * existing conversation opens or the user removes the chip. A host that
176
+ * splits or counts its suggestions on the selection must use THIS value, not
177
+ * its live one. `AgentSidePane` wires it to `controller.selectionSnapshot`.
178
+ */
179
+ onSelectionSnapshot?: (selection: AgentScopeSelection | null) => void;
167
180
  /**
168
181
  * Supply the canvas as a component and the Dreamer panel drops its iframe:
169
182
  * no embedded page, so no cross-origin auth, no postMessage handshake, no
@@ -545,3 +558,123 @@ export interface ShowAgentSupportOptions {
545
558
  */
546
559
  currentPage?: string | null;
547
560
  }
561
+
562
+ /* ── Ask CXO side pane (`@streamoid/agent/pane`) ───────────────────────────
563
+ *
564
+ * The TYPES of the shared side pane live here, beside the adapters they are
565
+ * built from; its VALUES (`useAgentPaneController`, `AgentSidePane`, …) are
566
+ * declared in `pane.d.ts` and exported only from the `@streamoid/agent/pane`
567
+ * subpath — the light entry that does not pull in this 5MB bundle. Keep both
568
+ * in step with `src/embed/pane/`. */
569
+
570
+ /** `closed` → `pane` (the side pane) → `fullscreen` (a non-text artefact took
571
+ * the page). Fullscreen is never restored on reload. */
572
+ export type AgentPaneSurface = 'closed' | 'pane' | 'fullscreen';
573
+
574
+ export interface AgentPaneControllerOptions {
575
+ /** The workspace the pane's conversation belongs to. The open state and the
576
+ * conversation are persisted per workspace; a change closes the pane, drops
577
+ * the old workspace's conversation and loads the new one's saved state. An
578
+ * empty value (not resolved yet) persists nothing. */
579
+ workspaceId: string | null | undefined;
580
+ /** localStorage prefix for the per-workspace session
581
+ * (`<persistKey>:<workspaceId>`). Default `streamoid.askPane.session`.
582
+ * Artifax passes `artifax-assistant-session` to keep its users' open chats
583
+ * across the migration — its stored `{ surface: 'rail', chatId }` is read. */
584
+ persistKey?: string;
585
+ /** The app's old width key(s), copied once into `streamoid.askPane.width`
586
+ * while that key is still empty: `artifax.assistant.width`,
587
+ * `tactix.assistant.width`, `catalogix.ask.width`, `photogenix.ask.width`. */
588
+ legacyWidthKey?: string | readonly string[];
589
+ /** Bind ⌘K / Ctrl+K to `askCxo` (with a prefetch). Default `true`; pass
590
+ * `false` where the host binds the chord to something else. */
591
+ bindShortcut?: boolean;
592
+ }
593
+
594
+ /** Options for {@link AgentPaneController.requestSupport} — exactly
595
+ * {@link showAgentSupport}'s, passed straight through. */
596
+ export type AgentPaneSupportOptions = ShowAgentSupportOptions;
597
+
598
+ export interface AgentPaneController {
599
+ /** The pane is showing (side pane or fullscreen). Hand it to the rail's Ask
600
+ * CXO trigger as `active`. */
601
+ open: boolean;
602
+ /** `fullscreen` while a non-text artefact holds the page. A host whose page
603
+ * sits beside the pane HIDES it (`display: none`, not unmount — the page
604
+ * keeps its scroll and form state) for exactly as long:
605
+ * `<main style={{ display: controller.surface === 'fullscreen' ? 'none' : undefined }}>`. */
606
+ surface: AgentPaneSurface;
607
+ /** The conversation on screen; `null` lets the chat create one. Persisted
608
+ * per workspace with the open state. */
609
+ chatId: string | null;
610
+ /** Show the pane. Open, never toggle. Applies the chat's latest surface
611
+ * report: an artefact still live behind a closed pane reopens fullscreen,
612
+ * unless the user declined it with Escape. */
613
+ reveal(): void;
614
+ /** Hide the pane. It stays mounted: an answer still streaming keeps going,
615
+ * and an artefact stays live (reports arriving while closed are kept). */
616
+ close(): void;
617
+ toggle(): void;
618
+ /** The rail trigger / ⌘K: reveal and put the caret in the composer. */
619
+ askCxo(): void;
620
+ /** Reveal on a NEW conversation and send `prompt` in it, without waiting for
621
+ * the network (`enqueueChatPrompt`). `false` when the prompt is blank. */
622
+ askAbout(prompt: string): boolean;
623
+ /** Help & Support: reveal on the Get help home ({@link showAgentSupport});
624
+ * sends nothing. */
625
+ requestSupport(options?: AgentPaneSupportOptions | null): void;
626
+ /** Warm the agent chunk — wire to the trigger's hover and focus. */
627
+ prefetch(): void;
628
+ /** Docked width in px, from `SC_ASSISTANT_PANEL_WIDTH` within
629
+ * `SC_ASSISTANT_PANEL_BOUNDS`, persisted under `streamoid.askPane.width`. */
630
+ width: number;
631
+ /** Live width during a drag; not persisted. */
632
+ setWidth(width: number): void;
633
+ /** Persist a width (drag end). */
634
+ commitWidth(width: number): void;
635
+ bounds: { min: number; max: number };
636
+ /** The selection the pane HOLDS (see `adapters.onSelectionSnapshot`) —
637
+ * `null` until the chat mounts. Split or count suggestions on this, e.g.
638
+ * `paneSuggestionCount(sections, controller.selectionSnapshot)`, not on the
639
+ * host's live selection. */
640
+ selectionSnapshot: AgentScopeSelection | null;
641
+ /** Changes on every real workspace switch (not when an unresolved id first
642
+ * resolves). `AgentSidePane` keys the chat on it, so a switch remounts the
643
+ * chat — workspace A's draft, uploads and selection chip never reach B —
644
+ * and `selectionSnapshot` resets in the same update. */
645
+ workspaceEpoch: number;
646
+ /** Wiring for `AgentSidePane`; hosts do not need these. */
647
+ setChatId(chatId: string | null): void;
648
+ reportSurface(state: AgentSurfaceState): void;
649
+ collapse(): void;
650
+ reportSelectionSnapshot(selection: AgentScopeSelection | null): void;
651
+ }
652
+
653
+ /** The adapters a host still supplies to {@link AgentSidePaneProps}. The pane
654
+ * fills `navigation`, `onClose`, `onSurfaceChange` and `onSelectionSnapshot`
655
+ * from the controller and takes `hostContext`, `scope`, `support` and
656
+ * `canvas` as props. */
657
+ export type AgentSidePaneAdapters = Omit<
658
+ AgentPanelAdapters,
659
+ | 'navigation'
660
+ | 'onClose'
661
+ | 'onSurfaceChange'
662
+ | 'onSelectionSnapshot'
663
+ | 'hostContext'
664
+ | 'scope'
665
+ | 'support'
666
+ | 'canvas'
667
+ >;
668
+
669
+ export interface AgentSidePaneProps {
670
+ controller: AgentPaneController;
671
+ /** Memoise it: a new object every render re-renders the whole chat. */
672
+ adapters: AgentSidePaneAdapters;
673
+ hostContext?: AgentHostContext | null;
674
+ scope?: AgentScopeAdapter | null;
675
+ support?: AgentSupportAdapter | null;
676
+ canvas?: HostCanvasContextValue;
677
+ className?: string;
678
+ /** Accessible name of the pane landmark. Default "Ask CXO assistant". */
679
+ ariaLabel?: string;
680
+ }