@jupyter-ai/persona-manager 0.1.3 → 0.2.0-a1

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.
@@ -1,26 +1,11 @@
1
1
  /**
2
- * Reading persona session information from a chat's Yjs awareness channel.
2
+ * Shared persona session data types.
3
3
  *
4
- * The persona-manager server extension broadcasts session info over awareness
5
- * instead of REST. This module gives frontends a typed, read-only view of it:
6
- *
7
- * - `PersonaManagerAwareness` reads the persona list the `PersonaManager`
8
- * publishes under a fixed, hardcoded client ID.
9
- * - `PersonaAwareness` reads one persona's slot (model configuration, settings,
10
- * usage, slash commands, writing status), keyed by that persona's Yjs client
11
- * ID (reported in the persona list).
12
- *
13
- * These mirror the Python awareness helpers of the same names. They are
14
- * read-only: user selections ride on message metadata, not awareness.
15
- */
16
- import { IAwareness } from '@jupyter/ydoc';
17
- /**
18
- * The fixed Yjs client ID the `PersonaManager` publishes its persona list under.
19
- * A chosen 53-bit constant, hardcoded on both server and client so the browser
20
- * can find the manager's slot without a discovery request. Must match
21
- * `PERSONA_MANAGER_AWARENESS_CLIENT_ID` in the persona-manager Python package.
4
+ * These mirror the Pydantic models in the persona-manager Python package and are
5
+ * the payloads carried by the `personas` / `persona_state` Jupyter Events. The
6
+ * live, per-chat state built from those events lives in
7
+ * `PersonaManagerSessionState` / `PersonaSessionState` (see `./persona-events`).
22
8
  */
23
- export declare const PERSONA_MANAGER_AWARENESS_CLIENT_ID = 7133713371337;
24
9
  /** A selectable model. Mirrors `ModelOption` in the Python package. */
