collabmd 0.1.44 → 0.1.45

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.
Files changed (170) hide show
  1. package/dist/client/assets/{chunk-AXR3VHVW-0GTk_evH.js → chunk-ERSWUTIN-ME9RVNel.js} +3 -3
  2. package/dist/client/assets/{chunk-CZQZEIFA-DT3N9fo7.js → chunk-YGJVY4SA-CjSe7Bx7.js} +2 -2
  3. package/dist/client/assets/{comment-threads-D-iKphIp.js → comment-threads-DDegmaFO.js} +1 -1
  4. package/dist/client/assets/drawio-editor-ClE8jpoG.js +9 -0
  5. package/dist/client/assets/drawioEditor-CXaTM9Ds.js +2 -0
  6. package/dist/client/assets/editor-session-GTwZS6Df.js +16 -0
  7. package/dist/client/assets/embedded-editor-base-CiNEc8vy.css +1 -0
  8. package/dist/client/assets/{en-GADW4C6J-DQ6zyCEM.js → en-7NFCYGKX-BrgYZAEe.js} +1 -1
  9. package/dist/client/assets/{excalidraw-editor-BHu6k8d4.js → excalidraw-editor-BZVM81BO.js} +2 -2
  10. package/dist/client/assets/excalidraw-editor-CyGwOhLz.css +1 -0
  11. package/dist/client/assets/excalidraw-scene-C8n9CHsK.js +176 -0
  12. package/dist/client/assets/excalidrawEditor-DxJanCze.js +2 -0
  13. package/dist/client/assets/export-page-CUfws2T-.js +39 -0
  14. package/dist/client/assets/exportDocument-BCuxqXDY.js +2 -0
  15. package/dist/client/assets/exportDocument-DV-OX74E.css +1 -0
  16. package/dist/client/assets/image-QXRYHUNP-lfTHE3bb.js +1 -0
  17. package/dist/client/assets/index-C3YnUgMD.css +1 -0
  18. package/dist/client/assets/index-D2wXraJ1.js +2 -0
  19. package/dist/client/assets/main-DtiGz7uI.js +795 -0
  20. package/dist/client/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  21. package/dist/client/assets/preview-diagram-utils-txBCoW8d.js +1 -0
  22. package/dist/client/assets/quick-switcher-controller-shW_6vPY.js +12 -0
  23. package/dist/client/assets/room-CQLoqtMo.js +1 -0
  24. package/dist/client/assets/runtime-config-DSGmGE9V.js +1 -0
  25. package/dist/client/assets/vault-api-client-C6bcGwy-.js +5 -0
  26. package/dist/client/assets/{yjs-provider-reset-guard-Cox4nxwu.js → yjs-provider-reset-guard-DE0vINDP.js} +1 -1
  27. package/dist/client/drawio-editor.html +3 -2
  28. package/dist/client/excalidraw-editor.html +3 -2
  29. package/dist/client/export-document.html +4 -3
  30. package/dist/client/index.html +97 -27
  31. package/package.json +10 -12
  32. package/src/client/app/drawio-editor-entry.js +0 -3
  33. package/src/client/app/drawio-editor.html +1 -0
  34. package/src/client/app/excalidraw-editor-entry.js +0 -3
  35. package/src/client/app/excalidraw-editor.html +1 -0
  36. package/src/client/app/export-document-entry.js +0 -3
  37. package/src/client/app/export-document.html +1 -0
  38. package/src/client/app/index.html +94 -24
  39. package/src/client/app/main-entry.js +0 -5
  40. package/src/client/application/app-shell/chat-feature.js +180 -124
  41. package/src/client/application/app-shell/comments-feature.js +1 -1
  42. package/src/client/application/app-shell/git-feature.js +4 -9
  43. package/src/client/application/app-shell/presence-feature.js +16 -10
  44. package/src/client/application/app-shell/ui-feature-shell.js +33 -15
  45. package/src/client/application/app-shell/ui-feature-sidebar.js +74 -1
  46. package/src/client/application/app-shell/ui-feature-tab-activity.js +2 -2
  47. package/src/client/application/app-shell/workspace-feature.js +22 -83
  48. package/src/client/application/app-shell-elements.js +7 -1
  49. package/src/client/application/diagram-chrome.js +161 -166
  50. package/src/client/application/diagram-preview-export.js +7 -34
  51. package/src/client/application/diagram-preview-hydrator.js +1 -19
  52. package/src/client/application/markdown-frontmatter.js +0 -2
  53. package/src/client/application/mermaid-preview-hydrator.js +1 -14
  54. package/src/client/application/plantuml-preview-hydrator.js +0 -10
  55. package/src/client/application/preview-diagram-utils.js +31 -2
  56. package/src/client/application/preview-render-compiler.js +2 -1
  57. package/src/client/application/preview-renderer.js +2 -2
  58. package/src/client/application/quick-switcher-loader.js +3 -1
  59. package/src/client/application/workspace-preview-controller.js +0 -3
  60. package/src/client/bootstrap/collabmd-app-shell.js +125 -39
  61. package/src/client/domain/excalidraw-collaboration.js +0 -2
  62. package/src/client/domain/room.js +38 -0
  63. package/src/client/export/export-pipeline.js +6 -34
  64. package/src/client/infrastructure/auth-client.js +4 -10
  65. package/src/client/infrastructure/browser-notification-port.js +33 -20
  66. package/src/client/infrastructure/browser-preferences-port.js +36 -9
  67. package/src/client/infrastructure/editor-view-adapter.js +87 -3
  68. package/src/client/infrastructure/lobby-presence.js +1 -0
  69. package/src/client/infrastructure/vault-api-client.js +25 -0
  70. package/src/client/infrastructure/workspace-sync-client.js +13 -1
  71. package/src/client/presentation/comment-overview-controller.js +18 -53
  72. package/src/client/presentation/comment-ui/comment-ui-card.js +1 -12
  73. package/src/client/presentation/comment-ui/comment-ui-layout.js +242 -219
  74. package/src/client/presentation/comment-ui/comment-ui-render.js +32 -45
  75. package/src/client/presentation/comment-ui/comment-ui-shared.js +65 -14
  76. package/src/client/presentation/comment-ui-controller.js +5 -1
  77. package/src/client/presentation/file-action-controller.js +32 -0
  78. package/src/client/presentation/file-explorer-controller.js +33 -3
  79. package/src/client/presentation/file-explorer-view.js +49 -5
  80. package/src/client/presentation/file-tree-state.js +10 -4
  81. package/src/client/presentation/quick-switcher-controller.js +243 -72
  82. package/src/client/presentation/quick-switcher-text-search.js +2 -2
  83. package/src/client/presentation/toast-controller.js +3 -0
  84. package/src/client/styles/components/feedback.css +6 -0
  85. package/src/client/styles/features/collaboration-chat.css +49 -53
  86. package/src/client/styles/features/collaboration-presence.css +9 -7
  87. package/src/client/styles/features/comment-card.css +25 -11
  88. package/src/client/styles/features/comment-overlays.css +0 -4
  89. package/src/client/styles/features/comment-overview.css +44 -21
  90. package/src/client/styles/features/comments-drawer.css +2 -100
  91. package/src/client/styles/features/diagram-preview.css +5 -18
  92. package/src/client/styles/features/embedded-preview.css +8 -33
  93. package/src/client/styles/features/quick-switcher.css +8 -0
  94. package/src/client/styles/foundation/tokens.css +1 -0
  95. package/src/client/styles/layout/editor-page.css +40 -0
  96. package/src/client/styles/layout/responsive.css +31 -1
  97. package/src/client/styles/layout/shell.css +25 -6
  98. package/src/client/styles/layout/sidebar.css +47 -15
  99. package/src/client/styles/overrides/codemirror.css +217 -15
  100. package/src/server/application/workspace-reconciliation.js +1 -1
  101. package/src/server/create-app-server.js +2 -3
  102. package/src/server/domain/bases/base-query-service.js +1 -77
  103. package/src/server/domain/comment-overview.js +10 -3
  104. package/src/server/domain/docx-exporter.js +24 -33
  105. package/src/server/domain/ripgrep-search-service.js +3 -1
  106. package/src/server/domain/workspace-state.js +32 -11
  107. package/src/server/infrastructure/git/diff-service.js +24 -13
  108. package/src/server/infrastructure/git/git-service.js +40 -10
  109. package/src/server/infrastructure/git/history-service.js +42 -29
  110. package/src/server/infrastructure/git/path-utils.js +1 -1
  111. package/src/server/infrastructure/git/responses.js +0 -95
  112. package/src/server/infrastructure/github/github-app-client.js +22 -15
  113. package/src/server/infrastructure/http/create-request-handler.js +2 -8
  114. package/src/server/infrastructure/http/create-vault-api-command-handler.js +5 -5
  115. package/src/server/infrastructure/http/http-errors.js +7 -1
  116. package/dist/client/assets/bases-preview-controller-2pLHk0uk.js +0 -143
  117. package/dist/client/assets/button-BCEiw4Rw.js +0 -1
  118. package/dist/client/assets/diagram-action-icons-aa_KOv9h.js +0 -36
  119. package/dist/client/assets/drawio-editor-DDvFoarP.js +0 -9
  120. package/dist/client/assets/drawio-embed-controller-BD1GTRwn.js +0 -1
  121. package/dist/client/assets/drawioEditor-Bu5grlH5.js +0 -2
  122. package/dist/client/assets/editor-session-DGlpiAsK.js +0 -16
  123. package/dist/client/assets/embedded-editor-base-C9roKYpW.css +0 -1
  124. package/dist/client/assets/excalidraw-editor-Cr_184EF.css +0 -1
  125. package/dist/client/assets/excalidraw-embed-controller-y2fenN_g.js +0 -1
  126. package/dist/client/assets/excalidraw-embed-reconciler-DGiagExg.js +0 -1
  127. package/dist/client/assets/excalidraw-scene-BvsQ6mLP.js +0 -176
  128. package/dist/client/assets/excalidrawEditor-4vFE--Uw.js +0 -2
  129. package/dist/client/assets/export-page-Cby8JzPO.js +0 -39
  130. package/dist/client/assets/exportDocument-C9v-bc7I.css +0 -1
  131. package/dist/client/assets/exportDocument-CRHCfsdo.js +0 -2
  132. package/dist/client/assets/file-history-view-controller-BDjEV9xJ.js +0 -112
  133. package/dist/client/assets/git-diff-view-controller-DgdM1aqb.js +0 -150
  134. package/dist/client/assets/git-panel-controller-BDTR2HVp.js +0 -207
  135. package/dist/client/assets/image-XGD7JQR6-XDUWHRpO.js +0 -1
  136. package/dist/client/assets/index-7xOyXDhP.css +0 -1
  137. package/dist/client/assets/index-Bz1KuQ6y.js +0 -2
  138. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  139. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  140. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  141. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  142. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  143. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  144. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  145. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  146. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  147. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  148. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  149. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  150. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  151. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  152. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  153. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  154. package/dist/client/assets/main-Ci07m9oP.js +0 -152
  155. package/dist/client/assets/preview-diagram-utils-n_bbJA9V.js +0 -1
  156. package/dist/client/assets/quick-switcher-controller-CoiYvllm.js +0 -12
  157. package/dist/client/assets/room-CDrmIdDt.js +0 -1
  158. package/dist/client/assets/runtime-config-CDgeuPWN.js +0 -1
  159. package/dist/client/assets/runtime-config-loader-BHGniHP5.js +0 -1
  160. package/dist/client/assets/runtime-paths-DvpihCEq.js +0 -1
  161. package/dist/client/assets/segmented-control-DJrHSz8M.js +0 -1
  162. package/dist/client/assets/vault-api-client-sLB2bo56.js +0 -1
  163. package/dist/client/assets/vault-paths-CVkuh0j-.js +0 -5
  164. package/src/client/bootstrap/lazy-controller-feature.js +0 -433
  165. package/src/client/infrastructure/backlinks-api-client.js +0 -16
  166. package/src/client/infrastructure/plantuml-api-client.js +0 -19
  167. package/src/client/infrastructure/runtime-config-loader.js +0 -56
  168. package/src/server/infrastructure/git/command-runner.js +0 -49
  169. package/src/server/infrastructure/git/errors.js +0 -8
  170. package/src/server/infrastructure/http/create-esm-proxy-handler.js +0 -130
