@jupyter-ai/persona-manager 0.2.0-a6 → 0.2.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.
package/lib/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { IChatCommandRegistry, IInputToolbarRegistryFactory, InputToolbarRegistry } from '@jupyter/chat';
2
- import { IEventListener } from 'jupyterlab-eventlistener';
3
2
  import { PersonaControls } from './persona-controls';
4
3
  import { IPersonaControlRegistry, PersonaControlRegistry } from './persona-control-registry';
5
4
  import { IPersonaSessionRegistry, PersonaSessionRegistry } from './persona-events';
@@ -24,16 +23,15 @@ const plugin = {
24
23
  };
25
24
  /**
26
25
  * Plugin that provides the shared per-chat persona session-state registry,
27
- * fed by Jupyter Events via `jupyterlab-eventlistener`.
26
+ * fed by Jupyter Events via the `ServiceManager` event bus.
28
27
  */
29
28
  const sessionRegistryPlugin = {
30
29
  id: '@jupyter-ai/persona-manager:session-registry',
31
30
  description: 'Provides the per-chat persona session-state registry (fed by Jupyter Events).',
32
31
  autoStart: true,
33
32
  provides: IPersonaSessionRegistry,
34
- requires: [IEventListener],
35
- activate: (app, eventListener) => {
36
- return new PersonaSessionRegistry(eventListener);
33
+ activate: (app) => {
34
+ return new PersonaSessionRegistry(app.serviceManager.events);
37
35
  }
38
36
  };
39
37
  /**
@@ -87,6 +85,9 @@ const toolbarPlugin = {
87
85
  sessionRegistry,
88
86
  events
89
87
  });
88
+ // Wrap the stop button to inject the session registry, so it can enable
89
+ // itself while a persona in the chat is processing a message.
90
+ const StopButtonItem = (itemProps) => StopButton({ ...itemProps, sessionRegistry });
90
91
  return {
91
92
  create: () => {
92
93
  // Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
@@ -100,7 +101,7 @@ const toolbarPlugin = {
100
101
  // toolbar's cancel button (10); a tie would leave the order to
101
102
  // insertion rather than position.
102
103
  registry.addItem('stop', {
103
- element: StopButton,
104
+ element: StopButtonItem,
104
105
  position: 7
105
106
  });
106
107
  return registry;
@@ -1,4 +1,17 @@
1
- import { IEventListener } from 'jupyterlab-eventlistener';
1
+ /**
2
+ * Frontend persona session state, delivered over Jupyter Events (via the
3
+ * `ServiceManager` event bus), replacing the previous Yjs-awareness channel.
4
+ *
5
+ * Flow: the server emits `personas` / `persona_state` events (each carrying the
6
+ * chat's stable `chat_id`); the `PersonaSessionRegistry` routes them to the
7
+ * per-chat `PersonaManagerSessionState`, which holds the persona list and a
8
+ * `PersonaSessionState` per persona and fires a Lumino `changed` signal. React
9
+ * components listen to that signal. When a chat closes, its state is discarded.
10
+ *
11
+ * Names mirror the Python package (`PersonaManagerSessionState`,
12
+ * `PersonaSessionState`).
13
+ */
14
+ import { Event } from '@jupyterlab/services';
2
15
  import { Token } from '@lumino/coreutils';
3
16
  import { IDisposable } from '@lumino/disposable';
4
17
  import { ISignal } from '@lumino/signaling';
@@ -14,6 +27,7 @@ type PersonaStatePayload = {
14
27
  settings?: SettingConfiguration[];
15
28
  usage?: Usage;
16
29
  slash_commands?: CommandOption[];
30
+ processing?: boolean;
17
31
  };
18
32
  /**
19
33
  * One persona's live session state, built from a `persona_state` event.
@@ -27,6 +41,15 @@ export declare class PersonaSessionState {
27
41
  readonly settings: SettingConfiguration[];
28
42
  readonly usage: Usage;
29
43
  readonly slash_commands: CommandOption[];
44
+ /** Whether the persona is currently processing a message. */
45
+ readonly processing: boolean;
46
+ /**
47
+ * Merge a `persona_state` event payload onto this state, returning a new
48
+ * instance. Attributes the event omits are carried forward unchanged, so a
49
+ * partial event (e.g. usage-only) replaces only what it carries. A new
50
+ * reference is returned so React consumers re-render.
51
+ */
52
+ withUpdate(payload: PersonaStatePayload): PersonaSessionState;
30
53
  }
