@in-the-loop-labs/pair-review 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,7 @@
31
31
  - [Customization](#customization)
32
32
  - [Review Feedback Export](#review-feedback-export)
33
33
  - [Inline Comments](#inline-comments)
34
+ - [Comment Format](#comment-format)
34
35
  - [Local Mode](#local-mode)
35
36
  - [Claude Code Plugins](#claude-code-plugins)
36
37
  - [MCP Integration](#mcp-integration)
@@ -235,6 +236,22 @@ On first run, pair-review creates `~/.pair-review/config.example.json` with comp
235
236
 
236
237
  For advanced configuration with custom providers and models, see [AI Provider Configuration](#ai-provider-configuration) below.
237
238
 
239
+ ### Configuration Files
240
+
241
+ pair-review loads configuration from multiple files, merged in order of increasing precedence:
242
+
243
+ | Priority | File | Purpose |
244
+ |----------|------|---------|
245
+ | 1 (lowest) | Built-in defaults | Sensible defaults for all settings |
246
+ | 2 | `~/.pair-review/config.json` | Global user configuration |
247
+ | 3 | `~/.pair-review/config.local.json` | Personal overrides (gitignored) |
248
+ | 4 | `.pair-review/config.json` | Project-specific configuration (can be checked in) |
249
+ | 5 (highest) | `.pair-review/config.local.json` | Personal project overrides (gitignored) |
250
+
251
+ Nested objects (like `chat`, `providers`, `monorepos`) are deep-merged across layers — you only need to specify the keys you want to override.
252
+
253
+ **`config.local.json`** files are intended for personal overrides that should not be committed to version control. Add `config.local.json` to your `.gitignore`.
254
+
238
255
  ### Environment Variables
239
256
 
240
257
  pair-review supports several environment variables for customizing behavior:
@@ -484,6 +501,66 @@ The markdown includes file paths, line numbers, and your comments - everything t
484
501
  - Edit or discard AI suggestions before finalizing
485
502
  - Comments include file and line number for precision
486
503
 
504
+ ### Comment Format
505
+
506
+ When AI suggestions are adopted as review comments, pair-review formats them with an emoji and category prefix by default. You can customize this format via the `comment_format` setting in `~/.pair-review/config.json`.
507
+
508
+ **Presets:**
509
+
510
+ | Preset | Template | Example Output (without suggestion)|
511
+ |--------|----------|---------------|
512
+ | `legacy` | `{emoji} **{category}**: {description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}` | 🐛 **Bug**: Missing null check |
513
+ | `minimal` | `[{category}] {description}{?suggestion}\n\n{suggestion}{/suggestion}` | [Bug] Missing null check |
514
+ | `plain` | `{description}{?suggestion}\n\n{suggestion}{/suggestion}` | Missing null check |
515
+ | `emoji-only` | `{emoji} {description}{?suggestion}\n\n{suggestion}{/suggestion}` | 🐛 Missing null check |
516
+ | `maximal` | `{emoji} **{category}**{?title}: {title}{/title}\n\n{description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}` | 🐛 **Bug**: Null Safety Issue (includes title) |
517
+
518
+ To use a preset:
519
+
520
+ ```json
521
+ {
522
+ "comment_format": "minimal"
523
+ }
524
+ ```
525
+
526
+ **Custom templates:**
527
+
528
+ You can provide a custom template using these placeholders: `{emoji}`, `{category}`, `{title}`, `{description}`, `{suggestion}`.
529
+
530
+ **Conditional sections:** Use `{?field}...{/field}` to conditionally include content. When the field value is truthy, the delimiters are stripped and the content is kept. When the field is empty/null/undefined, the entire block (including surrounding text within the delimiters) is removed. For example, `{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}` will omit the entire suggestion line when there is no remediation text.
531
+
532
+ ```json
533
+ {
534
+ "comment_format": {
535
+ "template": "{emoji} **{category}**: {description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}",
536
+ "emojiOverrides": {
537
+ "bug": "🔴"
538
+ }
539
+ }
540
+ }
541
+ ```
542
+
543
+ **Category overrides:**
544
+
545
+ Use `categoryOverrides` to rename categories in the formatted output. This is a string-to-string mapping where keys are the original category names (lowercase) and values are the replacement names.
546
+
547
+ ```json
548
+ {
549
+ "comment_format": {
550
+ "template": "{emoji} **{category}**: {description}{?suggestion}\n\n**Suggestion:** {suggestion}{/suggestion}",
551
+ "categoryOverrides": { "bug": "defect", "performance": "perf" }
552
+ }
553
+ }
554
+ ```
555
+
556
+ - `{title}` uses the suggestion's title field if available.
557
+ - `emojiOverrides` lets you replace the default emoji for specific categories.
558
+ - `categoryOverrides` lets you rename categories (e.g., "bug" to "defect").
559
+
560
+ Templates typically include `{description}` to render the suggestion body.
561
+
562
+ **Builtin categories:** bug, improvement, praise, suggestion, design, performance, security, code-style
563
+
487
564
  ### Local Mode
488
565
 
489
566
  Review **unstaged**, uncommitted changes before creating a PR:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -67,7 +67,8 @@
67
67
  "open": "^9.1.0",
68
68
  "simple-git": "^3.19.1",
69
69
  "update-notifier": "^5.1.0",
70
- "uuid": "^11.1.0"
70
+ "uuid": "^11.1.0",
71
+ "ws": "^8.19.0"
71
72
  },
72
73
  "devDependencies": {
73
74
  "@changesets/cli": "^2.29.8",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -18,8 +18,8 @@ class ChatPanel {
18
18
  this.reviewId = null;
19
19
  this.isOpen = false;
20
20
  this.isStreaming = false;
21
- this.eventSource = null;
22
- this._sseReconnectTimer = null;
21
+ this._chatUnsub = null;
22
+ this._reviewUnsub = null;
23
23
  this.messages = [];
24
24
  this._streamingContent = '';
25
25
  this._pendingContext = [];
@@ -28,7 +28,7 @@ class ChatPanel {
28
28
  this._contextItemId = null; // suggestion ID or comment ID from context
29
29
  this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
30
30
  this._pendingActionContext = null; // { type, itemId } — set by action button handlers, consumed by sendMessage
31
- this._resizeConfig = { min: 300, default: 400, storageKey: 'chat-panel-width' };
31
+ this._resizeConfig = ChatPanel.RESIZE_CONFIG;
32
32
  this._analysisContextRemoved = false;
33
33
  this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
34
34
  this._openPromise = null; // concurrency guard for open()
@@ -434,8 +434,8 @@ class ChatPanel {
434
434
  this.panel.classList.remove('chat-panel--closed');
435
435
  this.panel.classList.add('chat-panel--open');
436
436
 
437
- // Ensure SSE is connected (but don't create a session yet — lazy creation)
438
- this._ensureGlobalSSE();
437
+ // Ensure WebSocket subscriptions are active (but don't create a session yet — lazy creation)
438
+ this._ensureSubscriptions();
439
439
 
440
440
  // Load MRU session with message history (if any previous sessions exist).
441
441
  // Skip when opening with explicit context (suggestion/comment/file) — the
@@ -495,7 +495,7 @@ class ChatPanel {
495
495
  close() {
496
496
  this._hideSessionDropdown();
497
497
  // Reset UI streaming state (buttons) but keep isStreaming and _streamingContent
498
- // intact so the background SSE handler can continue accumulating events.
498
+ // intact so the background WebSocket handler can continue accumulating events.
499
499
  this.sendBtn.style.display = '';
500
500
  this.stopBtn.style.display = 'none';
501
501
  this.sendBtn.disabled = !this.inputEl?.value?.trim();
@@ -544,6 +544,7 @@ class ChatPanel {
544
544
  // 2. Clear everything as normal
545
545
  this._finalizeStreaming();
546
546
  this.currentSessionId = null;
547
+ this._resubscribeChat(); // Unsubscribe old chat topic
547
548
  this.messages = [];
548
549
  this._streamingContent = '';
549
550
  this._pendingContext = [];
@@ -556,7 +557,6 @@ class ChatPanel {
556
557
  this._clearMessages();
557
558
  this._updateActionButtons();
558
559
  this._updateTitle(); // Reset title for new conversation
559
- // SSE stays connected — it's multiplexed and will filter by sessionId
560
560
 
561
561
  // 3. Re-add analysis context (appears first, handled separately from pending arrays)
562
562
  this._ensureAnalysisContext();
@@ -626,6 +626,7 @@ class ChatPanel {
626
626
 
627
627
  const mru = sessions[0];
628
628
  this.currentSessionId = mru.id;
629
+ this._resubscribeChat();
629
630
  console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
630
631
 
631
632
  if (mru.provider) {
@@ -786,6 +787,7 @@ class ChatPanel {
786
787
 
787
788
  // 2. Reset state
788
789
  this.currentSessionId = sessionId;
790
+ this._resubscribeChat();
789
791
  this.messages = [];
790
792
  this._streamingContent = '';
791
793
  this._pendingContext = [];
@@ -869,12 +871,12 @@ class ChatPanel {
869
871
  }
870
872
 
871
873
  /**
872
- * Ensure the global SSE connection is active.
874
+ * Ensure WebSocket subscriptions are established for review and chat topics.
873
875
  * No longer creates sessions — that happens lazily on first message.
874
876
  * @returns {{sessionData: null}}
875
877
  */
876
878
  _ensureConnected() {
877
- this._ensureGlobalSSE();
879
+ this._ensureSubscriptions();
878
880
  return { sessionData: null };
879
881
  }
880
882
 
@@ -893,6 +895,12 @@ class ChatPanel {
893
895
  this.reviewId = reviewId;
894
896
  console.debug('[ChatPanel] Late-bound reviewId:', reviewId);
895
897
 
898
+ // Subscribe to review topic now that reviewId is available.
899
+ // _ensureSubscriptions() skips this when reviewId is null at panel open time,
900
+ // so we must subscribe here. The chat subscription is a benign no-op when
901
+ // currentSessionId is null.
902
+ this._ensureSubscriptions();
903
+
896
904
  // Re-enable input now that reviewId is available
897
905
  if (this.inputEl.disabled) {
898
906
  this._enableInput();
@@ -942,6 +950,7 @@ class ChatPanel {
942
950
 
943
951
  const result = await response.json();
944
952
  this.currentSessionId = result.data.id;
953
+ this._resubscribeChat();
945
954
  console.debug('[ChatPanel] Session created:', this.currentSessionId);
946
955
  return result.data;
947
956
  } catch (error) {
@@ -975,7 +984,7 @@ class ChatPanel {
975
984
 
976
985
  // Lazy session creation: create on first message, not on panel open
977
986
  if (!this.currentSessionId) {
978
- this._ensureGlobalSSE();
987
+ this._ensureSubscriptions();
979
988
  const sessionData = await this.createSession();
980
989
  if (!sessionData) {
981
990
  // Restore the user's message text into the input
@@ -1044,7 +1053,8 @@ class ChatPanel {
1044
1053
  if (response.status === 410) {
1045
1054
  console.debug('[ChatPanel] Session not resumable (410), creating new session and retrying');
1046
1055
  this.currentSessionId = null;
1047
- this._ensureGlobalSSE();
1056
+ this._resubscribeChat();
1057
+ this._ensureSubscriptions();
1048
1058
  const sessionData = await this.createSession();
1049
1059
  if (!sessionData) {
1050
1060
  throw new Error('Failed to create replacement session');
@@ -1061,7 +1071,7 @@ class ChatPanel {
1061
1071
  const err = await response.json().catch(() => ({}));
1062
1072
  throw new Error(err.error || 'Failed to send message');
1063
1073
  }
1064
- console.debug('[ChatPanel] Message accepted, waiting for SSE events');
1074
+ console.debug('[ChatPanel] Message accepted, waiting for WebSocket events');
1065
1075
  } catch (error) {
1066
1076
  // Restore pending context so it's not lost
1067
1077
  this._pendingContext = savedContext;
@@ -1914,122 +1924,173 @@ class ChatPanel {
1914
1924
  }
1915
1925
 
1916
1926
  /**
1917
- * Ensure the global multiplexed SSE connection is established.
1918
- * Creates the EventSource once; subsequent calls are no-ops if already connected.
1919
- * Events are filtered by sessionId to dispatch only to the active session.
1927
+ * Ensure WebSocket subscriptions are established for review and chat topics.
1928
+ * Subscribes to review events (stable for page lifetime) and chat events
1929
+ * (changes when session changes). Subsequent calls are no-ops if already subscribed.
1920
1930
  */
1921
- _ensureGlobalSSE() {
1922
- // Already connected or connecting — nothing to do
1923
- if (this.eventSource &&
1924
- this.eventSource.readyState !== EventSource.CLOSED) {
1925
- return;
1931
+ _ensureSubscriptions() {
1932
+ window.wsClient.connect();
1933
+
1934
+ // Subscribe to review events (stable for page lifetime)
1935
+ if (this.reviewId && !this._reviewUnsub) {
1936
+ this._reviewUnsub = window.wsClient.subscribe('review:' + this.reviewId, (msg) => {
1937
+ if (msg.type?.startsWith('review:')) {
1938
+ document.dispatchEvent(new CustomEvent(msg.type, {
1939
+ detail: { ...msg }
1940
+ }));
1941
+ }
1942
+ });
1926
1943
  }
1927
1944
 
1928
- // Clear any pending reconnect timer
1929
- clearTimeout(this._sseReconnectTimer);
1930
- this._sseReconnectTimer = null;
1945
+ // Subscribe to chat session
1946
+ if (this.currentSessionId && !this._chatUnsub) {
1947
+ this._chatUnsub = window.wsClient.subscribe('chat:' + this.currentSessionId, (msg) => {
1948
+ this._handleChatMessage(msg);
1949
+ });
1950
+ }
1931
1951
 
1932
- const url = '/api/chat/stream';
1933
- console.debug('[ChatPanel] Connecting multiplexed SSE:', url);
1934
- this.eventSource = new EventSource(url);
1952
+ // Listen for WebSocket reconnects — any deltas broadcast during the
1953
+ // reconnect gap are lost, so we re-fetch via HTTP to recover the stream.
1954
+ if (!this._onReconnect) {
1955
+ this._onReconnect = () => { this._recoverAfterReconnect(); };
1956
+ window.addEventListener('wsReconnected', this._onReconnect);
1957
+ }
1958
+ }
1935
1959
 
1936
- this.eventSource.onmessage = (event) => {
1937
- try {
1938
- const data = JSON.parse(event.data);
1960
+ /**
1961
+ * Recover streaming state after a WebSocket reconnect.
1962
+ * If a stream was in progress when the connection dropped, deltas broadcast
1963
+ * during the gap are lost. Re-fetch the full message history via HTTP and
1964
+ * replace the partial `_streamingContent` with the complete last assistant
1965
+ * message. When not streaming, no action is needed.
1966
+ */
1967
+ async _recoverAfterReconnect() {
1968
+ if (!this.isStreaming || !this.currentSessionId) return;
1939
1969
 
1940
- // Initial connection acknowledgement — no sessionId, just log
1941
- if (data.type === 'connected' && !data.sessionId) {
1942
- console.debug('[ChatPanel] Multiplexed SSE connected');
1943
- return;
1970
+ try {
1971
+ const response = await fetch(`/api/chat/session/${this.currentSessionId}/messages`);
1972
+ if (!response.ok) return;
1973
+
1974
+ const result = await response.json();
1975
+ const messages = result.data?.messages || [];
1976
+
1977
+ // Find the last assistant message — this is the one being streamed
1978
+ let lastAssistant = null;
1979
+ for (let i = messages.length - 1; i >= 0; i--) {
1980
+ if (messages[i].type === 'message' && messages[i].role === 'assistant') {
1981
+ lastAssistant = messages[i];
1982
+ break;
1944
1983
  }
1984
+ }
1945
1985
 
1946
- // Route review-scoped events to document as CustomEvents
1947
- if (data.reviewId && data.type?.startsWith('review:')) {
1948
- document.dispatchEvent(new CustomEvent(data.type, {
1949
- detail: { ...data }
1950
- }));
1951
- return;
1986
+ if (lastAssistant && lastAssistant.content) {
1987
+ this._streamingContent = lastAssistant.content;
1988
+ // The message is already persisted in the DB, so the stream is
1989
+ // definitively complete. Finalize rather than continuing the
1990
+ // streaming UI (which would leave the Stop button visible, etc.).
1991
+ if (this.isOpen) {
1992
+ this.finalizeStreamingMessage(lastAssistant.id);
1993
+ } else {
1994
+ this.messages.push({ role: 'assistant', content: lastAssistant.content, id: lastAssistant.id });
1995
+ this._finalizeStreaming();
1952
1996
  }
1997
+ }
1998
+ } catch (err) {
1999
+ console.warn('[ChatPanel] Failed to recover stream after reconnect:', err);
2000
+ }
2001
+ }
1953
2002
 
1954
- // Filter: only process events for the active session
1955
- if (data.sessionId !== this.currentSessionId) return;
2003
+ /**
2004
+ * Unsubscribe from the current chat topic and re-subscribe to the new one.
2005
+ * Called whenever `this.currentSessionId` changes.
2006
+ */
2007
+ _resubscribeChat() {
2008
+ if (this._chatUnsub) { this._chatUnsub(); this._chatUnsub = null; }
2009
+ if (this.currentSessionId) {
2010
+ this._chatUnsub = window.wsClient.subscribe('chat:' + this.currentSessionId, (msg) => {
2011
+ this._handleChatMessage(msg);
2012
+ });
2013
+ }
2014
+ }
1956
2015
 
1957
- if (data.type !== 'delta') {
1958
- console.debug('[ChatPanel] SSE event:', data.type, 'session:', data.sessionId);
1959
- }
2016
+ /**
2017
+ * Handles incoming WebSocket messages for the active chat session.
2018
+ * @param {Object} data - Parsed message object
2019
+ */
2020
+ _handleChatMessage(data) {
2021
+ try {
2022
+ // Assertion: WebSocket topic scoping guarantees sessionId match.
2023
+ // This warn is a safety net — if it fires, something is wrong upstream.
2024
+ if (data.sessionId !== this.currentSessionId) {
2025
+ console.warn(`[ChatPanel] Unexpected sessionId mismatch: got ${data.sessionId}, expected ${this.currentSessionId}`);
2026
+ return;
2027
+ }
1960
2028
 
1961
- // When the panel is closed, still accumulate internal state
1962
- // so messages are available when the panel reopens.
1963
- if (!this.isOpen) {
1964
- switch (data.type) {
1965
- case 'delta':
1966
- this._streamingContent += data.text;
1967
- break;
1968
- case 'complete':
1969
- if (this._streamingContent) {
1970
- this.messages.push({ role: 'assistant', content: this._streamingContent, id: data.messageId });
1971
- }
1972
- this._streamingContent = '';
1973
- this.isStreaming = false;
1974
- break;
1975
- case 'error':
1976
- this._streamingContent = '';
1977
- this.isStreaming = false;
1978
- break;
1979
- // tool_use, status: purely visual, skip when closed
1980
- }
1981
- return;
1982
- }
2029
+ if (data.type !== 'delta') {
2030
+ console.debug('[ChatPanel] WS event:', data.type, 'session:', data.sessionId);
2031
+ }
1983
2032
 
2033
+ // When the panel is closed, still accumulate internal state
2034
+ // so messages are available when the panel reopens.
2035
+ if (!this.isOpen) {
1984
2036
  switch (data.type) {
1985
2037
  case 'delta':
1986
- this._hideThinkingIndicator();
1987
2038
  this._streamingContent += data.text;
1988
- this.updateStreamingMessage(this._streamingContent);
1989
- break;
1990
-
1991
- case 'tool_use':
1992
- this._showToolUse(data.toolName, data.status, data.toolInput);
1993
2039
  break;
1994
-
1995
- case 'status':
1996
- this._handleAgentStatus(data.status);
1997
- break;
1998
-
1999
2040
  case 'complete':
2000
- this.finalizeStreamingMessage(data.messageId);
2041
+ if (this._streamingContent) {
2042
+ this.messages.push({ role: 'assistant', content: this._streamingContent, id: data.messageId });
2043
+ }
2044
+ this._streamingContent = '';
2045
+ this.isStreaming = false;
2001
2046
  break;
2002
-
2003
2047
  case 'error':
2004
- this._showError(data.message || 'An error occurred');
2005
- this._finalizeStreaming();
2048
+ this._streamingContent = '';
2049
+ this.isStreaming = false;
2006
2050
  break;
2051
+ // tool_use, status: purely visual, skip when closed
2007
2052
  }
2008
- } catch (e) {
2009
- console.error('[ChatPanel] SSE parse error:', e);
2053
+ return;
2010
2054
  }
2011
- };
2012
2055
 
2013
- this.eventSource.onerror = () => {
2014
- if (this.eventSource?.readyState === EventSource.CLOSED) {
2015
- console.warn('[ChatPanel] Multiplexed SSE connection closed, reconnecting in 2s');
2016
- this.eventSource = null;
2017
- this._sseReconnectTimer = setTimeout(() => {
2018
- this._ensureGlobalSSE();
2019
- }, 2000);
2056
+ switch (data.type) {
2057
+ case 'delta':
2058
+ this._hideThinkingIndicator();
2059
+ this._streamingContent += data.text;
2060
+ this.updateStreamingMessage(this._streamingContent);
2061
+ break;
2062
+
2063
+ case 'tool_use':
2064
+ this._showToolUse(data.toolName, data.status, data.toolInput);
2065
+ break;
2066
+
2067
+ case 'status':
2068
+ this._handleAgentStatus(data.status);
2069
+ break;
2070
+
2071
+ case 'complete':
2072
+ this.finalizeStreamingMessage(data.messageId);
2073
+ break;
2074
+
2075
+ case 'error':
2076
+ this._showError(data.message || 'An error occurred');
2077
+ this._finalizeStreaming();
2078
+ break;
2020
2079
  }
2021
- };
2080
+ } catch (e) {
2081
+ console.error('[ChatPanel] WS parse error:', e);
2082
+ }
2022
2083
  }
2023
2084
 
2024
2085
  /**
2025
- * Close the global SSE connection and cancel any reconnect timer.
2086
+ * Close all WebSocket subscriptions (chat and review).
2026
2087
  */
2027
- _closeGlobalSSE() {
2028
- clearTimeout(this._sseReconnectTimer);
2029
- this._sseReconnectTimer = null;
2030
- if (this.eventSource) {
2031
- this.eventSource.close();
2032
- this.eventSource = null;
2088
+ _closeSubscriptions() {
2089
+ if (this._chatUnsub) { this._chatUnsub(); this._chatUnsub = null; }
2090
+ if (this._reviewUnsub) { this._reviewUnsub(); this._reviewUnsub = null; }
2091
+ if (this._onReconnect) {
2092
+ window.removeEventListener('wsReconnected', this._onReconnect);
2093
+ this._onReconnect = null;
2033
2094
  }
2034
2095
  }
2035
2096
 
@@ -2930,7 +2991,7 @@ class ChatPanel {
2930
2991
  */
2931
2992
  destroy() {
2932
2993
  document.removeEventListener('keydown', this._onKeydown);
2933
- this._closeGlobalSSE();
2994
+ this._closeSubscriptions();
2934
2995
  this.messages = [];
2935
2996
 
2936
2997
  // Clean up context tooltip
@@ -2946,6 +3007,9 @@ class ChatPanel {
2946
3007
  }
2947
3008
  }
2948
3009
 
3010
+ /** Resize configuration for the chat panel, exposed as a static for cross-module use. */
3011
+ ChatPanel.RESIZE_CONFIG = { min: 300, default: 400, cssVar: '--chat-panel-width', storageKey: 'chat-panel-width' };
3012
+
2949
3013
  // Make ChatPanel available globally
2950
3014
  window.ChatPanel = ChatPanel;
2951
3015