25
10
  export type ModelOption = {
26
11
  id: string;
@@ -91,62 +76,8 @@ export type PersonaOption = {
91
76
  id: string;
92
77
  name: string;
93
78
  avatar_url: string | null;
94
- /** The Yjs client ID of this persona's awareness slot. */
95
- yjs_client_id: number;
79
+ /** Deprecated: legacy Yjs client id, unused now that state rides events. */
80
+ yjs_client_id?: number;
96
81
  };
97
82
  export declare const EMPTY_USAGE: Usage;
98
- /**
99
- * A typed, read-only view of the `PersonaManager`'s awareness slot: the list of
100
- * personas in the chat. Construct it with `PersonaManagerAwareness.from()`,
101
- * which resolves once the manager has published its slot.
102
- */
103
- export declare class PersonaManagerAwareness {
104
- private _awareness;
105
- private constructor();
106
- /**
107
- * Resolve once the `PersonaManager` has published its slot under the fixed
108
- * client ID, polling the awareness map until it appears (or rejecting after a
109
- * bounded wait). Pass the chat's Yjs awareness object (e.g. from the chat
110
- * model's shared model).
111
- *
112
- * Safe to `await` in any context: if the slot is already present it resolves
113
- * on the first check, effectively immediately.
114
- */
115
- static from(awareness: IAwareness): Promise<PersonaManagerAwareness>;
116
- /**
117
- * The personas available in this chat. Empty if none are published. Pass an
118
- * entry to `PersonaAwareness.from()` to read that persona's own slot.
119
- */
120
- get personas(): PersonaOption[];
121
- }
122
- /**
123
- * A typed, read-only view of one persona's awareness slot. Construct it with
124
- * `PersonaAwareness.from(awareness, personaOption)` — the `PersonaOption` comes
125
- * from `PersonaManagerAwareness.personas` and carries the persona's client ID.
126
- */
127
- export declare class PersonaAwareness {
128
- private _awareness;
129
- private _id;
130
- private _clientId;
131
- private constructor();
132
- /** Build a view of the persona named by `option` (from the persona list). */
133
- static from(awareness: IAwareness, option: PersonaOption): PersonaAwareness;
134
- /** The persona's stable ID (from the persona list). */
135
- get id(): string;
136
- private get _state();
137
- /** The persona's model configuration (current model, options, model settings). */
138
- get model(): ModelConfiguration;
139
- /** The persona's general (non-model) setting configurations. */
140
- get settings(): SettingConfiguration[];
141
- /** The token and cost usage the persona reports for the session. */
142
- get usage(): Usage;
143
- /** The slash commands the persona advertises. */
144
- get slash_commands(): CommandOption[];
145
- /**
146
- * Whether the persona is currently writing: `false` when idle, or the ID of
147
- * the message being written while streaming.
148
- */
149
- get isWriting(): boolean | string;
150
- /** Whether this persona has published its state yet. */
151
- get isReady(): boolean;
152
- }
83
+ export declare const EMPTY_MODEL_CONFIGURATION: ModelConfiguration;
package/lib/awareness.js CHANGED
@@ -1,25 +1,11 @@
1
1
  /**
2
- * Reading persona session information from a chat's Yjs awareness channel.
2
+ * Shared persona session data types.
3
3
  *
4
- * The persona-manager server extension broadcasts session info over awareness
5
- * instead of REST. This module gives frontends a typed, read-only view of it:
6
- *
7
- * - `PersonaManagerAwareness` reads the persona list the `PersonaManager`
8
- * publishes under a fixed, hardcoded client ID.
9
- * - `PersonaAwareness` reads one persona's slot (model configuration, settings,
10
- * usage, slash commands, writing status), keyed by that persona's Yjs client
11
- * ID (reported in the persona list).
12
- *
13
- * These mirror the Python awareness helpers of the same names. They are
14
- * read-only: user selections ride on message metadata, not awareness.
4
+ * These mirror the Pydantic models in the persona-manager Python package and are
5
+ * the payloads carried by the `personas` / `persona_state` Jupyter Events. The
6
+ * live, per-chat state built from those events lives in
7
+ * `PersonaManagerSessionState` / `PersonaSessionState` (see `./persona-events`).
15
8
  */
16
- /**
17
- * The fixed Yjs client ID the `PersonaManager` publishes its persona list under.
18
- * A chosen 53-bit constant, hardcoded on both server and client so the browser
19
- * can find the manager's slot without a discovery request. Must match
20
- * `PERSONA_MANAGER_AWARENESS_CLIENT_ID` in the persona-manager Python package.
21
- */
22
- export const PERSONA_MANAGER_AWARENESS_CLIENT_ID = 7133713371337;
23
9
  export const EMPTY_USAGE = {
24
10
  context_tokens: null,
25
11
  context_size: null,
@@ -33,114 +19,8 @@ export const EMPTY_USAGE = {
33
19
  cost_amount: null,
34
20
  cost_currency: null
35
21
  };
36
- // Interval and attempt cap for `PersonaManagerAwareness.from()` polling. The
37
- // manager registers a moment after a chat opens, and a persona's agent session
38
- // can take 20s+ to initialize, so poll generously before giving up.
39
- const POLL_MS = 500;
40
- const MAX_POLLS = 120;
41
- const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
42
- /**
43
- * A typed, read-only view of the `PersonaManager`'s awareness slot: the list of
44
- * personas in the chat. Construct it with `PersonaManagerAwareness.from()`,
45
- * which resolves once the manager has published its slot.
46
- */
47
- export class PersonaManagerAwareness {
48
- constructor(_awareness) {
49
- this._awareness = _awareness;
50
- }
51
- /**
52
- * Resolve once the `PersonaManager` has published its slot under the fixed
53
- * client ID, polling the awareness map until it appears (or rejecting after a
54
- * bounded wait). Pass the chat's Yjs awareness object (e.g. from the chat
55
- * model's shared model).
56
- *
57
- * Safe to `await` in any context: if the slot is already present it resolves
58
- * on the first check, effectively immediately.
59
- */
60
- static async from(awareness) {
61
- for (let i = 0; i < MAX_POLLS; i++) {
62
- if (awareness.getStates().has(PERSONA_MANAGER_AWARENESS_CLIENT_ID)) {
63
- return new PersonaManagerAwareness(awareness);
64
- }
65
- await delay(POLL_MS);
66
- }
67
- throw new Error('Timed out waiting for the PersonaManager awareness slot. Is the ' +
68
- 'jupyter-ai-persona-manager server extension installed and enabled?');
69
- }
70
- /**
71
- * The personas available in this chat. Empty if none are published. Pass an
72
- * entry to `PersonaAwareness.from()` to read that persona's own slot.
73
- */
74
- get personas() {
75
- const state = this._awareness
76
- .getStates()
77
- .get(PERSONA_MANAGER_AWARENESS_CLIENT_ID);
78
- const personas = state === null || state === void 0 ? void 0 : state.personas;
79
- return Array.isArray(personas) ? personas : [];
80
- }
81
- }
82
- /**
83
- * A typed, read-only view of one persona's awareness slot. Construct it with
84
- * `PersonaAwareness.from(awareness, personaOption)` — the `PersonaOption` comes
85
- * from `PersonaManagerAwareness.personas` and carries the persona's client ID.
86
- */
87
- export class PersonaAwareness {
88
- constructor(_awareness, _id, _clientId) {
89
- this._awareness = _awareness;
90
- this._id = _id;
91
- this._clientId = _clientId;
92
- }
93
- /** Build a view of the persona named by `option` (from the persona list). */
94
- static from(awareness, option) {
95
- return new PersonaAwareness(awareness, option.id, option.yjs_client_id);
96
- }
97
- /** The persona's stable ID (from the persona list). */
98
- get id() {
99
- return this._id;
100
- }
101
- get _state() {
102
- return this._awareness.getStates().get(this._clientId);
103
- }
104
- /** The persona's model configuration (current model, options, model settings). */
105
- get model() {
106
- var _a, _b;
107
- return ((_b = (_a = this._state) === null || _a === void 0 ? void 0 : _a.model) !== null && _b !== void 0 ? _b : {
108
- current: null,
109
- options: [],
110
- settings: []
111
- });
112
- }
113
- /** The persona's general (non-model) setting configurations. */
114
- get settings() {
115
- var _a, _b;
116
- return (_b = (_a = this._state) === null || _a === void 0 ? void 0 : _a.settings) !== null && _b !== void 0 ? _b : [];
117
- }
118
- /** The token and cost usage the persona reports for the session. */
119
- get usage() {
120
- var _a, _b;
121
- // Backfill from EMPTY_USAGE so fields a (possibly older) server never
122
- // published read as null, as the type promises, not undefined.
123
- return {
124
- ...EMPTY_USAGE,
125
- ...((_b = (_a = this._state) === null || _a === void 0 ? void 0 : _a.usage) !== null && _b !== void 0 ? _b : {})
126
- };
127
- }
128
- /** The slash commands the persona advertises. */
129
- get slash_commands() {
130
- var _a, _b;
131
- return (_b = (_a = this._state) === null || _a === void 0 ? void 0 : _a.slash_commands) !== null && _b !== void 0 ? _b : [];
132
- }
133
- /**
134
- * Whether the persona is currently writing: `false` when idle, or the ID of
135
- * the message being written while streaming.
136
- */
137
- get isWriting() {
138
- var _a, _b;
139
- return (_b = (_a = this._state) === null || _a === void 0 ? void 0 : _a.isWriting) !== null && _b !== void 0 ? _b : false;
140
- }
141
- /** Whether this persona has published its state yet. */
142
- get isReady() {
143
- var _a;
144
- return ((_a = this._state) === null || _a === void 0 ? void 0 : _a.model) !== undefined;
145
- }
146
- }
22
+ export const EMPTY_MODEL_CONFIGURATION = {
23
+ current: null,
24
+ options: [],
25
+ settings: []
26
+ };
package/lib/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
  import { IInputToolbarRegistryFactory } from '@jupyter/chat';
3
3
  import { IPersonaControlRegistry } from './persona-control-registry';
4
+ import { PersonaSessionRegistry } from './persona-events';
4
5
  export * from './awareness';
6
+ export * from './persona-events';
5
7
  export * from './persona-control-registry';
6
- declare const _default: (JupyterFrontEndPlugin<void> | JupyterFrontEndPlugin<IPersonaControlRegistry> | JupyterFrontEndPlugin<IInputToolbarRegistryFactory>)[];
8
+ declare const _default: (JupyterFrontEndPlugin<void> | JupyterFrontEndPlugin<PersonaSessionRegistry> | JupyterFrontEndPlugin<IPersonaControlRegistry> | JupyterFrontEndPlugin<IInputToolbarRegistryFactory>)[];
7
9
  export default _default;
package/lib/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { IChatCommandRegistry, IInputToolbarRegistryFactory, InputToolbarRegistry } from '@jupyter/chat';
2
+ import { IEventListener } from 'jupyterlab-eventlistener';
2
3
  import { PersonaControls } from './persona-controls';
3
4
  import { IPersonaControlRegistry, PersonaControlRegistry } from './persona-control-registry';
5
+ import { IPersonaSessionRegistry, PersonaSessionRegistry } from './persona-events';
4
6
  import { SLASH_COMMAND_PROVIDER_ID, SlashCommandProvider } from './slash-commands';
5
7
  import { StopButton } from './stop-button';
6
- // Public awareness API: typed, read-only views of the persona-manager and
7
- // persona awareness slots, for consumers building on the awareness channel.
8
+ // Public persona session data types (event payloads).
8
9
  export * from './awareness';
10
+ // Public persona session-state models + registry (fed by Jupyter Events).
11
+ export * from './persona-events';
9
12
  // Public API for contributing controls to the persona controls toolbar.
10
13
  export * from './persona-control-registry';
11
14
  /**
@@ -19,17 +22,31 @@ const plugin = {
19
22
  console.log('JupyterLab extension @jupyter-ai/persona-manager is activated!');
20
23
  }
21
24
  };
25
+ /**
26
+ * Plugin that provides the shared per-chat persona session-state registry,
27
+ * fed by Jupyter Events via `jupyterlab-eventlistener`.
28
+ */
29
+ const sessionRegistryPlugin = {
30
+ id: '@jupyter-ai/persona-manager:session-registry',
31
+ description: 'Provides the per-chat persona session-state registry (fed by Jupyter Events).',
32
+ autoStart: true,
33
+ provides: IPersonaSessionRegistry,
34
+ requires: [IEventListener],
35
+ activate: (app, eventListener) => {
36
+ return new PersonaSessionRegistry(eventListener);
37
+ }
38
+ };
22
39
  /**
23
40
  * Plugin registering slash-command completions read from the selected
24
- * persona's awareness slot.
41
+ * persona's session state (fed by Jupyter Events).
25
42
  */
26
43
  const slashCommandPlugin = {
27
44
  id: SLASH_COMMAND_PROVIDER_ID,
28
45
  description: 'Adds support for slash commands in Jupyter AI.',
29
46
  autoStart: true,
30
- requires: [IChatCommandRegistry],
31
- activate: (app, registry) => {
32
- registry.addProvider(new SlashCommandProvider());
47
+ requires: [IChatCommandRegistry, IPersonaSessionRegistry],
48
+ activate: (app, registry, sessionRegistry) => {
49
+ registry.addProvider(new SlashCommandProvider(sessionRegistry));
33
50
  }
34
51
  };
35
52
  /**
@@ -57,12 +74,11 @@ const toolbarPlugin = {
57
74
  description: 'Provides the chat input toolbar with persona controls.',
58
75
  autoStart: true,
59
76
  provides: IInputToolbarRegistryFactory,
60
- requires: [IPersonaControlRegistry],
61
- activate: (app, controlRegistry) => {
62
- // Wrap the persona controls to inject the control registry, which the
63
- // generic toolbar-item props don't carry. Contributed controls (e.g. a
64
- // persona's settings button) render for the selected persona.
65
- const PersonaControlsItem = (itemProps) => PersonaControls({ ...itemProps, controlRegistry });
77
+ requires: [IPersonaControlRegistry, IPersonaSessionRegistry],
78
+ activate: (app, controlRegistry, sessionRegistry) => {
79
+ // Wrap the persona controls to inject the control + session registries,
80
+ // which the generic toolbar-item props don't carry.
81
+ const PersonaControlsItem = (itemProps) => PersonaControls({ ...itemProps, controlRegistry, sessionRegistry });
66
82
  return {
67
83
  create: () => {
68
84
  // Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
@@ -86,6 +102,7 @@ const toolbarPlugin = {
86
102
  };
87
103
  export default [
88
104
  plugin,
105
+ sessionRegistryPlugin,
89
106
  slashCommandPlugin,
90
107
  controlRegistryPlugin,
91
108
  toolbarPlugin
@@ -1,5 +1,6 @@
1
1
  import { InputToolbarRegistry } from '@jupyter/chat';
2
- import { PersonaAwareness, PersonaOption, Usage } from './awareness';
2
+ import { PersonaOption, Usage } from './awareness';
3
+ import { PersonaSessionRegistry, PersonaSessionState } from './persona-events';
3
4
  import { PersonaSettings } from './metadata';
4
5
  import { IPersonaControlRegistry } from './persona-control-registry';
5
6
  /**
@@ -26,7 +27,7 @@ export type Control = {
26
27
  * persona advertises models), its model settings, then its general settings.
27
28
  * The user's current selection seeds each control's `selection`.
28
29
  */
29
- export declare function buildControls(persona: PersonaAwareness | null, settings: PersonaSettings): Control[];
30
+ export declare function buildControls(persona: PersonaSessionState | null, settings: PersonaSettings): Control[];
30
31
  /**
31
32
  * Decide which persona list the toolbar should display: a freshly read empty
32
33
  * list is treated as a transient blip (e.g. a Yjs awareness sync hiccup
@@ -56,7 +57,7 @@ export declare function reconcilePersonas(previous: PersonaOption[], next: Perso
56
57
  * result, or switching personas could show the previous persona's stale
57
58
  * state under the new selection.
58
59
  */
59
- export declare function reconcilePersonaState(previous: PersonaAwareness | null, next: PersonaAwareness | null): PersonaAwareness | null;
60
+ export declare function reconcilePersonaState(previous: PersonaSessionState | null, next: PersonaSessionState | null): PersonaSessionState | null;
60
61
  /**
61
62
  * Decide how to reconcile the current selection with a freshly read persona
62
63
  * list: the new selection to apply, or `undefined` to keep the current one.
@@ -171,5 +172,10 @@ export declare function PersonaControls(props: InputToolbarRegistry.IToolbarItem
171
172
  * Optional so the component still works without a registry.
172
173
  */
173
174
  controlRegistry?: IPersonaControlRegistry;
175
+ /**
176
+ * The per-chat persona session-state registry (fed by Jupyter Events).
177
+ * Optional so the component still works without it (renders nothing).
178
+ */
179
+ sessionRegistry?: PersonaSessionRegistry;
174
180
  }): JSX.Element | null;
175
181
  export {};
@@ -1,10 +1,10 @@
1
- import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Button, ListItemText, ListSubheader, Menu, MenuItem, Popover, Skeleton, TextField } from '@mui/material';
3
3
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
4
4
  import CheckIcon from '@mui/icons-material/Check';