31
54
  /**
32
55
  * The per-chat manager session state: the persona list plus a
@@ -44,9 +67,18 @@ export declare class PersonaManagerSessionState implements IDisposable {
44
67
  get personas(): PersonaOption[];
45
68
  /** A persona's session state, or undefined if it has not published yet. */
46
69
  getPersona(id: string): PersonaSessionState | undefined;
70
+ /**
71
+ * Whether any persona in this chat is currently processing a message. The
72
+ * stop button uses this to enable itself, since a persona can be processing
73
+ * (thinking, running tools, awaiting an agent turn) without actively writing.
74
+ */
75
+ get processing(): boolean;
47
76
  /** Apply a `personas` event payload. */
48
77
  updatePersonas(personas: PersonaOption[]): void;
49
- /** Apply a `persona_state` event payload for one persona. */
78
+ /**
79
+ * Apply a `persona_state` event payload for one persona, merging it onto the
80
+ * persona's existing state (absent attributes are left unchanged).
81
+ */
50
82
  updatePersonaState(personaId: string, payload: PersonaStatePayload): void;
51
83
  get isDisposed(): boolean;
52
84
  dispose(): void;
@@ -64,7 +96,7 @@ export declare class PersonaManagerSessionState implements IDisposable {
64
96
  * controls and the slash-command provider.
65
97
  */
66
98
  export declare class PersonaSessionRegistry {
67
- constructor(eventListener: IEventListener);
99
+ constructor(events: Event.IManager);
68
100
  /**
69
101
  * Get (or create) the manager session state for a chat. Components call this
70
102
  * with their chat's stable id (`IChatModel.id` / `IChatContext.id`).
@@ -11,12 +11,29 @@ export const PERSONA_SELECTED_EVENT_SCHEMA_ID = 'https://schema.jupyter.org/jupy
11
11
  */
12
12
  export class PersonaSessionState {
13
13
  constructor(id, payload = {}) {
14
- var _a, _b, _c, _d;
14
+ var _a, _b, _c, _d, _e;
15
15
  this.id = id;
16
16
  this.model = (_a = payload.model) !== null && _a !== void 0 ? _a : EMPTY_MODEL_CONFIGURATION;
17
17
  this.settings = (_b = payload.settings) !== null && _b !== void 0 ? _b : [];
18
18
  this.usage = { ...EMPTY_USAGE, ...((_c = payload.usage) !== null && _c !== void 0 ? _c : {}) };
19
19
  this.slash_commands = (_d = payload.slash_commands) !== null && _d !== void 0 ? _d : [];
20
+ this.processing = (_e = payload.processing) !== null && _e !== void 0 ? _e : false;
21
+ }
22
+ /**
23
+ * Merge a `persona_state` event payload onto this state, returning a new
24
+ * instance. Attributes the event omits are carried forward unchanged, so a
25
+ * partial event (e.g. usage-only) replaces only what it carries. A new
26
+ * reference is returned so React consumers re-render.
27
+ */
28
+ withUpdate(payload) {
29
+ var _a, _b, _c, _d, _e;
30
+ return new PersonaSessionState(this.id, {
31
+ model: (_a = payload.model) !== null && _a !== void 0 ? _a : this.model,
32
+ settings: (_b = payload.settings) !== null && _b !== void 0 ? _b : this.settings,
33
+ usage: (_c = payload.usage) !== null && _c !== void 0 ? _c : this.usage,
34
+ slash_commands: (_d = payload.slash_commands) !== null && _d !== void 0 ? _d : this.slash_commands,
35
+ processing: (_e = payload.processing) !== null && _e !== void 0 ? _e : this.processing
36
+ });
20
37
  }
21
38
  }
22
39
  /**
@@ -49,15 +66,35 @@ export class PersonaManagerSessionState {
49
66
  getPersona(id) {
50
67
  return this._states.get(id);
51
68
  }
69
+ /**
70
+ * Whether any persona in this chat is currently processing a message. The
71
+ * stop button uses this to enable itself, since a persona can be processing
72
+ * (thinking, running tools, awaiting an agent turn) without actively writing.
73
+ */
74
+ get processing() {
75
+ for (const state of this._states.values()) {
76
+ if (state.processing) {
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
52
82
  /** Apply a `personas` event payload. */
53
83
  updatePersonas(personas) {
54
84
  this._personasReceived = true;
55
85
  this._personas = personas;
56
86
  this._changed.emit();
57
87
  }
58
- /** Apply a `persona_state` event payload for one persona. */
88
+ /**
89
+ * Apply a `persona_state` event payload for one persona, merging it onto the
90
+ * persona's existing state (absent attributes are left unchanged).
91
+ */
59
92
  updatePersonaState(personaId, payload) {
60
- this._states.set(personaId, new PersonaSessionState(personaId, payload));
93
+ const prev = this._states.get(personaId);
94
+ const next = prev
95
+ ? prev.withUpdate(payload)
96
+ : new PersonaSessionState(personaId, payload);
97
+ this._states.set(personaId, next);
61
98
  this._changed.emit();
62
99
  }
63
100
  get isDisposed() {
@@ -80,15 +117,15 @@ export class PersonaManagerSessionState {
80
117
  * controls and the slash-command provider.
81
118
  */
82
119
  export class PersonaSessionRegistry {
83
- constructor(eventListener) {
84
- this._onPersonasEvent = async (_manager, _schemaId, event) => {
120
+ constructor(events) {
121
+ this._onPersonasEvent = async (event) => {
85
122
  const data = event;
86
123
  if (!data.chat_id) {
87
124
  return;
88
125
  }
89
126
  this.get(data.chat_id).updatePersonas(Array.isArray(data.personas) ? data.personas : []);
90
127
  };
91
- this._onPersonaStateEvent = async (_manager, _schemaId, event) => {
128
+ this._onPersonaStateEvent = async (event) => {
92
129
  const data = event;
93
130
  if (!data.chat_id || !data.persona_id) {
94
131
  return;
@@ -96,8 +133,18 @@ export class PersonaSessionRegistry {
96
133
  this.get(data.chat_id).updatePersonaState(data.persona_id, data);
97
134
  };
98
135
  this._byChatId = new Map();
99
- eventListener.addListener(PERSONAS_EVENT_SCHEMA_ID, this._onPersonasEvent);
100
- eventListener.addListener(PERSONA_STATE_EVENT_SCHEMA_ID, this._onPersonaStateEvent);
136
+ // The ServiceManager event bus (JupyterLab >= 4.0) exposes a single shared
137
+ // stream of all Jupyter Events; filter it by schema id to route the two
138
+ // persona event types. This supersedes the former `jupyterlab-eventlistener`
139
+ // dependency.
140
+ events.stream.connect((_, emission) => {
141
+ if (emission.schema_id === PERSONAS_EVENT_SCHEMA_ID) {
142
+ void this._onPersonasEvent(emission);
143
+ }
144
+ else if (emission.schema_id === PERSONA_STATE_EVENT_SCHEMA_ID) {
145
+ void this._onPersonaStateEvent(emission);
146
+ }
147
+ });
101
148
  }
102
149
  /**
103
150
  * Get (or create) the manager session state for a chat. Components call this
@@ -1,7 +1,19 @@
1
1
  import { InputToolbarRegistry } from '@jupyter/chat';
2
+ import { PersonaSessionRegistry } from './persona-events';
2
3
  /**
3
- * A stop button for the chat input toolbar. Observes the chat model's
4
- * writers list to enable itself when an AI persona is actively writing,
5
- * and calls the persona cancel endpoint on click.
4
+ * A stop button for the chat input toolbar. Enables itself while a persona in
5
+ * the chat is processing a message — tracked via the persona-state events fed
6
+ * into the `PersonaSessionRegistry` — and calls the persona cancel endpoint on
7
+ * click.
8
+ *
9
+ * This tracks *processing* rather than the chat's writers list: a persona can
10
+ * be processing (thinking, running tools, awaiting an agent turn) without
11
+ * actively writing, and should be interruptible the whole time.
6
12
  */
7
- export declare function StopButton(props: InputToolbarRegistry.IToolbarItemProps): JSX.Element;
13
+ export declare function StopButton(props: InputToolbarRegistry.IToolbarItemProps & {
14
+ /**
15
+ * The per-chat persona session-state registry (fed by Jupyter Events).
16
+ * Optional so the button still renders (permanently disabled) without it.
17
+ */
18
+ sessionRegistry?: PersonaSessionRegistry;
19
+ }): JSX.Element;
@@ -4,39 +4,62 @@ import { TooltippedIconButton } from '@jupyter/chat';
4
4
  import { cancelResponse } from './request';
5
5
  const STOP_BUTTON_CLASS = 'jp-jai-stopButton';
6
6
  /**
7
- * A stop button for the chat input toolbar. Observes the chat model's
8
- * writers list to enable itself when an AI persona is actively writing,
9
- * and calls the persona cancel endpoint on click.
7
+ * A stop button for the chat input toolbar. Enables itself while a persona in
8
+ * the chat is processing a message — tracked via the persona-state events fed
9
+ * into the `PersonaSessionRegistry` — and calls the persona cancel endpoint on
10
+ * click.
11
+ *
12
+ * This tracks *processing* rather than the chat's writers list: a persona can
13
+ * be processing (thinking, running tools, awaiting an agent turn) without
14
+ * actively writing, and should be interruptible the whole time.
10
15
  */
11
16
  export function StopButton(props) {
12
- const { chatModel } = props;
17
+ var _a;
18
+ const { chatModel, sessionRegistry } = props;
13
19
  const [disabled, setDisabled] = useState(true);
14
20
  const [inFlight, setInFlight] = useState(false);
15
21
  const tooltip = 'Stop generating';
22
+ // The chat's stable id is assigned asynchronously (once the model is
23
+ // `ready`), so track it in state and update it when the model becomes ready.
24
+ const [chatId, setChatId] = useState((_a = chatModel === null || chatModel === void 0 ? void 0 : chatModel.id) !== null && _a !== void 0 ? _a : null);
16
25
  useEffect(() => {
17
- var _a;
18
26
  if (!chatModel) {
19
- setDisabled(true);
27
+ setChatId(null);
20
28
  return;
21
29
  }
22
- const checkWriters = () => {
23
- const hasAIWriter = chatModel.writers.some(w => w.user.bot);
24
- setDisabled(!hasAIWriter);
25
- };
26
- checkWriters();
27
- (_a = chatModel.writersChanged) === null || _a === void 0 ? void 0 : _a.connect(checkWriters);
30
+ let cancelled = false;
31
+ void chatModel.ready.then(id => {
32
+ if (!cancelled) {
33
+ setChatId(id !== null && id !== void 0 ? id : null);
34
+ }
35
+ });
28
36
  return () => {
29
- var _a;
30
- (_a = chatModel.writersChanged) === null || _a === void 0 ? void 0 : _a.disconnect(checkWriters);
37
+ cancelled = true;
31
38
  };
32
39
  }, [chatModel]);
40
+ // Enable the button while any persona in this chat is processing. The
41
+ // manager session state fires `changed` on every persona-state update, which
42
+ // includes the processing transitions emitted by `track_processing`.
43
+ useEffect(() => {
44
+ if (!sessionRegistry || !chatId) {
45
+ setDisabled(true);
46
+ return;
47
+ }
48
+ const managerState = sessionRegistry.get(chatId);
49
+ const sync = () => setDisabled(!managerState.processing);
50
+ sync();
51
+ managerState.changed.connect(sync);
52
+ return () => {
53
+ managerState.changed.disconnect(sync);
54
+ };
55
+ }, [sessionRegistry, chatId]);
33
56
  async function handleStop() {
34
- if (!(chatModel === null || chatModel === void 0 ? void 0 : chatModel.id)) {
57
+ if (!chatId) {
35
58
  return;
36
59
  }
37
60
  setInFlight(true);
38
61
  try {
39
- await cancelResponse(chatModel.id);
62
+ await cancelResponse(chatId);
40
63
  }
41
64
  finally {
42
65
  setInFlight(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter-ai/persona-manager",
3
- "version": "0.2.0-a6",
3
+ "version": "0.2.0",
4
4
  "description": "The core manager & registry for AI personas in Jupyter AI",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -61,8 +61,7 @@
61
61
  "@jupyter/chat": "^0.25.0-alpha.5",
62
62
  "@jupyterlab/application": "^4.0.0",
63
63
  "@jupyterlab/coreutils": "^6.0.0",
64
- "@jupyterlab/services": "^7.0.0",
65
- "jupyterlab-eventlistener": "^0.4.0"
64
+ "@jupyterlab/services": "^7.0.0"
66
65
  },
67
66
  "devDependencies": {
68
67
  "@jupyterlab/builder": "^4.0.0",
@@ -3,7 +3,8 @@
3
3
  * per-chat routing, the `changed` signal, and discarding a chat's state when it
4
4
  * closes.
5
5
  */
6
- import { IEventListener } from 'jupyterlab-eventlistener';
6
+ import { Event } from '@jupyterlab/services';
7
+ import { Signal } from '@lumino/signaling';
7
8
 
8
9
  import {
9
10
  PERSONAS_EVENT_SCHEMA_ID,
@@ -11,31 +12,26 @@ import {
11
12
  PersonaSessionRegistry
12
13
  } from '../persona-events';
13
14
 
14
- type Listener = (m: unknown, s: string, e: any) => Promise<void>;
15
-
16
- /** A stand-in for IEventListener that lets tests emit events to listeners. */
17
- class FakeEventListener {
18
- private _bySchema = new Map<string, Listener[]>();
15
+ /**
16
+ * A stand-in for the ServiceManager event bus (`Event.IManager`) that lets
17
+ * tests push events onto the shared stream the registry subscribes to.
18
+ */
19
+ class FakeEventManager {
20
+ readonly stream = new Signal<this, Event.Emission>(this);
19
21
 
20
- addListener(schemaId: string, listener: Listener): void {
21
- const list = this._bySchema.get(schemaId) ?? [];
22
- list.push(listener);
23
- this._bySchema.set(schemaId, list);
24
- }
22
+ /** Push an event onto the stream, mirroring the jupyter_server event bus. */
25
23
  async emit(schemaId: string, data: any): Promise<void> {
26
- for (const l of this._bySchema.get(schemaId) ?? []) {
27
- await l(null, schemaId, data);
28
- }
24
+ this.stream.emit({ schema_id: schemaId, ...data });
29
25
  }
30
26
  }
31
27
 
32
28
  function makeRegistry(): {
33
29
  registry: PersonaSessionRegistry;
34
- events: FakeEventListener;
30
+ events: FakeEventManager;
35
31
  } {
36
- const events = new FakeEventListener();
32
+ const events = new FakeEventManager();
37
33
  const registry = new PersonaSessionRegistry(
38
- events as unknown as IEventListener
34
+ events as unknown as Event.IManager
39
35
  );
40
36
  return { registry, events };
41
37
  }
@@ -66,6 +62,38 @@ describe('PersonaSessionRegistry', () => {
66
62
  expect(state?.usage.input_tokens).toBe(5);
67
63
  });
68
64
 
65
+ it('merges partial events, leaving unset attributes unchanged', async () => {
66
+ const { registry, events } = makeRegistry();
67
+ // First event sets the model.
68
+ await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
69
+ chat_id: 'a.chat',
70
+ persona_id: 'p1',
71
+ model: { current: 'm1', options: [], settings: [] }
72
+ });
73
+ // A usage-only event must not drop the previously-set model.
74
+ await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
75
+ chat_id: 'a.chat',
76
+ persona_id: 'p1',
77
+ usage: { input_tokens: 5 }
78
+ });
79
+ const state = registry.get('a.chat').getPersona('p1');
80
+ expect(state?.model.current).toBe('m1');
81
+ expect(state?.usage.input_tokens).toBe(5);
82
+
83
+ // A processing-only event likewise preserves model and usage.
84
+ await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
85
+ chat_id: 'a.chat',
86
+ persona_id: 'p1',
87
+ processing: true
88
+ });
89
+ const after = registry.get('a.chat').getPersona('p1');
90
+ expect(after?.model.current).toBe('m1');
91
+ expect(after?.usage.input_tokens).toBe(5);
92
+ expect(after?.processing).toBe(true);
93
+ // A new instance is produced on each merge, so React consumers re-render.
94
+ expect(after).not.toBe(state);
95
+ });
96
+
69
97
  it('fires the changed signal on updates', async () => {
70
98
  const { registry, events } = makeRegistry();
71
99
  const managerState = registry.get('a.chat');
@@ -84,6 +112,47 @@ describe('PersonaSessionRegistry', () => {
84
112
  expect(fired).toBe(2);
85
113
  });
86
114
 
115
+ it('tracks per-persona processing and exposes chat-level processing', async () => {
116
+ const { registry, events } = makeRegistry();
117
+ const managerState = registry.get('a.chat');
118
+ expect(managerState.processing).toBe(false);
119
+
120
+ // A persona reports it started processing.
121
+ await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
122
+ chat_id: 'a.chat',
123
+ persona_id: 'p1',
124
+ processing: true
125
+ });
126
+ expect(managerState.getPersona('p1')?.processing).toBe(true);
127
+ expect(managerState.processing).toBe(true);
128
+
129
+ // A second, idle persona doesn't flip the chat back to idle.
130
+ await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
131
+ chat_id: 'a.chat',
132
+ persona_id: 'p2',
133
+ processing: false
134
+ });
135
+ expect(managerState.processing).toBe(true);
136
+
137
+ // The processing persona reports it finished; chat is now idle.
138
+ await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
139
+ chat_id: 'a.chat',
140
+ persona_id: 'p1',
141
+ processing: false
142
+ });
143
+ expect(managerState.processing).toBe(false);
144
+ });
145
+
146
+ it('defaults persona processing to false when the field is absent', async () => {
147
+ const { registry, events } = makeRegistry();
148
+ await events.emit(PERSONA_STATE_EVENT_SCHEMA_ID, {
149
+ chat_id: 'a.chat',
150
+ persona_id: 'p1'
151
+ });
152
+ expect(registry.get('a.chat').getPersona('p1')?.processing).toBe(false);
153
+ expect(registry.get('a.chat').processing).toBe(false);
154
+ });
155
+
87
156
  it('discards a chat session state on close, freeing memory', async () => {
88
157
  const { registry, events } = makeRegistry();
89
158
  await events.emit(PERSONAS_EVENT_SCHEMA_ID, {
package/src/index.ts CHANGED
@@ -9,8 +9,6 @@ import {
9
9
  InputToolbarRegistry
10
10
  } from '@jupyter/chat';
11
11
 
12
- import { IEventListener } from 'jupyterlab-eventlistener';
13
-
14
12
  import { PersonaControls } from './persona-controls';
15
13
  import {
16
14
  IPersonaControlRegistry,
@@ -51,7 +49,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
51
49
 
52
50
  /**
53
51
  * Plugin that provides the shared per-chat persona session-state registry,
54
- * fed by Jupyter Events via `jupyterlab-eventlistener`.
52
+ * fed by Jupyter Events via the `ServiceManager` event bus.
55
53
  */
56
54
  const sessionRegistryPlugin: JupyterFrontEndPlugin<PersonaSessionRegistry> = {
57
55
  id: '@jupyter-ai/persona-manager:session-registry',
@@ -59,12 +57,8 @@ const sessionRegistryPlugin: JupyterFrontEndPlugin<PersonaSessionRegistry> = {
59
57
  'Provides the per-chat persona session-state registry (fed by Jupyter Events).',
60
58
  autoStart: true,
61
59
  provides: IPersonaSessionRegistry,
62
- requires: [IEventListener],
63
- activate: (
64
- app: JupyterFrontEnd,
65
- eventListener: IEventListener
66
- ): PersonaSessionRegistry => {
67
- return new PersonaSessionRegistry(eventListener);
60
+ activate: (app: JupyterFrontEnd): PersonaSessionRegistry => {
61
+ return new PersonaSessionRegistry(app.serviceManager.events);
68
62
  }
69
63
  };
70
64
 
@@ -133,6 +127,11 @@ const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory> = {
133
127
  sessionRegistry,
134
128
  events
135
129
  });
130
+ // Wrap the stop button to inject the session registry, so it can enable
131
+ // itself while a persona in the chat is processing a message.
132
+ const StopButtonItem = (
133
+ itemProps: InputToolbarRegistry.IToolbarItemProps
134
+ ) => StopButton({ ...itemProps, sessionRegistry });
136
135
  return {
137
136
  create: () => {
138
137
  // Start with the default toolbar (Send, Attach, Cancel, SaveEdit)
@@ -146,7 +145,7 @@ const toolbarPlugin: JupyterFrontEndPlugin<IInputToolbarRegistryFactory> = {
146
145
  // toolbar's cancel button (10); a tie would leave the order to
147
146
  // insertion rather than position.
148
147
  registry.addItem('stop', {
149
- element: StopButton,
148
+ element: StopButtonItem,
150
149
  position: 7
151
150
  });
152
151
  return registry;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Frontend persona session state, delivered over Jupyter Events (via
3
- * `jupyterlab-eventlistener`), replacing the previous Yjs-awareness channel.
2
+ * Frontend persona session state, delivered over Jupyter Events (via the
3
+ * `ServiceManager` event bus), replacing the previous Yjs-awareness channel.
4
4
  *
5
5
  * Flow: the server emits `personas` / `persona_state` events (each carrying the
6
6
  * chat's stable `chat_id`); the `PersonaSessionRegistry` routes them to the
@@ -12,7 +12,6 @@
12
12
  * `PersonaSessionState`).
13
13
  */
14
14
  import { Event } from '@jupyterlab/services';
15
- import { IEventListener } from 'jupyterlab-eventlistener';
16
15
  import { Token } from '@lumino/coreutils';
17
16
  import { IDisposable } from '@lumino/disposable';
18
17
  import { ISignal, Signal } from '@lumino/signaling';
@@ -42,6 +41,7 @@ type PersonaStatePayload = {
42
41
  settings?: SettingConfiguration[];
43
42
  usage?: Usage;
44
43
  slash_commands?: CommandOption[];
44
+ processing?: boolean;
45
45
  };
46
46
 
47
47
  /** The wire shape of a `personas` event. */
@@ -64,12 +64,31 @@ export class PersonaSessionState {
64
64
  this.settings = payload.settings ?? [];
65
65
  this.usage = { ...EMPTY_USAGE, ...(payload.usage ?? {}) };
66
66
  this.slash_commands = payload.slash_commands ?? [];
67
+ this.processing = payload.processing ?? false;
67
68
  }
68
69
 
69
70
  readonly model: ModelConfiguration;
70
71
  readonly settings: SettingConfiguration[];
71
72
  readonly usage: Usage;
72
73
  readonly slash_commands: CommandOption[];
74
+ /** Whether the persona is currently processing a message. */
75
+ readonly processing: boolean;
76
+
77
+ /**
78
+ * Merge a `persona_state` event payload onto this state, returning a new
79
+ * instance. Attributes the event omits are carried forward unchanged, so a
80
+ * partial event (e.g. usage-only) replaces only what it carries. A new
81
+ * reference is returned so React consumers re-render.
82
+ */
83
+ withUpdate(payload: PersonaStatePayload): PersonaSessionState {
84
+ return new PersonaSessionState(this.id, {
85
+ model: payload.model ?? this.model,
86
+ settings: payload.settings ?? this.settings,
87
+ usage: payload.usage ?? this.usage,
88
+ slash_commands: payload.slash_commands ?? this.slash_commands,
89
+ processing: payload.processing ?? this.processing
90
+ });
91
+ }
73
92
  }
74
93
 
75
94
  /**
@@ -100,6 +119,20 @@ export class PersonaManagerSessionState implements IDisposable {
100
119
  return this._states.get(id);
101
120
  }
102
121
 
122
+ /**
123
+ * Whether any persona in this chat is currently processing a message. The
124
+ * stop button uses this to enable itself, since a persona can be processing
125
+ * (thinking, running tools, awaiting an agent turn) without actively writing.
126
+ */
127
+ get processing(): boolean {
128
+ for (const state of this._states.values()) {
129
+ if (state.processing) {
130
+ return true;
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+
103
136
  /** Apply a `personas` event payload. */
104
137
  updatePersonas(personas: PersonaOption[]): void {
105
138
  this._personasReceived = true;
@@ -107,9 +140,16 @@ export class PersonaManagerSessionState implements IDisposable {
107
140
  this._changed.emit();
108
141
  }
109
142
 
110
- /** Apply a `persona_state` event payload for one persona. */
143
+ /**
144
+ * Apply a `persona_state` event payload for one persona, merging it onto the
145
+ * persona's existing state (absent attributes are left unchanged).
146
+ */
111
147
  updatePersonaState(personaId: string, payload: PersonaStatePayload): void {
112
- this._states.set(personaId, new PersonaSessionState(personaId, payload));
148
+ const prev = this._states.get(personaId);
149
+ const next = prev
150
+ ? prev.withUpdate(payload)
151
+ : new PersonaSessionState(personaId, payload);
152
+ this._states.set(personaId, next);
113
153
  this._changed.emit();
114
154
  }
115
155
 
@@ -141,15 +181,18 @@ export class PersonaManagerSessionState implements IDisposable {
141
181
  * controls and the slash-command provider.
142
182
  */
143
183
  export class PersonaSessionRegistry {
144
- constructor(eventListener: IEventListener) {
145
- eventListener.addListener(
146
- PERSONAS_EVENT_SCHEMA_ID,
147
- this._onPersonasEvent as any
148
- );
149
- eventListener.addListener(
150
- PERSONA_STATE_EVENT_SCHEMA_ID,
151
- this._onPersonaStateEvent as any
152
- );
184
+ constructor(events: Event.IManager) {
185
+ // The ServiceManager event bus (JupyterLab >= 4.0) exposes a single shared
186
+ // stream of all Jupyter Events; filter it by schema id to route the two
187
+ // persona event types. This supersedes the former `jupyterlab-eventlistener`
188
+ // dependency.
189
+ events.stream.connect((_, emission) => {
190
+ if (emission.schema_id === PERSONAS_EVENT_SCHEMA_ID) {
191
+ void this._onPersonasEvent(emission);
192
+ } else if (emission.schema_id === PERSONA_STATE_EVENT_SCHEMA_ID) {
193
+ void this._onPersonaStateEvent(emission);
194
+ }
195
+ });
153
196
  }
154
197
 
155
198
  /**
@@ -182,11 +225,7 @@ export class PersonaSessionRegistry {
182
225
  }
183
226
  }
184
227
 
185
- private _onPersonasEvent = async (
186
- _manager: unknown,
187
- _schemaId: string,
188
- event: Event.Emission
189
- ): Promise<void> => {
228
+ private _onPersonasEvent = async (event: Event.Emission): Promise<void> => {
190
229
  const data = event as PersonasPayload;
191
230
  if (!data.chat_id) {
192
231
  return;
@@ -197,8 +236,6 @@ export class PersonaSessionRegistry {
197
236
  };
198
237
 
199
238
  private _onPersonaStateEvent = async (
200
- _manager: unknown,
201
- _schemaId: string,
202
239
  event: Event.Emission
203
240
  ): Promise<void> => {
204
241
  const data = event as PersonaStatePayload;
@@ -2,49 +2,78 @@ import React, { useEffect, useState } from 'react';
2
2
  import StopIcon from '@mui/icons-material/Stop';
3
3
  import { InputToolbarRegistry, TooltippedIconButton } from '@jupyter/chat';
4
4
  import { cancelResponse } from './request';
5
+ import { PersonaSessionRegistry } from './persona-events';
5
6
 
6
7
  const STOP_BUTTON_CLASS = 'jp-jai-stopButton';
7
8
 
8
9
  /**
9
- * A stop button for the chat input toolbar. Observes the chat model's
10
- * writers list to enable itself when an AI persona is actively writing,
11
- * and calls the persona cancel endpoint on click.
10
+ * A stop button for the chat input toolbar. Enables itself while a persona in
11
+ * the chat is processing a message — tracked via the persona-state events fed
12
+ * into the `PersonaSessionRegistry` — and calls the persona cancel endpoint on
13
+ * click.
14
+ *
15
+ * This tracks *processing* rather than the chat's writers list: a persona can
16
+ * be processing (thinking, running tools, awaiting an agent turn) without
17
+ * actively writing, and should be interruptible the whole time.
12
18
  */
13
19
  export function StopButton(
14
- props: InputToolbarRegistry.IToolbarItemProps
20
+ props: InputToolbarRegistry.IToolbarItemProps & {
21
+ /**
22
+ * The per-chat persona session-state registry (fed by Jupyter Events).
23
+ * Optional so the button still renders (permanently disabled) without it.
24
+ */
25
+ sessionRegistry?: PersonaSessionRegistry;
26
+ }
15
27
  ): JSX.Element {
16
- const { chatModel } = props;
28
+ const { chatModel, sessionRegistry } = props;
17
29
  const [disabled, setDisabled] = useState(true);
18
30
  const [inFlight, setInFlight] = useState(false);
19
31
  const tooltip = 'Stop generating';
20
32
 
33
+ // The chat's stable id is assigned asynchronously (once the model is
34
+ // `ready`), so track it in state and update it when the model becomes ready.
35
+ const [chatId, setChatId] = useState<string | null>(chatModel?.id ?? null);
21
36
  useEffect(() => {
22
37
  if (!chatModel) {
23
- setDisabled(true);
38
+ setChatId(null);
24
39
  return;
25
40
  }
26
-
27
- const checkWriters = () => {
28
- const hasAIWriter = chatModel.writers.some(w => w.user.bot);
29
- setDisabled(!hasAIWriter);
41
+ let cancelled = false;
42
+ void chatModel.ready.then(id => {
43
+ if (!cancelled) {
44
+ setChatId(id ?? null);
45
+ }
46
+ });
47
+ return () => {
48
+ cancelled = true;
30
49
  };
50
+ }, [chatModel]);
31
51
 
32
- checkWriters();
33
- chatModel.writersChanged?.connect(checkWriters);
34
-
52
+ // Enable the button while any persona in this chat is processing. The
53
+ // manager session state fires `changed` on every persona-state update, which
54
+ // includes the processing transitions emitted by `track_processing`.
55
+ useEffect(() => {
56
+ if (!sessionRegistry || !chatId) {
57
+ setDisabled(true);
58
+ return;
59
+ }
60
+ const managerState = sessionRegistry.get(chatId);
61
+ const sync = () => setDisabled(!managerState.processing);
62
+ sync();
63
+ managerState.changed.connect(sync);
35
64
  return () => {
36
- chatModel.writersChanged?.disconnect(checkWriters);
65
+ managerState.changed.disconnect(sync);
37
66
  };
38
- }, [chatModel]);
67
+ }, [sessionRegistry, chatId]);
39
68
 
40
69
  async function handleStop() {
41
- if (!chatModel?.id) {
70
+ if (!chatId) {
42
71
  return;
43
72
  }
44
73
 
45
74
  setInFlight(true);
46
75
  try {
47
- await cancelResponse(chatModel.id);
76
+ await cancelResponse(chatId);
48
77
  } finally {
49
78
  setInFlight(false);
50
79
  }