@@ -1,3 +1,41 @@
1
+ import { getUserAvatarTextColor } from '../../domain/room.js';
2
+
3
+ const CHAT_ALERT_MUTE_STORAGE_KEY = 'collabmd-chat-alerts-muted-until';
4
+ const CHAT_ALERT_MUTE_DURATION_MS = 60 * 60 * 1000;
5
+
6
+ function getChatConnectionState(context) {
7
+ return context.lobby?.getConnectionState?.() ?? { status: 'connected', unreachable: false };
8
+ }
9
+
10
+ function isChatAtBottom(list) {
11
+ return list.scrollHeight - list.scrollTop - list.clientHeight <= 24;
12
+ }
13
+
14
+ function getChatAlertMuteUntil() {
15
+ try {
16
+ const mutedUntil = Number(globalThis.localStorage?.getItem(CHAT_ALERT_MUTE_STORAGE_KEY));
17
+ if (!Number.isFinite(mutedUntil) || mutedUntil <= Date.now()) {
18
+ globalThis.localStorage?.removeItem(CHAT_ALERT_MUTE_STORAGE_KEY);
19
+ return 0;
20
+ }
21
+ return mutedUntil;
22
+ } catch {
23
+ return 0;
24
+ }
25
+ }
26
+
27
+ function setChatAlertMuteUntil(mutedUntil) {
28
+ try {
29
+ if (mutedUntil > 0) {
30
+ globalThis.localStorage?.setItem(CHAT_ALERT_MUTE_STORAGE_KEY, String(mutedUntil));
31
+ } else {
32
+ globalThis.localStorage?.removeItem(CHAT_ALERT_MUTE_STORAGE_KEY);
33
+ }
34
+ } catch {
35
+ // Local storage can be unavailable in private or restricted browsing modes.
36
+ }
37
+ }
38
+
1
39
  export const chatFeature = {
2
40
  updateChatMessages(messages, { initial = false } = {}) {
3
41
  const previousIds = new Set(this.chatMessageIds);
@@ -36,7 +74,7 @@ export const chatFeature = {
36
74
 
37
75
  toggleChatPanel() {
38
76
  if (this.chatIsOpen) {
39
- this.closeChatPanel();
77
+ this.closeChatPanel({ restoreFocus: true });
40
78
  return;
41
79
  }
42
80
 
@@ -46,24 +84,27 @@ export const chatFeature = {
46
84
  openChatPanel() {
47
85
  this.chatIsOpen = true;
48
86
  this.chatUnreadCount = 0;
49
- this.renderChat();
87
+ this.renderChat({ stickToBottom: true });
50
88
  requestAnimationFrame(() => {
51
89
  this.elements.chatInput?.focus();
52
- this.scrollChatToBottom();
53
90
  });
54
91
  },
55
92
 
56
- closeChatPanel() {
93
+ closeChatPanel({ restoreFocus = false } = {}) {
57
94
  if (!this.chatIsOpen) {
58
95
  return;
59
96
  }
60
97
 
61
98
  this.chatIsOpen = false;
62
- this.renderChat();
99
+ this.renderChat({ messagesChanged: false });
100
+ if (restoreFocus) {
101
+ requestAnimationFrame(() => this.elements.chatToggleButton?.focus());
102
+ }
63
103
  },
64
104
 
65
105
  handleChatSubmit() {
66
- if (!this.isTabActive) {
106
+ const connectionState = getChatConnectionState(this);
107
+ if (this.isTabActive === false || connectionState.status !== 'connected') {
67
108
  return;
68
109
  }
69
110
 
@@ -84,32 +125,41 @@ export const chatFeature = {
84
125
  return;
85
126
  }
86
127
 
87
- this.renderChat();
128
+ this.renderChat({ stickToBottom: true });
88
129
  },
89
130
 
90
- renderChat() {
131
+ renderChat({ messagesChanged = true, stickToBottom = false } = {}) {
91
132
  this.elements.chatContainer?.classList.toggle('is-open', this.chatIsOpen);
92
133
  this.elements.chatPanel?.classList.toggle('hidden', !this.chatIsOpen);
93
134
 
94
135
  this.syncChatToggleButton();
95
136
  this.syncChatNotificationButton();
96
-
97
137
  const list = this.elements.chatMessages;
98
138
  const emptyState = this.elements.chatEmptyState;
139
+ const connectionState = getChatConnectionState(this);
140
+ const isConnected = connectionState.status === 'connected';
141
+ const canSend = isConnected && this.chatInitialSyncComplete && this.isTabActive !== false;
99
142
 
100
143
  if (this.elements.chatStatus) {
101
- this.elements.chatStatus.textContent = this.chatInitialSyncComplete
102
- ? `${this.globalUsers.length} online`
103
- : 'Syncing...';
144
+ this.elements.chatStatus.textContent = !this.chatInitialSyncComplete
145
+ ? 'Syncing...'
146
+ : !isConnected
147
+ ? connectionState.unreachable ? 'Server unreachable' : 'Reconnecting...'
148
+ : `${this.globalUsers.length} online`;
104
149
  }
105
150
 
106
- if (!list) {
107
- return;
151
+ const sendButton = this.elements.chatForm?.querySelector?.('button[type="submit"]');
152
+ if (sendButton) {
153
+ sendButton.disabled = !canSend;
154
+ sendButton.title = canSend ? 'Send message' : 'Chat is unavailable while disconnected';
108
155
  }
109
156
 
110
- list.replaceChildren();
157
+ if (!list || !this.chatIsOpen || !messagesChanged) {
158
+ return;
159
+ }
111
160
 
112
161
  if (this.chatMessages.length === 0) {
162
+ list.replaceChildren();
113
163
  emptyState?.classList.remove('hidden');
114
164
  list.classList.add('hidden');
115
165
  return;
@@ -118,13 +168,14 @@ export const chatFeature = {
118
168
  emptyState?.classList.add('hidden');
119
169
  list.classList.remove('hidden');
120
170
 
171
+ const wasAtBottom = stickToBottom || isChatAtBottom(list);
121
172
  const fragment = document.createDocumentFragment();
122
173
  this.chatMessages.forEach((message) => {
123
174
  fragment.appendChild(this.createChatMessageElement(message));
124
175
  });
125
- list.appendChild(fragment);
176
+ list.replaceChildren(fragment);
126
177
 
127
- if (this.chatIsOpen) {
178
+ if (wasAtBottom) {
128
179
  this.scrollChatToBottom();
129
180
  }
130
181
  },
@@ -137,7 +188,9 @@ export const chatFeature = {
137
188
 
138
189
  const avatar = document.createElement('div');
139
190
  avatar.className = 'chat-message-avatar';
140
- avatar.style.backgroundColor = message.userColor || 'var(--color-primary)';
191
+ const avatarColor = message.userColor || 'var(--color-primary-active)';
192
+ avatar.style.backgroundColor = avatarColor;
193
+ avatar.style.color = getUserAvatarTextColor(message.userColor);
141
194
  avatar.textContent = (message.userName || '?').charAt(0).toUpperCase();
142
195
  avatar.setAttribute('aria-hidden', 'true');
143
196
 
@@ -160,7 +213,7 @@ export const chatFeature = {
160
213
  const fileLabel = this.getChatMessageFileLabel(message.filePath);
161
214
  if (fileLabel) {
162
215
  const file = document.createElement('span');
163
- file.className = 'chat-message-file';
216
+ file.className = 'ui-pill chat-message-file';
164
217
  file.textContent = fileLabel;
165
218
  meta.append(file);
166
219
  }
@@ -212,6 +265,92 @@ export const chatFeature = {
212
265
  return `${sender}: ${compactText}`;
213
266
  },
214
267
 
268
+ syncChatNotificationButton() {
269
+ const button = this.elements.chatNotificationButton;
270
+ const muteButton = this.elements.chatNotificationMuteButton;
271
+ const status = this.elements.chatNotificationStatus;
272
+ if (!button && !muteButton && !status) {
273
+ return;
274
+ }
275
+
276
+ const permission = this.notifications?.getPermission?.() ?? 'unsupported';
277
+ const enabled = permission === 'granted';
278
+ const muted = enabled && getChatAlertMuteUntil() > 0;
279
+ const blocked = permission === 'denied';
280
+ const statusState = enabled
281
+ ? {
282
+ text: muted ? 'Alerts muted' : 'Alerts on',
283
+ label: muted ? 'Desktop alerts muted' : 'Desktop alerts enabled',
284
+ }
285
+ : blocked
286
+ ? {
287
+ text: 'Alerts blocked',
288
+ label: 'Desktop alerts blocked in browser settings',
289
+ }
290
+ : null;
291
+
292
+ if (button) {
293
+ button.classList.toggle('hidden', permission !== 'default');
294
+ button.disabled = false;
295
+ button.removeAttribute('aria-pressed');
296
+ button.textContent = 'Enable desktop alerts';
297
+ button.title = 'Allow desktop alerts for new chat messages';
298
+ }
299
+
300
+ if (muteButton) {
301
+ muteButton.classList.toggle('hidden', !enabled);
302
+ muteButton.disabled = false;
303
+ muteButton.setAttribute('aria-pressed', String(muted));
304
+ const muteLabel = muted ? 'Unmute desktop alerts' : 'Mute desktop alerts for one hour';
305
+ muteButton.setAttribute('aria-label', muteLabel);
306
+ muteButton.title = muteLabel;
307
+ }
308
+
309
+ if (status) {
310
+ status.classList.toggle('hidden', !statusState);
311
+ status.textContent = statusState?.text ?? '';
312
+ if (statusState) {
313
+ status.setAttribute('aria-label', statusState.label);
314
+ status.title = statusState.label;
315
+ } else {
316
+ status.removeAttribute('aria-label');
317
+ status.removeAttribute('title');
318
+ }
319
+ status.classList.toggle('ui-status-badge--accent', enabled && !muted);
320
+ status.classList.toggle('ui-status-badge--muted', blocked || muted);
321
+ }
322
+ },
323
+
324
+ async handleChatNotificationToggle() {
325
+ const permission = await this.notifications?.requestPermission?.();
326
+ this.syncChatNotificationButton();
327
+
328
+ if (permission === 'granted') {
329
+ return;
330
+ }
331
+
332
+ if (permission === 'denied') {
333
+ (this.chatToastController ?? this.toastController)?.show(
334
+ 'Desktop alerts are blocked. Allow them in browser site settings.',
335
+ { duration: 5000, tone: 'warning' },
336
+ );
337
+ return;
338
+ }
339
+
340
+ if (permission === 'unsupported') {
341
+ (this.chatToastController ?? this.toastController)?.show(
342
+ 'This browser does not support desktop alerts.',
343
+ { duration: 5000, tone: 'warning' },
344
+ );
345
+ }
346
+ },
347
+
348
+ toggleChatNotificationMute() {
349
+ const mutedUntil = getChatAlertMuteUntil();
350
+ setChatAlertMuteUntil(mutedUntil > 0 ? 0 : Date.now() + CHAT_ALERT_MUTE_DURATION_MS);
351
+ this.renderChat({ messagesChanged: false });
352
+ },
353
+
215
354
  syncChatToggleButton() {
216
355
  const button = this.elements.chatToggleButton;
217
356
  const badge = this.elements.chatToggleBadge;
@@ -224,6 +363,7 @@ export const chatFeature = {
224
363
 
225
364
  button.classList.toggle('is-active', this.chatIsOpen);
226
365
  button.classList.toggle('is-unread', shouldEmphasizeUnread);
366
+ button.setAttribute('aria-controls', 'chatPanel');
227
367
  button.setAttribute('aria-expanded', String(this.chatIsOpen));
228
368
  button.setAttribute(
229
369
  'aria-label',
@@ -241,123 +381,39 @@ export const chatFeature = {
241
381
  badge.textContent = this.chatUnreadCount > 9 ? '9+' : String(this.chatUnreadCount);
242
382
  },
243
383
 
244
- syncChatNotificationButton() {
245
- const button = this.elements.chatNotificationButton;
246
- if (!button) {
247
- return;
248
- }
249
-
250
- const permission = this.notifications.getPermission();
251
- this.chatNotificationPermission = permission;
252
-
253
- let label = 'Enable alerts';
254
- let title = 'Enable browser notifications for new chat messages';
255
- let pressed = false;
256
-
257
- if (permission === 'unsupported') {
258
- label = 'Alerts unavailable';
259
- title = 'Browser notifications are unavailable here';
260
- } else if (permission === 'denied') {
261
- label = 'Alerts off';
262
- title = 'Browser notifications are blocked for this site';
263
- } else if (permission === 'granted') {
264
- pressed = this.chatNotificationsEnabled;
265
- label = pressed ? 'Alerts on' : 'Alerts off';
266
- title = pressed
267
- ? 'Disable browser notifications for chat'
268
- : 'Enable browser notifications for chat';
269
- }
270
-
271
- button.textContent = label;
272
- button.title = title;
273
- button.setAttribute('aria-pressed', String(pressed));
274
- button.classList.toggle('is-enabled', pressed);
275
- button.classList.toggle('is-blocked', permission === 'denied');
276
- },
277
-
278
- async handleChatNotificationToggle() {
279
- const permission = this.notifications.getPermission();
280
- this.chatNotificationPermission = permission;
281
-
282
- if (permission === 'unsupported') {
283
- this.toastController.show('Browser notifications are unavailable here');
284
- this.syncChatNotificationButton();
285
- return;
286
- }
287
-
288
- if (permission === 'denied') {
289
- this.chatNotificationsEnabled = false;
290
- this.preferences.setChatNotificationsEnabled(false);
291
- this.toastController.show('Browser notifications are blocked for this site');
292
- this.syncChatNotificationButton();
293
- return;
294
- }
295
-
296
- if (permission === 'default') {
297
- const nextPermission = await this.notifications.requestPermission();
298
- this.chatNotificationPermission = nextPermission;
299
-
300
- if (nextPermission !== 'granted') {
301
- this.chatNotificationsEnabled = false;
302
- this.preferences.setChatNotificationsEnabled(false);
303
- if (nextPermission === 'denied') {
304
- this.toastController.show('Browser notifications were blocked');
305
- }
306
- this.syncChatNotificationButton();
307
- return;
308
- }
309
-
310
- this.chatNotificationsEnabled = true;
311
- this.preferences.setChatNotificationsEnabled(true);
312
- this.syncChatNotificationButton();
313
- return;
314
- }
315
-
316
- this.chatNotificationsEnabled = !this.chatNotificationsEnabled;
317
- this.preferences.setChatNotificationsEnabled(this.chatNotificationsEnabled);
318
- this.syncChatNotificationButton();
319
- },
320
-
321
384
  maybeNotifyChatMessage(message) {
322
385
  if (!this.chatInitialSyncComplete) {
323
386
  return;
324
387
  }
325
388
 
326
- if (document.hidden) {
327
- this.maybeShowBrowserChatNotification(message);
389
+ if (this.isTabActive === false || this.chatIsOpen || getChatAlertMuteUntil() > 0) {
328
390
  return;
329
391
  }
330
392
 
331
- if (!this.isTabActive || this.chatIsOpen) {
332
- return;
393
+ const pageHidden = Boolean(
394
+ globalThis.document?.hidden || globalThis.document?.visibilityState === 'hidden',
395
+ );
396
+ const pageFocused = typeof globalThis.document?.hasFocus === 'function'
397
+ ? globalThis.document.hasFocus()
398
+ : !pageHidden;
399
+ let notification = null;
400
+ if (pageHidden || !pageFocused) {
401
+ notification = this.notifications?.show?.({
402
+ body: String(message?.text ?? '').replace(/\s+/g, ' ').trim(),
403
+ onClick: () => {
404
+ window.focus?.();
405
+ notification?.close?.();
406
+ this.openChatPanel();
407
+ },
408
+ tag: `collabmd-chat-${message?.id ?? 'message'}`,
409
+ title: `New message from ${message?.userName || 'Someone'}`,
410
+ });
333
411
  }
334
412
 
335
- (this.chatToastController ?? this.toastController).show(this.formatChatToastMessage(message), 4000);
336
- },
337
-
338
- maybeShowBrowserChatNotification(message) {
339
- if (!this.chatNotificationsEnabled || this.notifications.getPermission() !== 'granted') {
413
+ if (notification) {
340
414
  return;
341
415
  }
342
416
 
343
- const title = `CollabMD chat • ${message.userName}`;
344
- const fileLabel = this.getChatMessageFileLabel(message.filePath);
345
- const body = fileLabel ? `${fileLabel}: ${message.text}` : message.text;
346
- const notification = this.notifications.createNotification({
347
- body,
348
- onClick: () => {
349
- if (message.filePath) {
350
- this.navigation.navigateToFile(message.filePath);
351
- }
352
- },
353
- tag: `collabmd-chat-${message.id}`,
354
- title,
355
- });
356
-
357
- if (notification) {
358
- setTimeout(() => {
359
- notification.close?.();
360
- }, 6000);
361
- }
417
+ (this.chatToastController ?? this.toastController).show(this.formatChatToastMessage(message), 4000);
362
418
  },
363
419
  };
@@ -97,7 +97,7 @@ export const commentsFeature = {
97
97
  });
98
98
 
99
99
  if (this.currentFilePath !== filePath || !this.session) {
100
- await this.openFile(filePath);
100
+ await this.workspaceRouteController.openFile(filePath);
101
101
  }
102
102
 
103
103
  this.setSidebarTab('comments');
@@ -148,7 +148,7 @@ export const gitFeature = {
148
148
 
149
149
  syncMainChrome({ badgeLabel = '', mode, title = null } = {}) {
150
150
  const isSpecialMode = mode === 'diff' || mode === 'history' || mode === 'history-preview';
151
- this.elements.toolbarCenter?.classList.toggle('hidden', isSpecialMode);
151
+ this.elements.toolbarViewToggle?.classList.toggle('hidden', isSpecialMode);
152
152
  this.elements.mobileViewToggle?.classList.toggle('hidden', isSpecialMode);
153
153
  this.elements.userCount?.classList.toggle('hidden', isSpecialMode);
154
154
  this.elements.toolbarDiffBadge?.classList.toggle('hidden', !badgeLabel);
@@ -164,7 +164,6 @@ export const gitFeature = {
164
164
  },
165
165
 
166
166
  async showGitDiff({ filePath = null, scope = 'all' } = {}) {
167
- await this.ensureGitControllers?.();
168
167
  this.clearStaticPreviewDocument?.();
169
168
  this.gitPanel.setSelection(filePath ? { path: filePath, scope, source: 'workspace' } : {});
170
169
  this.gitPanel.setMode('changes');
@@ -179,7 +178,6 @@ export const gitFeature = {
179
178
  },
180
179
 
181
180
  async showGitCommit({ hash, path = null, historyFilePath = null } = {}) {
182
- await this.ensureGitControllers?.();
183
181
  this.clearStaticPreviewDocument?.();
184
182
  this.gitPanel.setMode('history');
185
183
  this.gitPanel.setSelection(hash ? { commitHash: hash, path, source: 'commit' } : { source: 'commit' });
@@ -194,7 +192,6 @@ export const gitFeature = {
194
192
  },
195
193
 
196
194
  async showGitHistory() {
197
- await this.ensureGitControllers?.();
198
195
  this.clearStaticPreviewDocument?.();
199
196
  this.gitPanel.setMode('history');
200
197
  this.gitPanel.setSelection({ source: 'commit' });
@@ -207,7 +204,6 @@ export const gitFeature = {
207
204
  },
208
205
 
209
206
  async showGitFileHistory({ filePath = null } = {}) {
210
- await this.ensureGitControllers?.();
211
207
  if (!filePath) {
212
208
  this.workspaceRouteController?.showEmptyState?.();
213
209
  return;
@@ -240,7 +236,7 @@ export const gitFeature = {
240
236
  ...data,
241
237
  currentFilePath: resolvedCurrentFilePath,
242
238
  });
243
- this.resetPreviewMode();
239
+ this.workspacePreviewController.resetPreviewMode();
244
240
  this.elements.markdownToolbar?.classList.add('hidden');
245
241
  this.elements.outlineToggle?.classList.toggle('hidden', data.fileKind !== 'markdown');
246
242
 
@@ -256,7 +252,7 @@ export const gitFeature = {
256
252
  }
257
253
 
258
254
  this.elements.outlineToggle?.classList.add('hidden');
259
- this.renderTextFilePreview?.({
255
+ this.workspacePreviewController.renderTextFilePreview({
260
256
  content: data.content,
261
257
  filePath: resolvedCurrentFilePath,
262
258
  });
@@ -266,7 +262,7 @@ export const gitFeature = {
266
262
  console.error('[git-preview] Failed to load file snapshot:', error);
267
263
  this.clearStaticPreviewDocument?.();
268
264
  this.toastController.show('Failed to load historical file preview');
269
- this.renderTextFilePreview?.({
265
+ this.workspacePreviewController.renderTextFilePreview({
270
266
  content: 'Failed to load historical file preview.',
271
267
  filePath: resolvedCurrentFilePath,
272
268
  });
@@ -335,7 +331,6 @@ export const gitFeature = {
335
331
  },
336
332
 
337
333
  async refreshGitAfterAction({ filePath = null, preferredScope = null } = {}) {
338
- await this.ensureGitControllers?.();
339
334
  await this.gitPanel.refresh({ force: true });
340
335
 
341
336
  const route = this.navigation.getHashRoute();
@@ -1,3 +1,5 @@
1
+ import { getUserAvatarTextColor } from '../../domain/room.js';
2
+
1
3
  const MAX_INLINE_AVATARS = 5;
2
4
 
3
5
  function compareUserNames(left, right) {
@@ -65,7 +67,7 @@ export const presenceFeature = {
65
67
  this.globalUsers = users;
66
68
  this.syncFollowedUser();
67
69
  this.renderAvatars();
68
- this.renderChat();
70
+ this.renderChat({ messagesChanged: false });
69
71
  this.renderPresence();
70
72
  this.syncCurrentUserName();
71
73
  },
@@ -126,6 +128,7 @@ export const presenceFeature = {
126
128
  avatar.className = 'user-avatar';
127
129
  avatar.classList.toggle('is-local', user.isLocal);
128
130
  avatar.style.backgroundColor = user.color;
131
+ avatar.style.color = getUserAvatarTextColor(user.color);
129
132
  avatar.classList.toggle('is-following', user.clientId === this.followedUserClientId);
130
133
 
131
134
  const initial = document.createElement('span');
@@ -181,8 +184,6 @@ export const presenceFeature = {
181
184
  overflow.appendChild(overflowLabel);
182
185
  avatars.appendChild(overflow);
183
186
  }
184
-
185
- this.renderPresencePanel();
186
187
  },
187
188
 
188
189
  openPresencePanel() {
@@ -196,7 +197,6 @@ export const presenceFeature = {
196
197
  this.closeToolbarOverflowMenu?.();
197
198
  this.renderAvatars();
198
199
  this.renderPresence();
199
- this.renderPresencePanel();
200
200
  },
201
201
 
202
202
  closePresencePanel() {
@@ -207,7 +207,6 @@ export const presenceFeature = {
207
207
  this.presencePanelOpen = false;
208
208
  this.renderAvatars();
209
209
  this.renderPresence();
210
- this.renderPresencePanel();
211
210
  },
212
211
 
213
212
  togglePresencePanel() {
@@ -227,6 +226,9 @@ export const presenceFeature = {
227
226
 
228
227
  panel.classList.toggle('hidden', !this.presencePanelOpen);
229
228
  panel.setAttribute('aria-hidden', String(!this.presencePanelOpen));
229
+ if (!this.presencePanelOpen) {
230
+ return;
231
+ }
230
232
 
231
233
  const orderedUsers = getPanelUsers(this.globalUsers, this.followedUserClientId);
232
234
  const effectiveConnectionState = getConnectedPresenceState(this);
@@ -268,6 +270,7 @@ export const presenceFeature = {
268
270
  const avatar = document.createElement('span');
269
271
  avatar.className = 'presence-panel-user-avatar';
270
272
  avatar.style.backgroundColor = user.color;
273
+ avatar.style.color = getUserAvatarTextColor(user.color);
271
274
  avatar.textContent = user.name.charAt(0).toUpperCase();
272
275
  avatar.setAttribute('aria-hidden', 'true');
273
276
 
@@ -346,10 +349,11 @@ export const presenceFeature = {
346
349
 
347
350
  this.followedUserClientId = clientId;
348
351
  this.followedCursorSignature = '';
349
- this.renderAvatars();
350
-
351
- if (closePanel) {
352
+ if (closePanel && this.presencePanelOpen) {
352
353
  this.closePresencePanel();
354
+ } else {
355
+ this.renderAvatars();
356
+ this.renderPresencePanel();
353
357
  }
354
358
 
355
359
  if (user.currentFile && user.currentFile !== this.currentFilePath) {
@@ -382,9 +386,11 @@ export const presenceFeature = {
382
386
  }
383
387
  this.followedUserClientId = null;
384
388
  this.followedCursorSignature = '';
385
- this.renderAvatars();
386
- if (closePanel) {
389
+ if (closePanel && this.presencePanelOpen) {
387
390
  this.closePresencePanel();
391
+ } else {
392
+ this.renderAvatars();
393
+ this.renderPresencePanel();
388
394
  }
389
395
  },
390
396