5
5
  import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
6
6
  import { PageConfig } from '@jupyterlab/coreutils';
7
- import { EMPTY_USAGE, PersonaAwareness, PersonaManagerAwareness } from './awareness';
7
+ import { EMPTY_USAGE } from './awareness';
8
8
  import { buildMessageMetadata, emptyPersonaSettings } from './metadata';
9
9
  const SELECTOR_CLASS = 'jp-jai-personaControls';
10
10
  const MENU_CLASS = 'jp-jai-controlMenu';
@@ -635,20 +635,16 @@ export function UsageChip(props) {
635
635
  */
636
636
  export function PersonaControls(props) {
637
637
  var _a, _b, _c, _d, _e, _f;
638
- const { chatModel, model, controlRegistry } = props;
639
- const awareness = (_a = chatModel === null || chatModel === void 0 ? void 0 : chatModel.awareness) !== null && _a !== void 0 ? _a : null;
640
- // The manager's awareness view, resolved once its slot appears. Null until
641
- // then. `PersonaManagerAwareness.from()` polls internally, so nothing here
642
- // polls; once resolved, awareness `change` events drive all updates.
643
- const [manager, setManager] = useState(null);
644
- // Whether resolving the manager's slot failed (timed out, extension absent).
645
- // Hides the loading placeholder along with the toolbar.
646
- const [managerFailed, setManagerFailed] = useState(false);
647
- // Whether the first persona-list read has completed after the manager
648
- // resolved. Before that, an empty list means "still loading", not "this chat
649
- // has no personas".
650
- const [listRead, setListRead] = useState(false);
638
+ const { chatModel, model, controlRegistry, sessionRegistry } = props;
639
+ // The chat's server-root-relative path scopes persona events to this chat.
640
+ const path = (_a = chatModel === null || chatModel === void 0 ? void 0 : chatModel.name) !== null && _a !== void 0 ? _a : null;
641
+ // The per-chat persona session state, built from persona events and shared
642
+ // via the registry. Created on demand; discarded when the chat closes.
643
+ const managerState = useMemo(() => (sessionRegistry && path ? sessionRegistry.get(path) : null), [sessionRegistry, path]);
651
644
  const [personas, setPersonas] = useState([]);
645
+ // Whether a persona list has been received for this chat yet. Before that, an
646
+ // empty list means "still loading", not "this chat has no personas".
647
+ const [ready, setReady] = useState(false);
652
648
  const [selectedId, setSelectedId] = useState(DEFAULT_PERSONA_ID);
653
649
  const [personaState, setPersonaState] = useState(null);
654
650
  // Per-persona settings the user has chosen, indexed by persona ID. Remembers
@@ -665,86 +661,66 @@ export function PersonaControls(props) {
665
661
  const settings = selectedId
666
662
  ? ((_b = settingsCache[selectedId]) !== null && _b !== void 0 ? _b : emptyPersonaSettings())
667
663
  : emptyPersonaSettings();
668
- // Resolve the manager's awareness view once the manager registers its slot.
669
- useEffect(() => {
670
- if (!awareness) {
671
- return;
672
- }
673
- let cancelled = false;
674
- PersonaManagerAwareness.from(awareness)
675
- .then(pm => {
676
- if (!cancelled) {
677
- setManager(pm);
678
- }
679
- })
680
- .catch(reason => {
681
- // Manager never registered (e.g. extension disabled); the toolbar
682
- // stays hidden. Surface why, or the empty toolbar is undiagnosable.
683
- console.warn('Persona toolbar hidden:', reason);
684
- if (!cancelled) {
685
- setManagerFailed(true);
686
- }
687
- });
688
- return () => {
689
- cancelled = true;
690
- };
691
- }, [awareness]);
692
- // Re-read the persona list from the manager view and reconcile the selection
693
- // (see reconcileSelection for the decision rules). This is the reactive
694
- // plumbing that replaces polling: a persona publishing or updating its state
695
- // fires an awareness `change` event.
664
+ // Re-read the persona list from the session state and reconcile the
665
+ // selection (see reconcileSelection for the decision rules).
696
666
  const readManager = useCallback(() => {
697
- if (!manager) {
667
+ if (!managerState) {
698
668
  return;
699
669
  }
700
- const list = manager.personas;
670
+ const list = managerState.personas;
701
671
  setPersonas(prev => reconcilePersonas(prev, list));
702
672
  setSelectedId(current => {
703
673
  const next = reconcileSelection(list, current, userPicked.current);
704
674
  return next === undefined ? current : next;
705
675
  });
706
- }, [manager]);
676
+ }, [managerState]);
677
+ // React to the session state's `changed` signal: re-read the persona list and
678
+ // readiness. This replaces the awareness `change` subscription — a persona
679
+ // publishing or updating its state fires `changed`.
707
680
  useEffect(() => {
708
- if (!awareness || !manager) {
681
+ if (!managerState) {
709
682
  return;
710
683
  }
711
- readManager();
712
- setListRead(true);
713
- const onChange = () => readManager();
714
- awareness.on('change', onChange);
684
+ const sync = () => {
685
+ readManager();
686
+ setReady(managerState.ready);
687
+ };
688
+ sync();
689
+ managerState.changed.connect(sync);
715
690
  return () => {
716
- awareness.off('change', onChange);
691
+ managerState.changed.disconnect(sync);
717
692
  };
718
- }, [awareness, manager, readManager]);
719
- // Build a view of the selected persona's slot from the manager's list, or
720
- // null when nothing is selected / the persona isn't present yet.
721
- const readSelectedPersona = () => {
722
- if (!awareness || !manager || !selectedId) {
723
- return null;
724
- }
725
- const option = manager.personas.find(p => p.id === selectedId);
726
- return option ? PersonaAwareness.from(awareness, option) : null;
727
- };
728
- // Track the selected persona's view in state, re-reading on every awareness
729
- // change (a persona updating usage, model, or commands) so the toolbar
730
- // reflects the latest published state. The first read (right below)
731
- // applies unconditionally, since it runs whenever `selectedId` itself
732
- // changes and must reflect the newly selected persona, even a genuinely
733
- // absent one; every later read goes through reconcilePersonaState so a
734
- // transient blip doesn't blank this out - see its comment for why that
735
- // matters more than it looks like it should.
693
+ }, [managerState, readManager]);
694
+ // Track the selected persona's state, re-reading on every change so the
695
+ // toolbar reflects the latest published model/usage/commands. The first read
696
+ // applies unconditionally (it runs whenever `selectedId` changes and must
697
+ // reflect the newly selected persona, even an absent one); later reads go
698
+ // through reconcilePersonaState so a transient absence doesn't blank it out.
736
699
  useEffect(() => {
737
- if (!awareness || !manager || !selectedId) {
700
+ var _a;
701
+ if (!managerState || !selectedId) {
738
702
  setPersonaState(null);
739
703
  return;
740
704
  }
741
- setPersonaState(readSelectedPersona());
742
- const read = () => setPersonaState(prev => reconcilePersonaState(prev, readSelectedPersona()));
743
- awareness.on('change', read);
705
+ setPersonaState((_a = managerState.getPersona(selectedId)) !== null && _a !== void 0 ? _a : null);
706
+ const read = () => setPersonaState(prev => { var _a; return reconcilePersonaState(prev, (_a = managerState.getPersona(selectedId)) !== null && _a !== void 0 ? _a : null); });
707
+ managerState.changed.connect(read);
708
+ return () => {
709
+ managerState.changed.disconnect(read);
710
+ };
711
+ }, [managerState, selectedId]);
712
+ // Discard this chat's session state when the chat model is disposed (chat
713
+ // closed), freeing its memory.
714
+ useEffect(() => {
715
+ if (!sessionRegistry || !path || !chatModel) {
716
+ return;
717
+ }
718
+ const onDisposed = () => sessionRegistry.discard(path);
719
+ chatModel.disposed.connect(onDisposed);
744
720
  return () => {
745
- awareness.off('change', read);
721
+ chatModel.disposed.disconnect(onDisposed);
746
722
  };
747
- }, [awareness, manager, selectedId]);
723
+ }, [sessionRegistry, path, chatModel]);
748
724
  // Stamp the current persona + its settings onto the input model's metadata,
