@jupyter-ai/persona-manager 0.2.0-a2 → 0.2.0-a4

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/lib/index.js CHANGED
@@ -76,9 +76,17 @@ const toolbarPlugin = {
76
76
  provides: IInputToolbarRegistryFactory,
77
77
  requires: [IPersonaControlRegistry, IPersonaSessionRegistry],
78
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 });
79
+ // The event bus front-end; `PersonaControls` uses it to emit
80
+ // `persona_selected` on selection.
81
+ const events = app.serviceManager.events;
82
+ // Wrap the persona controls to inject the control + session registries and
83
+ // the event manager, which the generic toolbar-item props don't carry.
84
+ const PersonaControlsItem = (itemProps) => PersonaControls({
85
+ ...itemProps,
86
+ controlRegistry,
87
+ sessionRegistry,
88
+ events
89
+ });
82
90
  return {
83
91
  create: () => {
84
92
  // Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
@@ -1,3 +1,4 @@
1
+ import { Event } from '@jupyterlab/services';
1
2
  import { InputToolbarRegistry } from '@jupyter/chat';
2
3
  import { PersonaOption, Usage } from './awareness';
3
4
  import { PersonaSessionRegistry, PersonaSessionState } from './persona-events';
@@ -177,5 +178,10 @@ export declare function PersonaControls(props: InputToolbarRegistry.IToolbarItem
177
178
  * Optional so the component still works without it (renders nothing).
178
179
  */
179
180
  sessionRegistry?: PersonaSessionRegistry;
181
+ /**
182
+ * The event bus front-end, used to emit a `persona_selected` event when the
183
+ * user picks a persona so the server prepares it eagerly.
184
+ */
185
+ events?: Event.IManager;
180
186
  }): JSX.Element | null;
181
187
  export {};
@@ -6,6 +6,7 @@ import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
6
6
  import { PageConfig } from '@jupyterlab/coreutils';
7
7
  import { EMPTY_USAGE } from './awareness';
8
8
  import { buildMessageMetadata, emptyPersonaSettings } from './metadata';
9
+ import { PERSONA_SELECTED_EVENT_SCHEMA_ID } from './persona-events';
9
10
  const SELECTOR_CLASS = 'jp-jai-personaControls';
10
11
  const MENU_CLASS = 'jp-jai-controlMenu';
11
12
  const USAGE_CLASS = 'jp-jai-usage';
@@ -635,12 +636,31 @@ export function UsageChip(props) {
635
636
  */
636
637
  export function PersonaControls(props) {
637
638
  var _a, _b, _c, _d, _e, _f;
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;
639
+ const { chatModel, model, controlRegistry, sessionRegistry, events } = props;
640
+ // The chat's stable id scopes persona events to this chat. It is assigned
641
+ // asynchronously (once the model is `ready`: the WS connection frame arrives,
642
+ // or the RTC document syncs), so track it in state and update it when the
643
+ // model becomes ready. Reading it synchronously would capture `undefined`
644
+ // forever, since `id` is a plain accessor with no change signal.
645
+ const [chatId, setChatId] = useState((_a = chatModel === null || chatModel === void 0 ? void 0 : chatModel.id) !== null && _a !== void 0 ? _a : null);
646
+ useEffect(() => {
647
+ if (!chatModel) {
648
+ setChatId(null);
649
+ return;
650
+ }
651
+ let cancelled = false;
652
+ void chatModel.ready.then(id => {
653
+ if (!cancelled) {
654
+ setChatId(id !== null && id !== void 0 ? id : null);
655
+ }
656
+ });
657
+ return () => {
658
+ cancelled = true;
659
+ };
660
+ }, [chatModel]);
641
661
  // The per-chat persona session state, built from persona events and shared
642
662
  // via the registry. Created on demand; discarded when the chat closes.
643
- const managerState = useMemo(() => (sessionRegistry && path ? sessionRegistry.get(path) : null), [sessionRegistry, path]);
663
+ const managerState = useMemo(() => (sessionRegistry && chatId ? sessionRegistry.get(chatId) : null), [sessionRegistry, chatId]);
644
664
  const [personas, setPersonas] = useState([]);
645
665
  // Whether a persona list has been received for this chat yet. Before that, an
646
666
  // empty list means "still loading", not "this chat has no personas".
@@ -712,15 +732,15 @@ export function PersonaControls(props) {
712
732
  // Discard this chat's session state when the chat model is disposed (chat
713
733
  // closed), freeing its memory.
714
734
  useEffect(() => {
715
- if (!sessionRegistry || !path || !chatModel) {
735
+ if (!sessionRegistry || !chatId || !chatModel) {
716
736
  return;
717
737
  }
718
- const onDisposed = () => sessionRegistry.discard(path);
738
+ const onDisposed = () => sessionRegistry.discard(chatId);
719
739
  chatModel.disposed.connect(onDisposed);
720
740
  return () => {
721
741
  chatModel.disposed.disconnect(onDisposed);
722
742
  };
723
- }, [sessionRegistry, path, chatModel]);
743
+ }, [sessionRegistry, chatId, chatModel]);
724
744
  // Stamp the current persona + its settings onto the input model's metadata,
725
745
  // so it rides out with the next message and the PersonaManager routes and
726
746
  // applies it. Keyed on a signature so we only write when it changes.
@@ -729,6 +749,21 @@ export function PersonaControls(props) {
729
749
  model.clearMetadata();
730
750
  model.updateMetadata(buildMessageMetadata(selectedId, settings));
731
751
  }, [model, metadataSignature]);
752
+ // On selection, tell the server to prepare the persona by
753
+ // emitting `persona_selected` over the event bus, so its controls appear
754
+ // before the first message. Skips "No one"; idempotent server-side.
755
+ useEffect(() => {
756
+ if (!events || !chatId || !selectedId) {
757
+ return;
758
+ }
759
+ void events
760
+ .emit({
761
+ schema_id: PERSONA_SELECTED_EVENT_SCHEMA_ID,
762
+ version: '1',
763
+ data: { chat_id: chatId, persona_id: selectedId }
764
+ })
765
+ .catch(e => console.warn('Error emitting persona_selected event: ', e));
766
+ }, [events, chatId, selectedId]);
732
767
  // No personas yet. While the manager's slot or its first list read is still
733
768
  // pending, show a loading placeholder (on slow networks this takes seconds);
734
769
  // once ready with no personas, show nothing.
@@ -5,9 +5,10 @@ import { ISignal } from '@lumino/signaling';
5
5
  import { CommandOption, ModelConfiguration, PersonaOption, SettingConfiguration, Usage } from './awareness';
6
6
  export declare const PERSONAS_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/personas/v1";
7
7
  export declare const PERSONA_STATE_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/persona_state/v1";
8
+ export declare const PERSONA_SELECTED_EVENT_SCHEMA_ID = "https://schema.jupyter.org/jupyter_ai_persona_manager/persona_selected/v1";
8
9
  /** The wire shape of a `persona_state` event. */
9
10
  type PersonaStatePayload = {
10
- path?: string;
11
+ chat_id?: string;
11
12
  persona_id?: string;
12
13
  model?: ModelConfiguration;
13
14
  settings?: SettingConfiguration[];
@@ -33,8 +34,8 @@ export declare class PersonaSessionState {
33
34
  * or any persona's state updates, so React components re-render.
34
35
  */
35
36
  export declare class PersonaManagerSessionState implements IDisposable {
36
- readonly path: string;
37
- constructor(path: string);
37
+ readonly chatId: string;
38
+ constructor(chatId: string);
38
39
  /** Emits whenever the persona list or a persona's state changes. */
39
40
  get changed(): ISignal<this, void>;
40
41
  /** Whether a `personas` event has been received for this chat yet. */
@@ -65,20 +66,20 @@ export declare class PersonaManagerSessionState implements IDisposable {
65
66
  export declare class PersonaSessionRegistry {
66
67
  constructor(eventListener: IEventListener);
67
68
  /**
68
- * Get (or create) the manager session state for a chat path. Components call
69
- * this with their chat's `IChatModel.name`.
69
+ * Get (or create) the manager session state for a chat. Components call this
70
+ * with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
70
71
  */
71
- get(path: string): PersonaManagerSessionState;
72
- /** Whether a manager session state exists for `path` (without creating one). */
73
- has(path: string): boolean;
72
+ get(chatId: string): PersonaManagerSessionState;
73
+ /** Whether a manager session state exists for `chatId` (without creating one). */
74
+ has(chatId: string): boolean;
74
75
  /**
75
76
  * Discard a chat's session state and free its memory. Called when the client
76
77
  * closes the chat (wired to the chat model's `disposed` signal).
77
78
  */
78
- discard(path: string): void;
79
+ discard(chatId: string): void;
79
80
  private _onPersonasEvent;
80
81
  private _onPersonaStateEvent;
81
- private _byPath;
82
+ private _byChatId;
82
83
  }
83
84
  /**
84
85
  * Plugin token for the shared `PersonaSessionRegistry`. Consumed by the toolbar
@@ -3,6 +3,7 @@ import { Signal } from '@lumino/signaling';
3
3
  import { EMPTY_MODEL_CONFIGURATION, EMPTY_USAGE } from './awareness';
4
4
  export const PERSONAS_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupyter_ai_persona_manager/personas/v1';
5
5
  export const PERSONA_STATE_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_state/v1';
6
+ export const PERSONA_SELECTED_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_selected/v1';
6
7
  /**
7
8
  * One persona's live session state, built from a `persona_state` event.
8
9
  * Immutable: each update produces a new instance, so React consumers see a new
@@ -24,8 +25,8 @@ export class PersonaSessionState {
24
25
  * or any persona's state updates, so React components re-render.
25
26
  */
26
27
  export class PersonaManagerSessionState {
27
- constructor(path) {
28
- this.path = path;
28
+ constructor(chatId) {
29
+ this.chatId = chatId;
29
30
  this._personasReceived = false;
30
31
  this._personas = [];
31
32
  this._states = new Map();
@@ -82,47 +83,47 @@ export class PersonaSessionRegistry {
82
83
  constructor(eventListener) {
83
84
  this._onPersonasEvent = async (_manager, _schemaId, event) => {
84
85
  const data = event;
85
- if (!data.path) {
86
+ if (!data.chat_id) {
86
87
  return;
87
88
  }
88
- this.get(data.path).updatePersonas(Array.isArray(data.personas) ? data.personas : []);
89
+ this.get(data.chat_id).updatePersonas(Array.isArray(data.personas) ? data.personas : []);
89
90
  };
90
91
  this._onPersonaStateEvent = async (_manager, _schemaId, event) => {
91
92
  const data = event;
92
- if (!data.path || !data.persona_id) {
93
+ if (!data.chat_id || !data.persona_id) {
93
94
  return;
94
95
  }
95
- this.get(data.path).updatePersonaState(data.persona_id, data);
96
+ this.get(data.chat_id).updatePersonaState(data.persona_id, data);
96
97
  };
97
- this._byPath = new Map();
98
+ this._byChatId = new Map();
98
99
  eventListener.addListener(PERSONAS_EVENT_SCHEMA_ID, this._onPersonasEvent);
99
100
  eventListener.addListener(PERSONA_STATE_EVENT_SCHEMA_ID, this._onPersonaStateEvent);
100
101
  }
101
102
  /**
102
- * Get (or create) the manager session state for a chat path. Components call
103
- * this with their chat's `IChatModel.name`.
103
+ * Get (or create) the manager session state for a chat. Components call this
104
+ * with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
104
105
  */
105
- get(path) {
106
- let state = this._byPath.get(path);
106
+ get(chatId) {
107
+ let state = this._byChatId.get(chatId);
107
108
  if (!state) {
108
- state = new PersonaManagerSessionState(path);
109
- this._byPath.set(path, state);
109
+ state = new PersonaManagerSessionState(chatId);
110
+ this._byChatId.set(chatId, state);
110
111
  }
111
112
  return state;
112
113
  }
113
- /** Whether a manager session state exists for `path` (without creating one). */
114
- has(path) {
115
- return this._byPath.has(path);
114
+ /** Whether a manager session state exists for `chatId` (without creating one). */
115
+ has(chatId) {
116
+ return this._byChatId.has(chatId);
116
117
  }
117
118
  /**
118
119
  * Discard a chat's session state and free its memory. Called when the client
119
120
  * closes the chat (wired to the chat model's `disposed` signal).
120
121
  */
121
- discard(path) {
122
- const state = this._byPath.get(path);
122
+ discard(chatId) {
123
+ const state = this._byChatId.get(chatId);
123
124
  if (state) {
124
125
  state.dispose();
125
- this._byPath.delete(path);
126
+ this._byChatId.delete(chatId);
126
127
  }
127
128
  }
128
129
  }
package/lib/request.d.ts CHANGED
@@ -12,4 +12,4 @@ export declare function requestAPI<T>(namespace: string, endPoint?: string, init
12
12
  * via the cancel endpoint. Backend-agnostic: each persona's
13
13
  * `cancel_response()` decides what stopping means.
14
14
  */
15
- export declare function cancelResponse(chatPath: string): Promise<void>;
15
+ export declare function cancelResponse(chatId: string): Promise<void>;
package/lib/request.js CHANGED
@@ -38,9 +38,9 @@ export async function requestAPI(namespace, endPoint = '', init = {}) {
38
38
  * via the cancel endpoint. Backend-agnostic: each persona's
39
39
  * `cancel_response()` decides what stopping means.
40
40
  */
41
- export async function cancelResponse(chatPath) {
41
+ export async function cancelResponse(chatId) {
42
42
  try {
43
- await requestAPI('api/ai', `personas/cancel?chat_path=${encodeURIComponent(chatPath)}`, { method: 'POST' });
43
+ await requestAPI('api/ai', `personas/cancel?chat_id=${encodeURIComponent(chatId)}`, { method: 'POST' });
44
44
  }
45
45
  catch (e) {
46
46
  console.warn('Error cancelling response: ', e);
@@ -34,15 +34,15 @@ export class SlashCommandProvider {
34
34
  if (!currentWord.startsWith('/')) {
35
35
  return [];
36
36
  }
37
- const path = (_b = (_a = inputModel.chatContext) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : null;
38
- if (!path) {
37
+ const chatId = (_b = (_a = inputModel.chatContext) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null;
38
+ if (!chatId) {
39
39
  return [];
40
40
  }
41
41
  const personaId = (_d = (_c = inputModel.getMetadata) === null || _c === void 0 ? void 0 : _c.call(inputModel).to_persona) !== null && _d !== void 0 ? _d : null;
42
42
  if (!personaId) {
43
43
  return [];
44
44
  }
45
- const persona = this._registry.get(path).getPersona(personaId);
45
+ const persona = this._registry.get(chatId).getPersona(personaId);
46
46
  if (!persona) {
47
47
  return [];
48
48
  }
@@ -31,12 +31,12 @@ export function StopButton(props) {
31
31
  };
32
32
  }, [chatModel]);
33
33
  async function handleStop() {
34
- if (!chatModel) {
34
+ if (!(chatModel === null || chatModel === void 0 ? void 0 : chatModel.id)) {
35
35
  return;
36
36
  }
37
37
  setInFlight(true);
38
38
  try {
39
- await cancelResponse(chatModel.name);
39
+ await cancelResponse(chatModel.id);
40
40
  }
41
41
  finally {
42
42
  setInFlight(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter-ai/persona-manager",
3
- "version": "0.2.0-a2",
3
+ "version": "0.2.0-a4",
4
4
  "description": "The core manager & registry for AI personas in Jupyter AI",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -58,7 +58,7 @@
58
58
  "watch:labextension": "jupyter labextension watch ."
59
59
  },
60
60
  "dependencies": {
61
- "@jupyter/chat": "^0.23.0-alpha.4",
61
+ "@jupyter/chat": "^0.25.0-alpha.5",
62
62
  "@jupyterlab/application": "^4.0.0",
63
63
  "@jupyterlab/coreutils": "^6.0.0",
64
64
  "@jupyterlab/services": "^7.0.0",
@@ -41,10 +41,10 @@ function makeRegistry(): {
41
41
  }
42
42
 
43
43
  describe('PersonaSessionRegistry', () => {
44
- it('routes a personas event to the matching chat by path', async () => {
44
+ it('routes a personas event to the matching chat by id', async () => {
45
45
  const { registry, events } = makeRegistry();
46
46
  await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
47
- path: 'a.chat',
47
+ chat_id: 'a.chat',
48
48
  personas: [{ id: 'p1', name: 'One', avatar_url: null }]
49
49
  });
50
50
  expect(registry.get('a.chat').personas.map(p => p.id)).toEqual(['p1']);
@@ -56,7 +56,7 @@ describe('PersonaSessionRegistry', () => {
56
56
  it('routes persona_state events and builds PersonaSessionState', async () => {
57
57
  const { registry, events } = makeRegistry();
58
58
  await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
59
- path: 'a.chat',
59
+ chat_id: 'a.chat',
60
60
  persona_id: 'p1',
61
61
  model: { current: 'm1', options: [], settings: [] },
62
62
  usage: { input_tokens: 5 }
@@ -74,11 +74,11 @@ describe('PersonaSessionRegistry', () => {
74
74
  fired += 1;
75
75
  });
76
76
  await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
77
- path: 'a.chat',
77
+ chat_id: 'a.chat',
78
78
  personas: []
79
79
  });
80
80
  await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
81
- path: 'a.chat',
81
+ chat_id: 'a.chat',
82
82
  persona_id: 'p1'
83
83
  });
84
84
  expect(fired).toBe(2);
@@ -87,7 +87,7 @@ describe('PersonaSessionRegistry', () => {
87
87
  it('discards a chat session state on close, freeing memory', async () => {
88
88
  const { registry, events } = makeRegistry();
89
89
  await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
90
- path: 'a.chat',
90
+ chat_id: 'a.chat',
91
91
  personas: [{ id: 'p1', name: 'One', avatar_url: null }]
92
92
  });
93
93
  const state = registry.get('a.chat');
package/src/index.ts CHANGED
@@ -119,11 +119,20 @@ const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory> = {
119
119
  controlRegistry: IPersonaControlRegistry,
120
120
  sessionRegistry: PersonaSessionRegistry
121
121
  ): IInputToolbarRegistryFactory => {
122
- // Wrap the persona controls to inject the control + session registries,
123
- // which the generic toolbar-item props don't carry.
122
+ // The event bus front-end; `PersonaControls` uses it to emit
123
+ // `persona_selected` on selection.
124
+ const events = app.serviceManager.events;
125
+ // Wrap the persona controls to inject the control + session registries and
126
+ // the event manager, which the generic toolbar-item props don't carry.
124
127
  const PersonaControlsItem = (
125
128
  itemProps: InputToolbarRegistry.IToolbarItemProps
126
- ) => PersonaControls({ ...itemProps, controlRegistry, sessionRegistry });
129
+ ) =>
130
+ PersonaControls({
131
+ ...itemProps,
132
+ controlRegistry,
133
+ sessionRegistry,
134
+ events
135
+ });
127
136
  return {
128
137
  create: () => {
129
138
  // Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
@@ -21,6 +21,7 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
21
21
  import CheckIcon from '@mui/icons-material/Check';
22
22
  import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
23
23
  import { PageConfig } from '@jupyterlab/coreutils';
24
+ import { Event } from '@jupyterlab/services';
24
25
  import { InputToolbarRegistry } from '@jupyter/chat';
25
26
  import {
26
27
  EMPTY_USAGE,
@@ -35,6 +36,7 @@ import {
35
36
  emptyPersonaSettings
36
37
  } from './metadata';
37
38
  import { IPersonaControlRegistry } from './persona-control-registry';
39
+ import { PERSONA_SELECTED_EVENT_SCHEMA_ID } from './persona-events';
38
40
 
39
41
  const SELECTOR_CLASS = 'jp-jai-personaControls';
40
42
  const MENU_CLASS = 'jp-jai-controlMenu';
@@ -1072,17 +1074,41 @@ export function PersonaControls(
1072
1074
  * Optional so the component still works without it (renders nothing).
1073
1075
  */
1074
1076
  sessionRegistry?: PersonaSessionRegistry;
1077
+ /**
1078
+ * The event bus front-end, used to emit a `persona_selected` event when the
1079
+ * user picks a persona so the server prepares it eagerly.
1080
+ */
1081
+ events?: Event.IManager;
1075
1082
  }
1076
1083
  ): JSX.Element | null {
1077
- const { chatModel, model, controlRegistry, sessionRegistry } = props;
1078
- // The chat's server-root-relative path scopes persona events to this chat.
1079
- const path = chatModel?.name ?? null;
1084
+ const { chatModel, model, controlRegistry, sessionRegistry, events } = props;
1085
+ // The chat's stable id scopes persona events to this chat. It is assigned
1086
+ // asynchronously (once the model is `ready`: the WS connection frame arrives,
1087
+ // or the RTC document syncs), so track it in state and update it when the
1088
+ // model becomes ready. Reading it synchronously would capture `undefined`
1089
+ // forever, since `id` is a plain accessor with no change signal.
1090
+ const [chatId, setChatId] = useState<string | null>(chatModel?.id ?? null);
1091
+ useEffect(() => {
1092
+ if (!chatModel) {
1093
+ setChatId(null);
1094
+ return;
1095
+ }
1096
+ let cancelled = false;
1097
+ void chatModel.ready.then(id => {
1098
+ if (!cancelled) {
1099
+ setChatId(id ?? null);
1100
+ }
1101
+ });
1102
+ return () => {
1103
+ cancelled = true;
1104
+ };
1105
+ }, [chatModel]);
1080
1106
 
1081
1107
  // The per-chat persona session state, built from persona events and shared
1082
1108
  // via the registry. Created on demand; discarded when the chat closes.
1083
1109
  const managerState = useMemo(
1084
- () => (sessionRegistry && path ? sessionRegistry.get(path) : null),
1085
- [sessionRegistry, path]
1110
+ () => (sessionRegistry && chatId ? sessionRegistry.get(chatId) : null),
1111
+ [sessionRegistry, chatId]
1086
1112
  );
1087
1113
 
1088
1114
  const [personas, setPersonas] = useState<PersonaOption[]>([]);
@@ -1169,15 +1195,15 @@ export function PersonaControls(
1169
1195
  // Discard this chat's session state when the chat model is disposed (chat
1170
1196
  // closed), freeing its memory.
1171
1197
  useEffect(() => {
1172
- if (!sessionRegistry || !path || !chatModel) {
1198
+ if (!sessionRegistry || !chatId || !chatModel) {
1173
1199
  return;
1174
1200
  }
1175
- const onDisposed = () => sessionRegistry.discard(path);
1201
+ const onDisposed = () => sessionRegistry.discard(chatId);
1176
1202
  chatModel.disposed.connect(onDisposed);
1177
1203
  return () => {
1178
1204
  chatModel.disposed.disconnect(onDisposed);
1179
1205
  };
1180
- }, [sessionRegistry, path, chatModel]);
1206
+ }, [sessionRegistry, chatId, chatModel]);
1181
1207
 
1182
1208
  // Stamp the current persona + its settings onto the input model's metadata,
1183
1209
  // so it rides out with the next message and the PersonaManager routes and
@@ -1188,6 +1214,22 @@ export function PersonaControls(
1188
1214
  model.updateMetadata(buildMessageMetadata(selectedId, settings));
1189
1215
  }, [model, metadataSignature]);
1190
1216
 
1217
+ // On selection, tell the server to prepare the persona by
1218
+ // emitting `persona_selected` over the event bus, so its controls appear
1219
+ // before the first message. Skips "No one"; idempotent server-side.
1220
+ useEffect(() => {
1221
+ if (!events || !chatId || !selectedId) {
1222
+ return;
1223
+ }
1224
+ void events
1225
+ .emit({
1226
+ schema_id: PERSONA_SELECTED_EVENT_SCHEMA_ID,
1227
+ version: '1',
1228
+ data: { chat_id: chatId, persona_id: selectedId }
1229
+ })
1230
+ .catch(e => console.warn('Error emitting persona_selected event: ', e));
1231
+ }, [events, chatId, selectedId]);
1232
+
1191
1233
  // No personas yet. While the manager's slot or its first list read is still
1192
1234
  // pending, show a loading placeholder (on slow networks this takes seconds);
1193
1235
  // once ready with no personas, show nothing.
@@ -3,8 +3,8 @@
3
3
  * `jupyterlab-eventlistener`), replacing the previous Yjs-awareness channel.
4
4
  *
5
5
  * Flow: the server emits `personas` / `persona_state` events (each carrying the
6
- * chat's `path`); the `PersonaSessionRegistry` routes them to the per-chat
7
- * `PersonaManagerSessionState`, which holds the persona list and a
6
+ * chat's stable `chat_id`); the `PersonaSessionRegistry` routes them to the
7
+ * per-chat `PersonaManagerSessionState`, which holds the persona list and a
8
8
  * `PersonaSessionState` per persona and fires a Lumino `changed` signal. React
9
9
  * components listen to that signal. When a chat closes, its state is discarded.
10
10
  *
@@ -31,10 +31,12 @@ export const PERSONAS_EVENT_SCHEMA_ID =
31
31
  'https://schema.jupyter.org/jupyter_ai_persona_manager/personas/v1';
32
32
  export const PERSONA_STATE_EVENT_SCHEMA_ID =
33
33
  'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_state/v1';
34
+ export const PERSONA_SELECTED_EVENT_SCHEMA_ID =
35
+ 'https://schema.jupyter.org/jupyter_ai_persona_manager/persona_selected/v1';
34
36
 
35
37
  /** The wire shape of a `persona_state` event. */
36
38
  type PersonaStatePayload = {
37
- path?: string;
39
+ chat_id?: string;
38
40
  persona_id?: string;
39
41
  model?: ModelConfiguration;
40
42
  settings?: SettingConfiguration[];
@@ -44,7 +46,7 @@ type PersonaStatePayload = {
44
46
 
45
47
  /** The wire shape of a `personas` event. */
46
48
  type PersonasPayload = {
47
- path?: string;
49
+ chat_id?: string;
48
50
  personas?: PersonaOption[];
49
51
  };
50
52
 
@@ -76,7 +78,7 @@ export class PersonaSessionState {
76
78
  * or any persona's state updates, so React components re-render.
77
79
  */
78
80
  export class PersonaManagerSessionState implements IDisposable {
79
- constructor(public readonly path: string) {}
81
+ constructor(public readonly chatId: string) {}
80
82
 
81
83
  /** Emits whenever the persona list or a persona's state changes. */
82
84
  get changed(): ISignal<this, void> {
@@ -151,32 +153,32 @@ export class PersonaSessionRegistry {
151
153
  }
152
154
 
153
155
  /**
154
- * Get (or create) the manager session state for a chat path. Components call
155
- * this with their chat's `IChatModel.name`.
156
+ * Get (or create) the manager session state for a chat. Components call this
157
+ * with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
156
158
  */
157
- get(path: string): PersonaManagerSessionState {
158
- let state = this._byPath.get(path);
159
+ get(chatId: string): PersonaManagerSessionState {
160
+ let state = this._byChatId.get(chatId);
159
161
  if (!state) {
160
- state = new PersonaManagerSessionState(path);
161
- this._byPath.set(path, state);
162
+ state = new PersonaManagerSessionState(chatId);
163
+ this._byChatId.set(chatId, state);
162
164
  }
163
165
  return state;
164
166
  }
165
167
 
166
- /** Whether a manager session state exists for `path` (without creating one). */
167
- has(path: string): boolean {
168
- return this._byPath.has(path);
168
+ /** Whether a manager session state exists for `chatId` (without creating one). */
169
+ has(chatId: string): boolean {
170
+ return this._byChatId.has(chatId);
169
171
  }
170
172
 
171
173
  /**
172
174
  * Discard a chat's session state and free its memory. Called when the client
173
175
  * closes the chat (wired to the chat model's `disposed` signal).
174
176
  */
175
- discard(path: string): void {
176
- const state = this._byPath.get(path);
177
+ discard(chatId: string): void {
178
+ const state = this._byChatId.get(chatId);
177
179
  if (state) {
178
180
  state.dispose();
179
- this._byPath.delete(path);
181
+ this._byChatId.delete(chatId);
180
182
  }
181
183
  }
182
184
 
@@ -186,10 +188,10 @@ export class PersonaSessionRegistry {
186
188
  event: Event.Emission
187
189
  ): Promise<void> => {
188
190
  const data = event as PersonasPayload;
189
- if (!data.path) {
191
+ if (!data.chat_id) {
190
192
  return;
191
193
  }
192
- this.get(data.path).updatePersonas(
194
+ this.get(data.chat_id).updatePersonas(
193
195
  Array.isArray(data.personas) ? data.personas : []
194
196
  );
195
197
  };
@@ -200,13 +202,13 @@ export class PersonaSessionRegistry {
200
202
  event: Event.Emission
201
203
  ): Promise<void> => {
202
204
  const data = event as PersonaStatePayload;
203
- if (!data.path || !data.persona_id) {
205
+ if (!data.chat_id || !data.persona_id) {
204
206
  return;
205
207
  }
206
- this.get(data.path).updatePersonaState(data.persona_id, data);
208
+ this.get(data.chat_id).updatePersonaState(data.persona_id, data);
207
209
  };
208
210
 
209
- private _byPath = new Map<string, PersonaManagerSessionState>();
211
+ private _byChatId = new Map<string, PersonaManagerSessionState>();
210
212
  }
211
213
 
212
214
  /**
package/src/request.ts CHANGED
@@ -48,11 +48,11 @@ export async function requestAPI<T>(
48
48
  * via the cancel endpoint. Backend-agnostic: each persona's
49
49
  * `cancel_response()` decides what stopping means.
50
50
  */
51
- export async function cancelResponse(chatPath: string): Promise<void> {
51
+ export async function cancelResponse(chatId: string): Promise<void> {
52
52
  try {
53
53
  await requestAPI(
54
54
  'api/ai',
55
- `personas/cancel?chat_path=${encodeURIComponent(chatPath)}`,
55
+ `personas/cancel?chat_id=${encodeURIComponent(chatId)}`,
56
56
  { method: 'POST' }
57
57
  );
58
58
  } catch (e) {
@@ -43,8 +43,8 @@ export class SlashCommandProvider implements IChatCommandProvider {
43
43
  return [];
44
44
  }
45
45
 
46
- const path = inputModel.chatContext?.name ?? null;
47
- if (!path) {
46
+ const chatId = inputModel.chatContext?.id ?? null;
47
+ if (!chatId) {
48
48
  return [];
49
49
  }
50
50
 
@@ -53,7 +53,7 @@ export class SlashCommandProvider implements IChatCommandProvider {
53
53
  return [];
54
54
  }
55
55
 
56
- const persona = this._registry.get(path).getPersona(personaId);
56
+ const persona = this._registry.get(chatId).getPersona(personaId);
57
57
  if (!persona) {
58
58
  return [];
59
59
  }
@@ -38,13 +38,13 @@ export function StopButton(
38
38
  }, [chatModel]);
39
39
 
40
40
  async function handleStop() {
41
- if (!chatModel) {
41
+ if (!chatModel?.id) {
42
42
  return;
43
43
  }
44
44
 
45
45
  setInFlight(true);
46
46
  try {
47
- await cancelResponse(chatModel.name);
47
+ await cancelResponse(chatModel.id);
48
48
  } finally {
49
49
  setInFlight(false);
50
50
  }