749
725
  // so it rides out with the next message and the PersonaManager routes and
750
726
  // applies it. Keyed on a signature so we only write when it changes.
@@ -755,9 +731,12 @@ export function PersonaControls(props) {
755
731
  }, [model, metadataSignature]);
756
732
  // No personas yet. While the manager's slot or its first list read is still
757
733
  // pending, show a loading placeholder (on slow networks this takes seconds);
758
- // once resolution failed or the chat genuinely has no personas, show nothing.
734
+ // once ready with no personas, show nothing.
759
735
  if (!personas.length) {
760
- if (showLoadingPlaceholder(awareness !== null, manager !== null, managerFailed, listRead)) {
736
+ // Still loading while the session state exists but no persona list has
737
+ // arrived yet (events are in flight). Once ready with an empty list, or
738
+ // with no registry at all, render nothing.
739
+ if (managerState && !ready) {
761
740
  return React.createElement(LoadingPlaceholder, null);
762
741
  }
763
742
  return null;
@@ -0,0 +1,88 @@
1
+ import { IEventListener } from 'jupyterlab-eventlistener';
2
+ import { Token } from '@lumino/coreutils';
3
+ import { IDisposable } from '@lumino/disposable';
4
+ import { ISignal } from '@lumino/signaling';
5
+ import { CommandOption, ModelConfiguration, PersonaOption, SettingConfiguration, Usage } from './awareness';
6
+ export declare const PERSONAS_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/personas/v1";
7
+ export declare const PERSONA_STATE_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/persona_state/v1";
8
+ /** The wire shape of a `persona_state` event. */
9
+ type PersonaStatePayload = {
10
+ path?: string;
11
+ persona_id?: string;
12
+ model?: ModelConfiguration;
13
+ settings?: SettingConfiguration[];
14
+ usage?: Usage;
15
+ slash_commands?: CommandOption[];
16
+ };
17
+ /**
18
+ * One persona's live session state, built from a `persona_state` event.
19
+ * Immutable: each update produces a new instance, so React consumers see a new
20
+ * reference and re-render.
21
+ */
22
+ export declare class PersonaSessionState {
23
+ readonly id: string;
24
+ constructor(id: string, payload?: PersonaStatePayload);
25
+ readonly model: ModelConfiguration;
26
+ readonly settings: SettingConfiguration[];
27
+ readonly usage: Usage;
28
+ readonly slash_commands: CommandOption[];
29
+ }
30
+ /**
31
+ * The per-chat manager session state: the persona list plus a
32
+ * `PersonaSessionState` per persona. Fires `changed` whenever the persona list
33
+ * or any persona's state updates, so React components re-render.
34
+ */
35
+ export declare class PersonaManagerSessionState implements IDisposable {
36
+ readonly path: string;
37
+ constructor(path: string);
38
+ /** Emits whenever the persona list or a persona's state changes. */
39
+ get changed(): ISignal<this, void>;
40
+ /** Whether a `personas` event has been received for this chat yet. */
41
+ get ready(): boolean;
42
+ /** The personas advertised in this chat. */
43
+ get personas(): PersonaOption[];
44
+ /** A persona's session state, or undefined if it has not published yet. */
45
+ getPersona(id: string): PersonaSessionState | undefined;
46
+ /** Apply a `personas` event payload. */
47
+ updatePersonas(personas: PersonaOption[]): void;
48
+ /** Apply a `persona_state` event payload for one persona. */
49
+ updatePersonaState(personaId: string, payload: PersonaStatePayload): void;
50
+ get isDisposed(): boolean;
51
+ dispose(): void;
52
+ private _personasReceived;
53
+ private _personas;
54
+ private _states;
55
+ private _isDisposed;
56
+ private _changed;
57
+ }
58
+ /**
59
+ * Routes persona events to the correct per-chat `PersonaManagerSessionState`,
60
+ * creating one on demand and discarding it when the chat closes.
61
+ *
62
+ * A single instance is created by the plugin and shared with the toolbar
63
+ * controls and the slash-command provider.
64
+ */
65
+ export declare class PersonaSessionRegistry {
66
+ constructor(eventListener: IEventListener);
67
+ /**
68
+ * Get (or create) the manager session state for a chat path. Components call
69
+ * this with their chat's `IChatModel.name`.
70
+ */
71
+ get(path: string): PersonaManagerSessionState;
72
+ /** Whether a manager session state exists for `path` (without creating one). */
73
+ has(path: string): boolean;
74
+ /**
75
+ * Discard a chat's session state and free its memory. Called when the client
76
+ * closes the chat (wired to the chat model's `disposed` signal).
77
+ */
78
+ discard(path: string): void;
79
+ private _onPersonasEvent;
80
+ private _onPersonaStateEvent;
81
+ private _byPath;
82
+ }
83
+ /**
84
+ * Plugin token for the shared `PersonaSessionRegistry`. Consumed by the toolbar
85
+ * controls and the slash-command provider.
86
+ */
87
+ export declare const IPersonaSessionRegistry: Token<PersonaSessionRegistry>;
88
+ export {};