@molecule/app-ide-react 1.6.0 → 1.7.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.
Files changed (35) hide show
  1. package/README.md +70 -4
  2. package/dist/command-metadata.d.ts.map +1 -1
  3. package/dist/command-metadata.js +9 -2
  4. package/dist/command-metadata.js.map +1 -1
  5. package/dist/components/ChatPanel.d.ts +14 -2
  6. package/dist/components/ChatPanel.d.ts.map +1 -1
  7. package/dist/components/ChatPanel.js +332 -114
  8. package/dist/components/ChatPanel.js.map +1 -1
  9. package/dist/components/EditorPanel.d.ts +1 -1
  10. package/dist/components/EditorPanel.d.ts.map +1 -1
  11. package/dist/components/EditorPanel.js +6 -1
  12. package/dist/components/EditorPanel.js.map +1 -1
  13. package/dist/components/FileExplorer.d.ts +1 -1
  14. package/dist/components/FileExplorer.d.ts.map +1 -1
  15. package/dist/components/FileExplorer.js +2 -2
  16. package/dist/components/FileExplorer.js.map +1 -1
  17. package/dist/components/FileExplorerContextMenu.d.ts +6 -1
  18. package/dist/components/FileExplorerContextMenu.d.ts.map +1 -1
  19. package/dist/components/FileExplorerContextMenu.js +19 -7
  20. package/dist/components/FileExplorerContextMenu.js.map +1 -1
  21. package/dist/components/SearchPanel.d.ts +1 -1
  22. package/dist/components/SearchPanel.d.ts.map +1 -1
  23. package/dist/components/SearchPanel.js +9 -6
  24. package/dist/components/SearchPanel.js.map +1 -1
  25. package/dist/components/ShareModal.d.ts +13 -1
  26. package/dist/components/ShareModal.d.ts.map +1 -1
  27. package/dist/components/ShareModal.js +2 -2
  28. package/dist/components/ShareModal.js.map +1 -1
  29. package/dist/components/ToolCallCard.d.ts.map +1 -1
  30. package/dist/components/ToolCallCard.js +6 -3
  31. package/dist/components/ToolCallCard.js.map +1 -1
  32. package/dist/types.d.ts +36 -1
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/package.json +2 -2
@@ -135,6 +135,7 @@ function renderCardSegment(seg, key) {
135
135
  }
136
136
  /** All stream event types that can trigger a notification sound. */
137
137
  const SOUND_EVENTS = [
138
+ 'message',
138
139
  'done',
139
140
  'error',
140
141
  'tool_result',
@@ -147,6 +148,7 @@ const SOUND_EVENTS = [
147
148
  ];
148
149
  /** User-friendly labels for each sound event (used as i18n defaultValues). */
149
150
  const SOUND_EVENT_LABELS = {
151
+ message: 'Team message',
150
152
  done: 'Response complete',
151
153
  error: 'Error',
152
154
  tool_result: 'Tool finished',
@@ -159,6 +161,7 @@ const SOUND_EVENT_LABELS = {
159
161
  };
160
162
  /** Brief descriptions for each sound event. */
161
163
  const SOUND_EVENT_DESCRIPTIONS = {
164
+ message: 'A teammate posted a team-only note',
162
165
  done: '{{agentName}} finished responding',
163
166
  error: 'Something went wrong during a response',
164
167
  tool_result: 'A tool call (file read, command, etc.) completed',
@@ -176,7 +179,10 @@ const SOUND_MODE_LABELS = {
176
179
  whenNotFocused: 'when not focused',
177
180
  always: 'always',
178
181
  };
182
+ /** Per-device sounds preference (see the soundsConfig state comment). */
183
+ const SOUNDS_STORAGE_KEY = 'molecule.ide.sounds';
179
184
  const DEFAULT_SOUNDS_CONFIG = {
185
+ message: 'always',
180
186
  done: 'whenNotFocused',
181
187
  error: 'whenNotFocused',
182
188
  tool_result: 'off',
@@ -191,8 +197,13 @@ let audioCtx = null;
191
197
  /**
192
198
  * Play a short notification tone using the Web Audio API.
193
199
  * Creates the AudioContext lazily on first call (after user interaction).
200
+ *
201
+ * The default variant is a single 660 Hz blip (status events). The `team`
202
+ * variant is the SAME blip an octave up (1320 Hz) — recognizably "a message,
203
+ * not a status event" while staying consistent with the sound family.
204
+ * @param variant - Which tone to play.
194
205
  */
195
- function playTone() {
206
+ function playTone(variant = 'default') {
196
207
  try {
197
208
  if (!audioCtx)
198
209
  audioCtx = new AudioContext();
@@ -201,8 +212,12 @@ function playTone() {
201
212
  osc.connect(gain);
202
213
  gain.connect(audioCtx.destination);
203
214
  osc.type = 'sine';
204
- osc.frequency.value = 660;
205
- gain.gain.setValueAtTime(0.15, audioCtx.currentTime);
215
+ osc.frequency.value = variant === 'team' ? 1320 : 660;
216
+ // Equal-loudness compensation: hearing is far more sensitive near 1–4 kHz
217
+ // than at 660 Hz, so the octave-up tone needs roughly half the amplitude
218
+ // to sound the same volume as the default blip.
219
+ const peak = variant === 'team' ? 0.08 : 0.15;
220
+ gain.gain.setValueAtTime(peak, audioCtx.currentTime);
206
221
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
207
222
  osc.start(audioCtx.currentTime);
208
223
  osc.stop(audioCtx.currentTime + 0.15);
@@ -928,7 +943,7 @@ export function CommitCardItem({ card, onRevert, }) {
928
943
  * @returns The rendered message item.
929
944
  */
930
945
  const MessageItem = memo(function MessageItem(props) {
931
- const { msg, sendMessage, handleAskUserResponse, isLoading, streamingStatus, onNavigatePreview, undoneTcIds, handleUndoToggle, onFileOpen, onFileDoubleClick, onFileDiff, handleFileRevert, setInputAndCursorEnd, setModelPicker, userAvatar, onAvatarClick, discovery, buildUpgradeCta, agentName, } = props;
946
+ const { msg, sendMessage, handleAskUserResponse, isLoading, streamingStatus, onNavigatePreview, undoneTcIds, handleUndoToggle, onFileOpen, onFileDoubleClick, onFileDiff, handleFileRevert, setInputAndCursorEnd, setModelPicker, chatMode, userAvatar, onAvatarClick, discovery, buildUpgradeCta, agentName, canEdit, } = props;
932
947
  const cm = getClassMap();
933
948
  const themeMode = useThemeMode();
934
949
  const isLight = themeMode === 'light';
@@ -1006,13 +1021,30 @@ const MessageItem = memo(function MessageItem(props) {
1006
1021
  defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
1007
1022
  }), "aria-label": t('ide.chat.teamOnly.badge', { agentName: agentName ?? 'the assistant' }, {
1008
1023
  defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
1009
- }), style: { display: 'inline-flex', alignSelf: 'center', flexShrink: 0 }, "data-mol-id": "chat-team-only-badge", children: _jsx(Icon
1010
- // Same size as the username text (textSize('sm') 14px) so the
1011
- // badge reads as part of the header line, not a footnote.
1024
+ }),
1025
+ // Centered in the header row with a -0.5px optical nudge. Platform
1026
+ // font metrics make a single integer offset impossible: against
1027
+ // plain center, macOS rendered the glyph ~1px low and Ubuntu spot-on
1028
+ // (measured in Brave on both, 2026-08-27), so -0.5 splits the
1029
+ // difference — each platform lands within half a pixel, and retina
1030
+ // displays render the half-pixel crisply. (Baseline alignment was
1031
+ // tried and rode too HIGH: a text-less inline-flex item's
1032
+ // synthesized baseline is its bottom edge.)
1033
+ style: {
1034
+ display: 'inline-flex',
1035
+ alignSelf: 'center',
1036
+ flexShrink: 0,
1037
+ position: 'relative',
1038
+ top: -0.5,
1039
+ }, "data-mol-id": "chat-team-only-badge", children: _jsx(Icon
1040
+ // 16px so the glyph reads at the username's optical size — the
1041
+ // people glyph has internal viewBox padding, so a nominal 14px
1042
+ // renders visibly smaller than the 14px text next to it.
1012
1043
  , {
1013
- // Same size as the username text (textSize('sm') 14px) so the
1014
- // badge reads as part of the header line, not a footnote.
1015
- name: "people", size: 14, "aria-hidden": "true", style: { color: NOTICE_TONE.gold.accent } }) })), typeof msg.timestamp === 'number' && (_jsx("span", { className: cm.textMuted, style: { fontSize: 11 }, children: relativeTimeLong(msg.timestamp) }))] })), _jsx(CollapsibleUserMessage, { content: msg.content, isLight: isLight }), msg.attachments && msg.attachments.length > 0 && (_jsx("div", { style: { marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }, children: msg.attachments.map((att, ai) => (_jsxs("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [att.mediaType.startsWith('image/')
1044
+ // 16px so the glyph reads at the username's optical size the
1045
+ // people glyph has internal viewBox padding, so a nominal 14px
1046
+ // renders visibly smaller than the 14px text next to it.
1047
+ name: "people", size: 16, "aria-hidden": "true", style: { color: NOTICE_TONE.gold.accent } }) })), typeof msg.timestamp === 'number' && (_jsx("span", { className: cm.textMuted, style: { fontSize: 11 }, children: relativeTimeLong(msg.timestamp) }))] })), _jsx(CollapsibleUserMessage, { content: msg.content, isLight: isLight }), msg.attachments && msg.attachments.length > 0 && (_jsx("div", { style: { marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }, children: msg.attachments.map((att, ai) => (_jsxs("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [att.mediaType.startsWith('image/')
1016
1048
  ? '\uD83D\uDDBC\uFE0F'
1017
1049
  : att.mediaType.startsWith('audio/')
1018
1050
  ? '\uD83C\uDFB5'
@@ -1091,11 +1123,11 @@ const MessageItem = memo(function MessageItem(props) {
1091
1123
  const tc = msg.toolCalls?.find((c) => c.id === block.id);
1092
1124
  if (!tc)
1093
1125
  return null;
1094
- return (_jsx("div", { style: { marginTop: '4px' }, children: _jsx(ToolCallCard, { id: tc.id, name: tc.name, input: tc.input, output: tc.output, status: tc.status, fileDiff: tc.fileDiff, isUndone: undoneTcIds.has(tc.id), onUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: handleFileRevert, onAskUserResponse: handleAskUserResponse }) }, tc.id));
1126
+ return (_jsx("div", { style: { marginTop: '4px' }, children: _jsx(ToolCallCard, { id: tc.id, name: tc.name, input: tc.input, output: tc.output, status: tc.status, fileDiff: tc.fileDiff, isUndone: undoneTcIds.has(tc.id), onUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: canEdit === false ? undefined : handleFileRevert, onAskUserResponse: canEdit === false ? undefined : handleAskUserResponse }) }, tc.id));
1095
1127
  })) : msg.content ? (_jsx(MarkdownContent, { text: msg.content, isStreaming: msg.isStreaming, statusLabel: msg.isStreaming ? streamingStatus : undefined, statusStartedAt: typeof msg.timestamp === 'number' ? msg.timestamp : undefined, onNavigatePreview: onNavigatePreview, hideStreamingIndicator: true })) : null, msg.toolCalls &&
1096
1128
  msg.toolCalls.length > 0 &&
1097
1129
  (!msg.blocks || msg.blocks.length === 0) &&
1098
- msg.toolCalls.map((tc) => (_jsx(ToolCallCard, { id: tc.id, name: tc.name, input: tc.input, output: tc.output, status: tc.status, fileDiff: tc.fileDiff, isUndone: undoneTcIds.has(tc.id), onUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: handleFileRevert, onAskUserResponse: handleAskUserResponse }, tc.id))), msg.aborted && (_jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { display: 'block', marginTop: 4, fontStyle: 'italic' }, children: t('ide.chat.responseStopped', undefined, {
1130
+ msg.toolCalls.map((tc) => (_jsx(ToolCallCard, { id: tc.id, name: tc.name, input: tc.input, output: tc.output, status: tc.status, fileDiff: tc.fileDiff, isUndone: undoneTcIds.has(tc.id), onUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: canEdit === false ? undefined : handleFileRevert, onAskUserResponse: canEdit === false ? undefined : handleAskUserResponse }, tc.id))), msg.aborted && (_jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { display: 'block', marginTop: 4, fontStyle: 'italic' }, children: t('ide.chat.responseStopped', undefined, {
1099
1131
  defaultValue: 'Response stopped',
1100
1132
  }) })), msg.loopLimitReached &&
1101
1133
  !msg.isStreaming &&
@@ -1107,7 +1139,7 @@ const MessageItem = memo(function MessageItem(props) {
1107
1139
  }),
1108
1140
  action: () => {
1109
1141
  setInputAndCursorEnd('/model ');
1110
- setModelPicker({ selectedIdx: -1 });
1142
+ setModelPicker({ selectedIdx: -1, mode: chatMode });
1111
1143
  },
1112
1144
  },
1113
1145
  {
@@ -1186,11 +1218,15 @@ const MessageItem = memo(function MessageItem(props) {
1186
1218
  * @param props - Component props (see {@link MessageItemProps}).
1187
1219
  * @returns The rendered chat inner component.
1188
1220
  */
1189
- function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, canEdit, buildUpgradeCta, buildHelpUpgradeSection, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onConversationId, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, openSettingsSignal, onManageCustomModels, modelSelectionSignal, openReportSignal, openShareSignal, initialInputValue, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, gitStatusTick: externalGitStatusTick, discovery, userAvatar, agentName = DEFAULT_AGENT_NAME, productName = DEFAULT_PRODUCT_NAME, version, extraCommands,
1221
+ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, canEdit, canShare, buildUpgradeCta, buildHelpUpgradeSection, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onConversationId, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, openSettingsSignal, onManageCustomModels, modelSelectionSignal, openReportSignal, openShareSignal, initialInputValue, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, gitStatusTick: externalGitStatusTick, discovery, userAvatar, agentName = DEFAULT_AGENT_NAME, productName = DEFAULT_PRODUCT_NAME, version, extraCommands,
1190
1222
  // feedbackUrl: prop kept for back-compat (callers still pass it), but no longer
1191
1223
  // consumed here — its only use was the command-menu footer link removed in P3-21.
1192
1224
  }) {
1193
1225
  const cm = getClassMap();
1226
+ // Share-link management may be gated ABOVE canEdit by the host (molecule.dev
1227
+ // mints/lists/revokes at admin+) — every /share surface below uses this, so
1228
+ // an editor without the capability gets no dead modal.
1229
+ const shareAllowed = canShare ?? canEdit !== false;
1194
1230
  const themeMode = useThemeMode();
1195
1231
  const isLight = themeMode === 'light';
1196
1232
  // Phone-width / touch-first branches: popovers cap with dvh, hover-revealed
@@ -1375,8 +1411,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1375
1411
  // still drives the phase STATE (useChat's setMode); it just no longer spawns a card.
1376
1412
  const cfg = soundsConfigRef.current;
1377
1413
  const eventType = event.type;
1378
- if (eventType in cfg && shouldPlaySound(cfg[eventType])) {
1379
- playTone();
1414
+ // 'message' is the team-note ping: it fires only for a TEAMMATE's note
1415
+ // (the pushed broadcast) — never for the sender's own SSE echo.
1416
+ const isOwnTeamNote = event.type === 'message' && !applyingPushedRef.current;
1417
+ if (!isOwnTeamNote && eventType in cfg && shouldPlaySound(cfg[eventType])) {
1418
+ playTone(eventType === 'message' ? 'team' : 'default');
1380
1419
  }
1381
1420
  }, [t]);
1382
1421
  // Countdown timer effect — ticks down and auto-sends fix message
@@ -1483,6 +1522,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1483
1522
  onConversationId,
1484
1523
  onStreamEvent: handleStreamEvent,
1485
1524
  });
1525
+ // The mode a model CHANGE should target when the user hasn't scoped one
1526
+ // explicitly (no --plan/--execute flag, no picker-mode choice): the LIVE
1527
+ // conversation mode. Discovery runs in plan mode (aiContext.mode is 'plan'
1528
+ // throughout discovery), so it maps to 'plan' — changing the model while
1529
+ // discovering/planning changes the plan model, while building the execute
1530
+ // model. This replaces the old default of writing the legacy `chatModel`
1531
+ // (which silently moved BOTH modes).
1532
+ const liveModelMode = mode === 'plan' ? 'plan' : 'execute';
1486
1533
  // Keep sendMessageRef in sync so the countdown effect can call the latest sendMessage
1487
1534
  sendMessageRef.current = sendMessage;
1488
1535
  // Keep the card-append ref current so handleStreamEvent (memoized) can append a teammate's
@@ -1627,7 +1674,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1627
1674
  useEffect(() => {
1628
1675
  if (typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches)
1629
1676
  return;
1630
- textareaRef.current?.focus();
1677
+ const ta = textareaRef.current;
1678
+ if (!ta)
1679
+ return;
1680
+ ta.focus();
1681
+ // The textarea can mount with content already in it (defaultValue carries a
1682
+ // persisted draft, and a viewer's box holds the '/teamsay ' prefill) — a
1683
+ // fresh element's selection is (0,0), so without this the caret sits BEFORE
1684
+ // the text and typing lands in front of '/teamsay'.
1685
+ ta.setSelectionRange(ta.value.length, ta.value.length);
1631
1686
  }, []);
1632
1687
  /** Update the ref, the DOM element, and the hasInput flag without re-rendering the parent. */
1633
1688
  const setInputValue = useCallback((val) => {
@@ -1635,7 +1690,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1635
1690
  const ta = textareaRef.current;
1636
1691
  if (ta && ta.value !== val)
1637
1692
  ta.value = val;
1638
- setHasInput(Boolean(val.trim()));
1693
+ // A viewer's composer is pre-filled with '/teamsay ' — the BARE prefix is
1694
+ // not sendable content, so it must not light the Send button.
1695
+ const bareSideChannel = canEdit === false && /^\/(?:teamsay|t)$/i.test(val.trim());
1696
+ setHasInput(Boolean(val.trim()) && !bareSideChannel);
1639
1697
  autoResize();
1640
1698
  // Clear persisted draft when input is emptied (e.g. on submit)
1641
1699
  if (!val) {
@@ -1646,7 +1704,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1646
1704
  /* sessionStorage unavailable — safe to ignore, draft simply persists */
1647
1705
  }
1648
1706
  }
1649
- }, [draftKey, autoResize]);
1707
+ }, [draftKey, autoResize, canEdit]);
1650
1708
  // Persist draft text to sessionStorage so it survives refresh (debounced)
1651
1709
  const draftTimerRef = useRef(null);
1652
1710
  const persistDraft = useCallback(() => {
@@ -2260,6 +2318,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2260
2318
  activityCardsLoadedConvRef.current = null;
2261
2319
  if (!conversationId)
2262
2320
  return;
2321
+ // Activity cards carry recipients/summaries and their routes are editor+ by
2322
+ // design — a viewer's fetch/persist would just 403 on every panel open
2323
+ // (observed live in prod logs). Skip both for read-only viewers.
2324
+ if (canEdit === false)
2325
+ return;
2263
2326
  if (prevConv && prevConv !== conversationId)
2264
2327
  setActivityCards([]);
2265
2328
  let cancelled = false;
@@ -2289,10 +2352,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2289
2352
  return () => {
2290
2353
  cancelled = true;
2291
2354
  };
2292
- }, [conversationId, projectId, http]);
2355
+ }, [conversationId, projectId, http, canEdit]);
2293
2356
  useEffect(() => {
2294
2357
  if (!conversationId || activityCardsLoadedConvRef.current !== conversationId)
2295
2358
  return;
2359
+ if (canEdit === false)
2360
+ return;
2296
2361
  void http
2297
2362
  .put(`/projects/${projectId}/conversations/${conversationId}/activity-cards`, {
2298
2363
  activityCards: activityCards.filter((c) => !c.received),
@@ -2301,7 +2366,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2301
2366
  // Best-effort save (bound as _error per Rule 14): the in-memory cards remain the
2302
2367
  // source of truth this session if the PUT fails.
2303
2368
  });
2304
- }, [activityCards, conversationId, projectId, http]);
2369
+ }, [activityCards, conversationId, projectId, http, canEdit]);
2305
2370
  // ── Auto-tips (dismissable onboarding hints) ──────────────────────────────
2306
2371
  // Two surfaces (see chat-tips-utilities): an ENTRY_TIP shown once on a fresh
2307
2372
  // conversation so a brand-new user always sees how to drive the agent, plus an
@@ -2331,6 +2396,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2331
2396
  shownTipIdsRef.current = [];
2332
2397
  // Re-arm the entry tip so a freshly-started chat shows the onboarding hint.
2333
2398
  entryTipShownRef.current = false;
2399
+ // Re-arm the viewer composer prefill ('/teamsay ') for the new conversation.
2400
+ viewerPrefillDoneRef.current = false;
2334
2401
  }
2335
2402
  }, [conversationId]);
2336
2403
  // Entry tip: the onboarding moment. Show ONE high-value hint as soon as a fresh
@@ -2364,7 +2431,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2364
2431
  return;
2365
2432
  viewerTipShownRef.current = true;
2366
2433
  const text = t('ide.chat.tip.viewerTeamOnly', { agentName }, {
2367
- defaultValue: 'View-only access — read along and /teamsay the team. This gold icon marks team-only messages ({{agentName}} ignores them). Running the assistant and changing the model or settings need editor access.',
2434
+ defaultValue: 'View-only access — read along and /teamsay (or just /t) the team. This gold icon marks team-only messages ({{agentName}} ignores them). Running the assistant and changing the model or settings need editor access.',
2368
2435
  });
2369
2436
  setTipCards((prev) => [
2370
2437
  ...prev,
@@ -2409,7 +2476,31 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2409
2476
  }, [messages, discovery]);
2410
2477
  // ── Sounds picker (shown when /sounds is executed) ────────────────────────
2411
2478
  const [soundsPicker, setSoundsPicker] = useState(null);
2412
- const [soundsConfig, setSoundsConfig] = useState({ ...DEFAULT_SOUNDS_CONFIG });
2479
+ // Notification sounds are a PER-DEVICE, per-user preference localStorage,
2480
+ // never project settings. They used to persist via PATCH /projects settings
2481
+ // (shared with every member, and unreachable for read-only viewers, whose
2482
+ // PATCH 403s); the old project-level value is still read once as a migration
2483
+ // fallback when this device has no local preference yet.
2484
+ const [soundsConfig, setSoundsConfig] = useState(() => {
2485
+ try {
2486
+ const raw = localStorage.getItem(SOUNDS_STORAGE_KEY);
2487
+ if (raw)
2488
+ return { ...DEFAULT_SOUNDS_CONFIG, ...JSON.parse(raw) };
2489
+ }
2490
+ catch (_error) {
2491
+ // localStorage unavailable / bad JSON — fall through to the defaults.
2492
+ }
2493
+ return { ...DEFAULT_SOUNDS_CONFIG };
2494
+ });
2495
+ const soundsLocallySetRef = useRef(false);
2496
+ useEffect(() => {
2497
+ try {
2498
+ soundsLocallySetRef.current = localStorage.getItem(SOUNDS_STORAGE_KEY) != null;
2499
+ }
2500
+ catch (_error) {
2501
+ // localStorage unavailable — treat as unset; the project fallback may apply.
2502
+ }
2503
+ }, []);
2413
2504
  // ── Panel overlay (/skills, /scripts, /settings — closeable popups) ──────────
2414
2505
  // These three commands open a closeable overlay above the composer (mirrors how
2415
2506
  // /model and /sounds own this popup region) instead of dropping an inline card
@@ -2447,16 +2538,16 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2447
2538
  updated = { ...soundsConfig, [eventType]: next };
2448
2539
  }
2449
2540
  setSoundsConfig(updated);
2541
+ // Per-device preference — localStorage, never a project PATCH (see the
2542
+ // state's comment; this also makes /sounds fully usable for viewers).
2450
2543
  try {
2451
- await http.patch(`/projects/${projectId}`, { settings: { sounds: updated } });
2544
+ localStorage.setItem(SOUNDS_STORAGE_KEY, JSON.stringify(updated));
2545
+ soundsLocallySetRef.current = true;
2452
2546
  }
2453
- catch (error) {
2454
- logger.warn('Failed to persist sound settings to server', { error });
2455
- addSystemCard(t('ide.chat.soundsError', undefined, {
2456
- defaultValue: 'Failed to update sound settings.',
2457
- }));
2547
+ catch (_error) {
2548
+ // localStorage unavailable the choice still applies for this session.
2458
2549
  }
2459
- }, [soundsConfig, http, projectId, addSystemCard]);
2550
+ }, [soundsConfig]);
2460
2551
  // ── Current project settings (model + maxloops + sounds) ──────────────────
2461
2552
  // Project-scoped so any custom (bring-your-own AI) models the project has
2462
2553
  // configured are included alongside the platform catalog.
@@ -2572,7 +2663,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2572
2663
  if (typeof s?.autoApproveCommands === 'boolean') {
2573
2664
  setAutoApproveCommandsEnabled(s.autoApproveCommands);
2574
2665
  }
2575
- if (s?.sounds && typeof s.sounds === 'object') {
2666
+ // Legacy migration only: sounds are per-device (localStorage) now — apply
2667
+ // the old project-level value only when this device has no local pref.
2668
+ if (s?.sounds && typeof s.sounds === 'object' && !soundsLocallySetRef.current) {
2576
2669
  setSoundsConfig((prev) => ({ ...prev, ...s.sounds }));
2577
2670
  }
2578
2671
  // Restore the persisted auto-commit cadence in the paused state (it
@@ -2684,9 +2777,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2684
2777
  if (fallback) {
2685
2778
  setCurrentModel(fallback);
2686
2779
  setSavedChatModel(fallback);
2687
- http.patch(`/projects/${projectId}`, { settings: { chatModel: fallback } }).catch(() => {
2688
- /* persistence is best-effort; the in-memory switch is what matters */
2689
- });
2780
+ // Persisting the swap writes project settings editor+ server-side, so a
2781
+ // viewer's tab keeps the in-memory switch (all this session needs) and
2782
+ // skips a PATCH that could only 403.
2783
+ if (canEdit !== false) {
2784
+ http.patch(`/projects/${projectId}`, { settings: { chatModel: fallback } }).catch(() => {
2785
+ /* persistence is best-effort; the in-memory switch is what matters */
2786
+ });
2787
+ }
2690
2788
  }
2691
2789
  }, [
2692
2790
  modelsLoading,
@@ -2696,6 +2794,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2696
2794
  addSystemCard,
2697
2795
  http,
2698
2796
  projectId,
2797
+ canEdit,
2699
2798
  ]);
2700
2799
  // ── Queued message editing ──────────────────────────────────────────────────
2701
2800
  const [editingQueuedId, setEditingQueuedId] = useState(null);
@@ -2879,6 +2978,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2879
2978
  pendingMessageKey !== undefined &&
2880
2979
  pendingMessageKey !== lastPendingKeyRef.current) {
2881
2980
  lastPendingKeyRef.current = pendingMessageKey;
2981
+ // A read-only VIEWER never dispatches platform-composed sends (build
2982
+ // kickoffs, auto-fixes, preview-failure reports): the server 403s them
2983
+ // and the denial used to surface as a red error in the viewer's chat.
2984
+ // Consume the key (above) so the same message isn't retried.
2985
+ if (canEdit === false)
2986
+ return;
2882
2987
  // A pending message is ALWAYS system-composed (an auto-fix prompt, a preview-failure
2883
2988
  // report, a "Fix with AI" request) — never text the user typed. So it must NEVER
2884
2989
  // render as a normal user bubble: either it's suppressed entirely (hidden build
@@ -2912,6 +3017,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2912
3017
  pendingMessageUserInitiated,
2913
3018
  sendMessage,
2914
3019
  isLoading,
3020
+ canEdit,
2915
3021
  ]);
2916
3022
  // When streaming ends, send any deferred message. (A user Stop also ends
2917
3023
  // streaming, but handleAbort drops the deferred message first — and useChat's
@@ -2925,6 +3031,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2925
3031
  deferredPendingRef.current = null;
2926
3032
  deferredPendingSuppressRef.current = false;
2927
3033
  deferredPendingUserInitiatedRef.current = false;
3034
+ // A viewer never dispatches platform-composed sends (same as the
3035
+ // immediate path above) — the refs are already drained, so it just drops.
3036
+ if (canEdit === false)
3037
+ return;
2928
3038
  // Same rule as the immediate path: a deferred pending message is system-composed —
2929
3039
  // always `automatic` (suppressed or visible), never a plain user bubble.
2930
3040
  sendMessage(msg, undefined, {
@@ -2933,7 +3043,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2933
3043
  ...(userInitiated ? { userInitiated: true } : {}),
2934
3044
  });
2935
3045
  }
2936
- }, [isLoading, sendMessage]);
3046
+ }, [isLoading, sendMessage, canEdit]);
2937
3047
  // ── Auto-delete queued autofix messages when user edits a relevant file ────
2938
3048
  const lastUserEditKeyRef = useRef(userEditedFileKey);
2939
3049
  useEffect(() => {
@@ -3428,11 +3538,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3428
3538
  return;
3429
3539
  }
3430
3540
  setFilePicker(null);
3431
- // Show model picker when typing "/model <filter>" — scoped to a mode when
3432
- // the input carries a --plan / --execute flag.
3541
+ // Show model picker when typing "/model <filter>" — scoped to the mode a
3542
+ // --plan / --execute flag names, else to the LIVE conversation mode
3543
+ // (discovery = plan), so a plain pick changes the mode the user is in.
3433
3544
  const modelMatch = val.match(/^\/model\s+/i);
3434
3545
  if (modelMatch) {
3435
- setModelPicker({ selectedIdx: -1, mode: parseModelModeCommand(val)?.mode });
3546
+ setModelPicker({ selectedIdx: -1, mode: parseModelModeCommand(val)?.mode ?? liveModelMode });
3436
3547
  setCommandMenu(null);
3437
3548
  return;
3438
3549
  }
@@ -3443,7 +3554,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3443
3554
  else {
3444
3555
  setCommandMenu(null);
3445
3556
  }
3446
- }, [openFilePicker, autoResize, persistDraft]);
3557
+ }, [openFilePicker, autoResize, persistDraft, liveModelMode]);
3447
3558
  // ── Execute command ────────────────────────────────────────────────────────
3448
3559
  /** Sets textarea value and moves cursor to the end. */
3449
3560
  const setInputAndCursorEnd = useCallback((val) => {
@@ -3457,6 +3568,17 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3457
3568
  }
3458
3569
  }, 0);
3459
3570
  }, [setInputValue, autoResize]);
3571
+ // Viewer composer prefill: the box IS the team-chat input, so it starts with
3572
+ // "/teamsay " already typed (and handleSubmit re-fills it after each sent team
3573
+ // message). Once per mount, and never over text the user already has.
3574
+ const viewerPrefillDoneRef = useRef(false);
3575
+ useEffect(() => {
3576
+ if (canEdit !== false || viewerPrefillDoneRef.current)
3577
+ return;
3578
+ viewerPrefillDoneRef.current = true;
3579
+ if (!inputRef.current.trim())
3580
+ setInputAndCursorEnd('/teamsay ');
3581
+ }, [canEdit, setInputAndCursorEnd, conversationId]);
3460
3582
  /**
3461
3583
  * Select and apply a model by ID. When `mode` is given the choice persists to
3462
3584
  * that mode's per-mode field (`planModel` / `executeModel` /
@@ -3568,7 +3690,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3568
3690
  }
3569
3691
  else if (id === 'model') {
3570
3692
  setInputAndCursorEnd('/model ');
3571
- setModelPicker({ selectedIdx: -1 });
3693
+ setModelPicker({ selectedIdx: -1, mode: liveModelMode });
3572
3694
  }
3573
3695
  else if (id === 'mic' || id === 'dictate') {
3574
3696
  setInputValue('');
@@ -3660,7 +3782,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3660
3782
  defaultValue: "Today's AI allowance is used up — refreshes {{when}}.",
3661
3783
  })
3662
3784
  : t('ide.chat.usageAllowanceTodayLine', { percent: d.allowancePercent }, {
3663
- defaultValue: "You've used ~{{percent}}% of today's AI allowance.",
3785
+ // Neutral phrasing on purpose: the allowance belongs to
3786
+ // the PROJECT (its owner's plan window) — "you've used"
3787
+ // misattributed it to whichever teammate ran /cost.
3788
+ defaultValue: "~{{percent}}% of today's AI allowance used.",
3664
3789
  }))
3665
3790
  : '';
3666
3791
  // `inputTokens` counts only the UNCACHED prompt: every bond normalizes
@@ -3886,7 +4011,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3886
4011
  }
3887
4012
  else if (id === 'share') {
3888
4013
  setInputValue('');
3889
- setShareModal({ role: DEFAULT_SHARE_ROLE });
4014
+ if (shareAllowed)
4015
+ setShareModal({ role: DEFAULT_SHARE_ROLE });
3890
4016
  }
3891
4017
  }, [
3892
4018
  clearHistory,
@@ -3905,6 +4031,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3905
4031
  extraCommandIds,
3906
4032
  canEdit,
3907
4033
  allCommands,
4034
+ liveModelMode,
3908
4035
  t,
3909
4036
  ]);
3910
4037
  // When the auto-commit countdown reaches zero, fire the existing /commit path
@@ -4023,10 +4150,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4023
4150
  return;
4024
4151
  }
4025
4152
  // Handle /share [role] locally — opens the share-link modal at the requested
4026
- // role (default viewer). An unrecognized role shows usage instead.
4153
+ // role (default viewer). An unrecognized role shows usage instead. Below the
4154
+ // host's share capability (e.g. an editor where minting is admin+), say so
4155
+ // plainly instead of opening a modal whose every request 403s.
4027
4156
  const shareMatch = parseShareCommand(trimmed);
4028
4157
  if (shareMatch) {
4029
4158
  setInputValue('');
4159
+ if (!shareAllowed) {
4160
+ addSystemCard(t('ide.chat.share.notAllowed', undefined, {
4161
+ defaultValue: 'Managing share links needs an admin role on this project.',
4162
+ }));
4163
+ return;
4164
+ }
4030
4165
  if (shareMatch.kind === 'invalid') {
4031
4166
  addSystemCard(t('ide.chat.share.usage', { roles: SHARE_ROLES.join(', ') }, {
4032
4167
  defaultValue: 'Usage: /share [role] — create a public link. Roles: {{roles}} (default viewer).',
@@ -4108,20 +4243,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4108
4243
  { action: buildUpgradeCta?.({}) ?? undefined });
4109
4244
  }
4110
4245
  else {
4111
- try {
4112
- await http.patch(`/projects/${projectId}`, { settings: { chatModel: name } });
4113
- setCurrentModel(name);
4114
- setSavedChatModel(name);
4115
- addSystemCard(t('ide.chat.modelSet', { name: resolved?.label ?? name }, {
4116
- defaultValue: `Chat model set to ${resolved?.label ?? name}`,
4117
- }));
4118
- }
4119
- catch (error) {
4120
- logger.warn('Failed to update chat model via /model command', { error });
4121
- addSystemCard(t('ide.chat.modelError', undefined, {
4122
- defaultValue: 'Failed to update chat model.',
4123
- }));
4124
- }
4246
+ // Unscoped /model targets the CURRENT conversation mode (discovery =
4247
+ // plan), same as /effort never the legacy both-modes chatModel.
4248
+ await selectModel(name, resolved?.label ?? name, liveModelMode);
4125
4249
  }
4126
4250
  }
4127
4251
  setInputValue('');
@@ -4285,7 +4409,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4285
4409
  // event (persisted + broadcast to every member), which IS the visible message — so the
4286
4410
  // transcript never shows the literal "/command" text, and never shows it twice.
4287
4411
  if (matchesSideChannelCommand(allCommands, trimmed)) {
4288
- setInputValue('');
4412
+ // A viewer's composer is the team-chat box — re-fill the /teamsay prefix
4413
+ // after each sent team message so the next one is one keystroke away.
4414
+ if (canEdit === false)
4415
+ setInputAndCursorEnd('/teamsay ');
4416
+ else
4417
+ setInputValue('');
4289
4418
  sendMessage(trimmed, undefined, { suppressUserMessage: true });
4290
4419
  return;
4291
4420
  }
@@ -4335,7 +4464,17 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4335
4464
  setAttachedFiles([]);
4336
4465
  setAttachmentError(null);
4337
4466
  sendMessage(message, chatAttachments.length > 0 ? chatAttachments : undefined);
4338
- }, [attachedFiles, http, projectId, sendMessage, setInputValue, runSavedScript, allCommands]);
4467
+ }, [
4468
+ attachedFiles,
4469
+ http,
4470
+ projectId,
4471
+ sendMessage,
4472
+ setInputValue,
4473
+ runSavedScript,
4474
+ allCommands,
4475
+ selectModel,
4476
+ liveModelMode,
4477
+ ]);
4339
4478
  // External auto-submit. When the signal changes, submit the current input —
4340
4479
  // used by the prompt → chat morph to send the prefilled prompt once the chat
4341
4480
  // has docked into place (handleSubmit clears the input as it sends).
@@ -4371,12 +4510,22 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4371
4510
  useEffect(() => {
4372
4511
  if (openShareSignal !== undefined && openShareSignal !== lastOpenShareRef.current) {
4373
4512
  lastOpenShareRef.current = openShareSignal;
4374
- setShareModal({ role: DEFAULT_SHARE_ROLE });
4513
+ if (shareAllowed)
4514
+ setShareModal({ role: DEFAULT_SHARE_ROLE });
4375
4515
  }
4376
- }, [openShareSignal]);
4516
+ }, [openShareSignal, shareAllowed]);
4377
4517
  // ── Keyboard ───────────────────────────────────────────────────────────────
4378
4518
  const filteredCmds = commandMenu
4379
- ? allCommands.filter((c) => c.label.startsWith(inputRef.current))
4519
+ ? allCommands.filter((c) =>
4520
+ // A showAll menu (slash-button toggle over existing text) lists every
4521
+ // command; a typed menu filters by the composer's text.
4522
+ (commandMenu.showAll === true || c.label.startsWith(inputRef.current)) &&
4523
+ // Viewers see only commands they can actually run (viewer-safe reads +
4524
+ // the /teamsay side channel) — no dead entries in the menu.
4525
+ (canEdit !== false || c.viewerSafe === true || c.sideChannel === true) &&
4526
+ // /share is gated separately: hosts commonly mint at admin+, so an
4527
+ // editor without the capability gets no dead menu entry either.
4528
+ (c.id !== 'share' || shareAllowed))
4380
4529
  : [];
4381
4530
  const filteredModels = useMemo(() => {
4382
4531
  if (!modelPicker)
@@ -4663,9 +4812,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4663
4812
  e.preventDefault();
4664
4813
  const idx = modelPicker.selectedIdx >= 0 ? modelPicker.selectedIdx : 0;
4665
4814
  if (onManageCustomModels && idx === visibleModels.length) {
4815
+ const manageMode = modelPicker.mode === 'plan' || modelPicker.mode === 'execute'
4816
+ ? modelPicker.mode
4817
+ : liveModelMode;
4666
4818
  setModelPicker(null);
4667
4819
  setInputValue('');
4668
- onManageCustomModels();
4820
+ onManageCustomModels({ mode: manageMode });
4669
4821
  return;
4670
4822
  }
4671
4823
  const model = visibleModels[idx];
@@ -4706,20 +4858,28 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4706
4858
  if (commandMenu && filteredCmds.length > 0) {
4707
4859
  if (e.key === 'ArrowDown') {
4708
4860
  e.preventDefault();
4709
- setCommandMenu((m) => m ? { selectedIdx: wrapIdx(m.selectedIdx, 1, filteredCmds.length) } : null);
4861
+ setCommandMenu((m) => m ? { ...m, selectedIdx: wrapIdx(m.selectedIdx, 1, filteredCmds.length) } : null);
4710
4862
  return;
4711
4863
  }
4712
4864
  if (e.key === 'ArrowUp') {
4713
4865
  e.preventDefault();
4714
- setCommandMenu((m) => m ? { selectedIdx: wrapIdx(m.selectedIdx, -1, filteredCmds.length) } : null);
4866
+ setCommandMenu((m) => m ? { ...m, selectedIdx: wrapIdx(m.selectedIdx, -1, filteredCmds.length) } : null);
4715
4867
  return;
4716
4868
  }
4717
4869
  if (e.key === 'Enter' || e.key === 'Tab') {
4718
- e.preventDefault();
4719
- const cmd = filteredCmds[commandMenu.selectedIdx >= 0 ? commandMenu.selectedIdx : 0];
4720
- if (cmd)
4721
- void executeCommand(cmd.id);
4722
- return;
4870
+ // A showAll menu (opened by the slash BUTTON over existing text) only
4871
+ // executes an explicitly highlighted command a bare Enter closes it
4872
+ // and falls through to submit the composer text as typed.
4873
+ if (commandMenu.showAll && commandMenu.selectedIdx < 0 && e.key === 'Enter') {
4874
+ setCommandMenu(null);
4875
+ }
4876
+ else {
4877
+ e.preventDefault();
4878
+ const cmd = filteredCmds[commandMenu.selectedIdx >= 0 ? commandMenu.selectedIdx : 0];
4879
+ if (cmd)
4880
+ void executeCommand(cmd.id);
4881
+ return;
4882
+ }
4723
4883
  }
4724
4884
  }
4725
4885
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -4773,8 +4933,16 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4773
4933
  };
4774
4934
  }
4775
4935
  case 'mode':
4776
- // Only the plan→build handoff renders a card; the server records a mode card solely
4777
- // for 'execute' ("🔨 Building your app"). Anything else renders nothing.
4936
+ // The plan→build handoff ("🔨 Building your app") and the plan-mode
4937
+ // announcement a new conversation is seeded with ("📝 Plan mode")
4938
+ // the server records mode cards only for those two.
4939
+ if (cardEvent.mode === 'plan') {
4940
+ return {
4941
+ id,
4942
+ text: t('ide.chat.phasePlanning', undefined, { defaultValue: '📝 Plan mode' }),
4943
+ timestamp,
4944
+ };
4945
+ }
4778
4946
  if (cardEvent.mode !== 'execute')
4779
4947
  return null;
4780
4948
  return {
@@ -4836,6 +5004,20 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4836
5004
  // reading the guest limit they just escaped. Same reasoning the host's
4837
5005
  // `upgrade_prompt` card factory already applies to the recorded guest card.
4838
5006
  const isStaleAnonymousLimit = errorMeta?.requiresSignup === true && isAnonymous === false;
5007
+ // A read-only viewer's access-denied chat error must never sit as the
5008
+ // persistent red banner — every legitimate viewer action is already gated
5009
+ // client-side, so this only fires when something slipped through to the
5010
+ // server (or an older tab raced a role change). Flash a calm, gold
5011
+ // view-only notice for a few seconds instead, then clear.
5012
+ const isViewerAccessDenied = canEdit === false && !!error && /access denied/i.test(error);
5013
+ const [viewerDeniedFlash, setViewerDeniedFlash] = useState(false);
5014
+ useEffect(() => {
5015
+ if (!isViewerAccessDenied)
5016
+ return;
5017
+ setViewerDeniedFlash(true);
5018
+ const timer = setTimeout(() => setViewerDeniedFlash(false), 6000);
5019
+ return () => clearTimeout(timer);
5020
+ }, [isViewerAccessDenied, error]);
4839
5021
  const timeline = useMemo(() => {
4840
5022
  const items = [];
4841
5023
  // A card-message (role:'system' carrying a cardEvent) renders as a SYSTEM CARD, not a chat
@@ -5003,7 +5185,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5003
5185
  defaultValue: 'Show earlier messages',
5004
5186
  }) }) })), (timeline.length > maxVisibleItems ? timeline.slice(-maxVisibleItems) : timeline).map((item) => (_jsx(ChatItemBoundary, { onError: onRenderError, render: () => {
5005
5187
  if (item.kind === 'commit')
5006
- return (_jsx(CommitCardItem, { card: item.card, onRevert: handleRevertCommit }, item.card.id));
5188
+ return (_jsx(CommitCardItem, { card: item.card, onRevert: canEdit === false ? undefined : handleRevertCommit }, item.card.id));
5007
5189
  if (item.kind === 'activity')
5008
5190
  return (_jsx(ActivityCard, { activity: item.card.activity, onActivityClick: onActivityClick }, item.card.id));
5009
5191
  if (item.kind === 'tip')
@@ -5108,9 +5290,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5108
5290
  timestamp: msg.timestamp,
5109
5291
  status: 'done',
5110
5292
  hash,
5111
- }, onRevert: handleRevertCommit }, msg.id));
5293
+ }, onRevert: canEdit === false ? undefined : handleRevertCommit }, msg.id));
5112
5294
  }
5113
- return (_jsx(MessageItem, { msg: msg, sendMessage: sendMessage, handleAskUserResponse: handleAskUserResponse, isLoading: isLoading, streamingStatus: streamingStatus, onNavigatePreview: onNavigatePreview, undoneTcIds: undoneTcIds, handleUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, handleFileRevert: handleFileRevert, setInputAndCursorEnd: setInputAndCursorEnd, setModelPicker: setModelPicker, userAvatar: userAvatar,
5295
+ return (_jsx(MessageItem, { msg: msg, sendMessage: sendMessage, handleAskUserResponse: handleAskUserResponse, isLoading: isLoading, streamingStatus: streamingStatus, onNavigatePreview: onNavigatePreview, undoneTcIds: undoneTcIds, handleUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, handleFileRevert: handleFileRevert, setInputAndCursorEnd: setInputAndCursorEnd, setModelPicker: setModelPicker, chatMode: liveModelMode, userAvatar: userAvatar,
5114
5296
  // Avatar clicks open the signed-in user's OWN profile, so only
5115
5297
  // their own messages get the handler: author-less ones (the
5116
5298
  // local echo, legacy solo rows) or an author matching
@@ -5118,10 +5300,23 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5118
5300
  // clicking Test's face must not open Luke's profile editor.
5119
5301
  onAvatarClick: !msg.author?.id || (currentUserId != null && msg.author.id === currentUserId)
5120
5302
  ? onUserAvatarClick
5121
- : undefined, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName }, msg.id));
5303
+ : undefined, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName, canEdit: canEdit }, msg.id));
5122
5304
  } }, item.kind === 'message' ? item.msg.id : item.card.id))), error &&
5123
5305
  !isStaleAnonymousLimit &&
5124
- (errorMeta?.limitType ? (_jsx(ResourceLimitBanner, { message: error, action: buildUpgradeCta?.({ requiresSignup: errorMeta.requiresSignup }) })) : (_jsx("div", { className: cm.cn(cm.textSize('sm'), cm.sp('p', 2), cm.sp('mb', 2), cm.bgErrorSubtle, cm.textError), style: { borderRadius: '6px' }, children: error }))), (() => {
5306
+ (errorMeta?.limitType ? (_jsx(ResourceLimitBanner, { message: error, action: buildUpgradeCta?.({ requiresSignup: errorMeta.requiresSignup }) })) : isViewerAccessDenied ? (
5307
+ // A viewer's denial is expected state, not an alarm: a brief, calm,
5308
+ // gold view-only notice (matching the viewer tip / team-note
5309
+ // treatment) that clears itself — never the persistent red banner.
5310
+ viewerDeniedFlash ? (_jsxs("div", { "data-mol-id": "chat-viewer-denied-notice", className: cm.cn(cm.textSize('xs'), cm.textMuted), style: {
5311
+ display: 'flex',
5312
+ alignItems: 'flex-start',
5313
+ gap: 8,
5314
+ marginBottom: 8,
5315
+ ...chatCardStyle(NOTICE_TONE.gold.accent),
5316
+ lineHeight: 1.5,
5317
+ }, children: [_jsx(Icon, { name: "people", size: CHAT_CARD_ICON_SIZE, "aria-hidden": "true", style: { flexShrink: 0, marginTop: 1, color: NOTICE_TONE.gold.accent } }), _jsx("span", { style: { flex: 1 }, children: t('ide.chat.viewerReadOnly', undefined, {
5318
+ defaultValue: "You have view-only access, so you can't run the assistant here. You can still read along and use /teamsay to message the team.",
5319
+ }) })] })) : null) : (_jsx("div", { className: cm.cn(cm.textSize('sm'), cm.sp('p', 2), cm.sp('mb', 2), cm.bgErrorSubtle, cm.textError), style: { borderRadius: '6px' }, children: error }))), (() => {
5125
5320
  const showActivity = isLoading || awaitingSandboxBoot;
5126
5321
  const streamingMsg = isLoading
5127
5322
  ? [...visibleMessages].reverse().find((m) => m.isStreaming)
@@ -5920,9 +6115,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5920
6115
  cursor: 'pointer',
5921
6116
  textAlign: 'left',
5922
6117
  }, children: t('ide.chat.olderModelsExpand', { count: deprecatedModels.length }, { defaultValue: 'Older models ⌄ ({{count}})' }) }))] })), onManageCustomModels && (_jsx("button", { type: "button", "data-mol-id": "chat-model-manage-custom", onClick: () => {
6118
+ const manageMode = modelPicker.mode === 'plan' || modelPicker.mode === 'execute'
6119
+ ? modelPicker.mode
6120
+ : liveModelMode;
5923
6121
  setModelPicker(null);
5924
6122
  setInputValue('');
5925
- onManageCustomModels();
6123
+ onManageCustomModels({ mode: manageMode });
5926
6124
  }, onMouseEnter: (e) => {
5927
6125
  ;
5928
6126
  e.currentTarget.style.background =
@@ -6318,7 +6516,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6318
6516
  }, onMouseLeave: (e) => {
6319
6517
  e.currentTarget.style.opacity = '0.7';
6320
6518
  e.currentTarget.style.background = 'none';
6321
- }, children: _jsx("svg", { width: "13", height: "13", viewBox: "0 0 12 12", style: { display: 'block' }, children: _jsx("path", { d: "M 2.5,6.5 L 6,3 L 9.5,6.5 M 6,3.5 L 6,10", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) }))] }, qm.id))) })] })), pendingFiles != null &&
6519
+ }, children: _jsx("svg", { width: "13", height: "13", viewBox: "0 0 12 12", style: { display: 'block' }, children: _jsx("path", { d: "M 2.5,6.5 L 6,3 L 9.5,6.5 M 6,3.5 L 6,10", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) }))] }, qm.id))) })] })), canEdit !== false &&
6520
+ pendingFiles != null &&
6322
6521
  pendingFiles.length > 0 &&
6323
6522
  !commandMenu &&
6324
6523
  !modelPicker &&
@@ -6464,7 +6663,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6464
6663
  opacity: 0.5,
6465
6664
  transition: 'opacity 100ms, background 100ms',
6466
6665
  ...(!(f.additions || f.deletions) ? { marginLeft: 'auto' } : {}),
6467
- }, children: _jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "12", height: "12", fill: "currentColor", children: _jsx("path", { d: "M1.22 6.28a.749.749 0 0 1 0-1.06l3.5-3.5a.749.749 0 1 1 1.06 1.06L3.561 5h7.188l.001.007L10.749 5c.058 0 .116.007.171.019A4.501 4.501 0 0 1 10.5 14H8.796a.75.75 0 0 1 0-1.5H10.5a3 3 0 1 0 0-6H3.561L5.78 8.72a.749.749 0 1 1-1.06 1.06l-3.5-3.5Z" }) }) }))] }, f.path))) }))] })), _jsxs("div", { className: cm.surfaceSecondary, style: {
6666
+ }, children: _jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "12", height: "12", fill: "currentColor", children: _jsx("path", { d: "M1.22 6.28a.749.749 0 0 1 0-1.06l3.5-3.5a.749.749 0 1 1 1.06 1.06L3.561 5h7.188l.001.007L10.749 5c.058 0 .116.007.171.019A4.501 4.501 0 0 1 10.5 14H8.796a.75.75 0 0 1 0-1.5H10.5a3 3 0 1 0 0-6H3.561L5.78 8.72a.749.749 0 1 1-1.06 1.06l-3.5-3.5Z" }) }) }))] }, f.path))) }))] })), _jsxs("div", { className: cm.surfaceSecondary, ...(canEdit === false ? { 'data-mol-viewer': '' } : {}), style: {
6468
6667
  // Round the TOP corners only (8px) so the composer reads as a self-contained
6469
6668
  // input with its own border on all sides, flush at the bottom-left/right with
6470
6669
  // the panel's bottom edge. Discovery rounds ALL FOUR corners (the centered
@@ -6473,11 +6672,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6473
6672
  borderRadius: discovery ? 8 : '8px 8px 0 0',
6474
6673
  padding: '8px 10px',
6475
6674
  cursor: 'text',
6675
+ // View-only mode: the composer IS the team-chat box, so it wears the
6676
+ // team-message gold border (and the box is pre-filled with /teamsay).
6677
+ ...(canEdit === false ? { border: `1px solid ${NOTICE_TONE.gold.accent}` } : {}),
6476
6678
  }, onClick: (e) => {
6477
6679
  if (!e.target.closest('button')) {
6478
6680
  textareaRef.current?.focus();
6479
6681
  }
6480
- }, children: [_jsx("textarea", { ref: textareaRef, "data-mol-chat-input": "", defaultValue: inputRef.current, autoComplete: "off", onChange: handleInputChange, onPaste: handlePaste, placeholder: t('ide.chat.placeholder'), rows: 1, className: cm.textSize('sm'), style: {
6682
+ }, children: [_jsx("textarea", { ref: textareaRef, "data-mol-chat-input": "", defaultValue: inputRef.current, autoComplete: "off", onChange: handleInputChange, onPaste: handlePaste, placeholder: canEdit === false
6683
+ ? t('ide.chat.placeholderViewer', undefined, {
6684
+ defaultValue: 'Message your team',
6685
+ })
6686
+ : t('ide.chat.placeholder'), rows: 1, className: cm.textSize('sm'), style: {
6481
6687
  width: '100%',
6482
6688
  display: 'block',
6483
6689
  padding: 0,
@@ -6503,7 +6709,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6503
6709
  alignItems: 'center',
6504
6710
  marginTop: '6px',
6505
6711
  gap: '4px',
6506
- }, children: [_jsx("button", { type: "button", disabled: !canEdit, onClick: () => {
6712
+ }, children: [canEdit !== false && (_jsx("button", { type: "button", onClick: () => {
6507
6713
  const newMode = mode === 'plan' ? 'execute' : 'plan';
6508
6714
  setMode(newMode);
6509
6715
  http
@@ -6548,7 +6754,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6548
6754
  }, onMouseLeave: (e) => {
6549
6755
  if (mode !== 'plan')
6550
6756
  e.currentTarget.style.opacity = '0.4';
6551
- }, children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" }) }) }), fastModeAvailable && (_jsx("button", { type: "button", "data-mol-id": "chat-fast-mode-toggle", disabled: !canEdit, onClick: () => {
6757
+ }, children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" }) }) })), fastModeAvailable && canEdit !== false && (_jsx("button", { type: "button", "data-mol-id": "chat-fast-mode-toggle", disabled: !canEdit, onClick: () => {
6552
6758
  const next = !fastMode;
6553
6759
  setFastMode(next);
6554
6760
  http
@@ -6613,7 +6819,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6613
6819
  }, onMouseLeave: (e) => {
6614
6820
  if (!isListening)
6615
6821
  e.currentTarget.style.opacity = '0.4';
6616
- }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "16", height: "16", style: { display: 'block' }, children: [_jsx("rect", { x: "6", y: "1", width: "4", height: "8", rx: "2", fill: isListening ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: "1.25" }), _jsx("path", { d: "M4 7v1a4 4 0 008 0V7", fill: "none", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "8", y1: "12", x2: "8", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "6", y1: "15", x2: "10", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" })] }) }), _jsx("button", { type: "button", onClick: () => fileInputRef.current?.click(), title: t('ide.chat.attachFile', undefined, { defaultValue: 'Attach file' }), style: {
6822
+ }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "16", height: "16", style: { display: 'block' }, children: [_jsx("rect", { x: "6", y: "1", width: "4", height: "8", rx: "2", fill: isListening ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: "1.25" }), _jsx("path", { d: "M4 7v1a4 4 0 008 0V7", fill: "none", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "8", y1: "12", x2: "8", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "6", y1: "15", x2: "10", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" })] }) }), canEdit !== false && (_jsx("button", { type: "button", onClick: () => fileInputRef.current?.click(), title: t('ide.chat.attachFile', undefined, { defaultValue: 'Attach file' }), style: {
6617
6823
  display: 'inline-flex',
6618
6824
  alignItems: 'center',
6619
6825
  justifyContent: 'center',
@@ -6631,7 +6837,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6631
6837
  e.currentTarget.style.opacity = '0.85';
6632
6838
  }, onMouseLeave: (e) => {
6633
6839
  e.currentTarget.style.opacity = '0.4';
6634
- }, children: _jsx("svg", { width: "15", height: "15", viewBox: "0 0 16 16", fill: "currentColor", style: { display: 'block' }, children: _jsx("path", { d: "M12.212 3.02a1.753 1.753 0 0 0-2.478.003l-5.83 5.83a3.007 3.007 0 0 0-.88 2.127c0 .795.315 1.551.88 2.116.567.567 1.333.89 2.126.89.79 0 1.548-.321 2.116-.89l5.48-5.48a.75.75 0 0 1 1.061 1.06l-5.48 5.48a4.492 4.492 0 0 1-3.177 1.33c-1.2 0-2.345-.487-3.187-1.33a4.483 4.483 0 0 1-1.32-3.177c0-1.195.475-2.341 1.32-3.186l5.83-5.83a3.25 3.25 0 0 1 5.553 2.297c0 .863-.343 1.691-.953 2.301L7.439 12.39c-.375.377-.884.59-1.416.593a1.998 1.998 0 0 1-1.412-.593 1.992 1.992 0 0 1 0-2.828l5.48-5.48a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-5.48 5.48a.492.492 0 0 0 0 .707.499.499 0 0 0 .352.154.51.51 0 0 0 .356-.154l5.833-5.827a1.755 1.755 0 0 0 0-2.481Z" }) }) }), [
6840
+ }, children: _jsx("svg", { width: "15", height: "15", viewBox: "0 0 16 16", fill: "currentColor", style: { display: 'block' }, children: _jsx("path", { d: "M12.212 3.02a1.753 1.753 0 0 0-2.478.003l-5.83 5.83a3.007 3.007 0 0 0-.88 2.127c0 .795.315 1.551.88 2.116.567.567 1.333.89 2.126.89.79 0 1.548-.321 2.116-.89l5.48-5.48a.75.75 0 0 1 1.061 1.06l-5.48 5.48a4.492 4.492 0 0 1-3.177 1.33c-1.2 0-2.345-.487-3.187-1.33a4.483 4.483 0 0 1-1.32-3.177c0-1.195.475-2.341 1.32-3.186l5.83-5.83a3.25 3.25 0 0 1 5.553 2.297c0 .863-.343 1.691-.953 2.301L7.439 12.39c-.375.377-.884.59-1.416.593a1.998 1.998 0 0 1-1.412-.593 1.992 1.992 0 0 1 0-2.828l5.48-5.48a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-5.48 5.48a.492.492 0 0 0 0 .707.499.499 0 0 0 .352.154.51.51 0 0 0 .356-.154l5.833-5.827a1.755 1.755 0 0 0 0-2.481Z" }) }) })), [
6635
6841
  {
6636
6842
  sym: '@',
6637
6843
  nudgeY: 0,
@@ -6660,34 +6866,38 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6660
6866
  defaultValue: 'Slash commands',
6661
6867
  }),
6662
6868
  onClick: () => {
6663
- const cur = inputRef.current;
6664
- if (cur === '/') {
6665
- // Toggle off remove the slash this button added
6666
- setInputValue('');
6667
- autoResize();
6869
+ // TOGGLE the menu regardless of the composer's current text —
6870
+ // never clobbering it. With text present the menu opens in
6871
+ // showAll mode (every command listed; picking one replaces the
6872
+ // input via executeCommand's prefill). An empty box still gets
6873
+ // the type-ahead '/' so filtering-by-typing works as before.
6874
+ if (commandMenu) {
6668
6875
  setCommandMenu(null);
6876
+ if (inputRef.current === '/') {
6877
+ // Remove the slash this button added on open.
6878
+ setInputValue('');
6879
+ autoResize();
6880
+ }
6669
6881
  setTimeout(() => {
6670
6882
  textareaRef.current?.focus();
6671
6883
  }, 0);
6884
+ return;
6672
6885
  }
6673
- else if (!cur) {
6674
- // Cursor lands AFTER the slash so the user can type the
6675
- // command immediately (or backspace to remove it).
6676
- // Popups are one-at-a-time — close siblings first.
6677
- setMicPicker(null);
6678
- setModelPicker(null);
6679
- setSoundsPicker(null);
6886
+ // Popups are one-at-a-time — close siblings first.
6887
+ setMicPicker(null);
6888
+ setModelPicker(null);
6889
+ setSoundsPicker(null);
6890
+ const cur = inputRef.current;
6891
+ if (!cur)
6680
6892
  setInputAndCursorEnd('/');
6681
- setCommandMenu({ selectedIdx: -1 });
6682
- }
6683
- else {
6684
- setTimeout(() => {
6685
- textareaRef.current?.focus();
6686
- }, 0);
6687
- }
6893
+ setCommandMenu({ selectedIdx: -1, showAll: cur !== '' && cur !== '/' });
6688
6894
  },
6689
6895
  },
6690
- ].map(({ sym, nudgeY, size: fontSize, title, onClick }) => (_jsx("button", { type: "button", onClick: onClick, title: title, style: {
6896
+ ]
6897
+ // Viewers keep the / shortcut (viewer-safe commands + /teamsay) but not @ —
6898
+ // file mentions only feed Synthase turns.
6899
+ .filter(({ sym }) => canEdit !== false || sym !== '@')
6900
+ .map(({ sym, nudgeY, size: fontSize, title, onClick }) => (_jsx("button", { type: "button", onClick: onClick, title: title, style: {
6691
6901
  display: 'inline-flex',
6692
6902
  alignItems: 'center',
6693
6903
  justifyContent: 'center',
@@ -6708,7 +6918,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6708
6918
  e.currentTarget.style.opacity = '0.85';
6709
6919
  }, onMouseLeave: (e) => {
6710
6920
  e.currentTarget.style.opacity = '0.4';
6711
- }, children: sym === 'slash' ? (_jsx("svg", { width: "9", height: "13", viewBox: "0 0 9 13", style: { display: 'block', position: 'relative', top: `${nudgeY}px` }, children: _jsx("line", { x1: "8", y1: "1", x2: "1", y2: "12", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) })) : (_jsx("span", { style: { position: 'relative', top: `${nudgeY}px` }, children: sym })) }, sym))), contextUsage &&
6921
+ }, children: sym === 'slash' ? (_jsx("svg", { width: "9", height: "13", viewBox: "0 0 9 13", style: { display: 'block', position: 'relative', top: `${nudgeY}px` }, children: _jsx("line", { x1: "8", y1: "1", x2: "1", y2: "12", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) })) : (_jsx("span", { style: { position: 'relative', top: `${nudgeY}px` }, children: sym })) }, sym))), canEdit !== false &&
6922
+ contextUsage &&
6712
6923
  (() => {
6713
6924
  // The ring represents usage toward the auto-compaction threshold,
6714
6925
  // not the raw context window. 100% = compaction will trigger.
@@ -6777,11 +6988,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6777
6988
  transition: 'opacity 150ms',
6778
6989
  }, children: [pct, "%"] }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, style: { display: 'block', transform: 'rotate(-90deg)', flexShrink: 0 }, children: [_jsx("circle", { cx: size / 2, cy: size / 2, r: r, fill: "none", stroke: "currentColor", strokeWidth: stroke, opacity: 0.15 }), _jsx("circle", { cx: size / 2, cy: size / 2, r: r, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${c}`, strokeDashoffset: `${dashOffset}`, strokeLinecap: "round" })] })] }));
6779
6990
  })(), _jsxs("div", { style: {
6780
- marginLeft: contextUsage ? '4px' : 'auto',
6991
+ // 'auto' pins the stop/send cluster right; the context ring
6992
+ // (hidden for viewers even when contextUsage exists) otherwise
6993
+ // carries the auto margin and this becomes a 4px gap.
6994
+ marginLeft: canEdit !== false && contextUsage ? '4px' : 'auto',
6781
6995
  display: 'flex',
6782
6996
  gap: '4px',
6783
6997
  alignItems: 'center',
6784
- }, children: [(isLoading || isRemoteStreaming) && (_jsx("button", { type: "button", onClick: handleAbort, title: t('ide.chat.stop', undefined, { defaultValue: 'Stop' }), onMouseEnter: (e) => {
6998
+ }, children: [canEdit !== false && (isLoading || isRemoteStreaming) && (_jsx("button", { type: "button", onClick: handleAbort, title: t('ide.chat.stop', undefined, { defaultValue: 'Stop' }), onMouseEnter: (e) => {
6785
6999
  e.currentTarget.style.background = 'rgba(248,81,73,0.3)';
6786
7000
  e.currentTarget.style.borderColor = 'rgba(248,81,73,0.65)';
6787
7001
  }, onMouseLeave: (e) => {
@@ -6835,7 +7049,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6835
7049
  }
6836
7050
  : undefined,
6837
7051
  });
6838
- } })), shareModal && (_jsx(ShareModal, { projectId: projectId, initialRole: shareModal.role, onClose: () => setShareModal(null), onCreated: (result) => {
7052
+ } })), shareModal && (_jsx(ShareModal, { projectId: projectId, initialRole: shareModal.role, canManage: shareAllowed, onClose: () => setShareModal(null), onCreated: (result) => {
6839
7053
  // Surface the created link in the timeline so it persists after the
6840
7054
  // modal closes — the role label and the public URL are both shown.
6841
7055
  addSystemCard(t('ide.chat.share.created', { role: result.role }, { defaultValue: 'Created a {{role}} share link.' }), {
@@ -6854,8 +7068,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6854
7068
  * @param props - Component props (see {@link MessageItemProps}).
6855
7069
  * @returns The rendered chat panel element.
6856
7070
  */
6857
- export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessageSent, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, initialInputValue, hideConversationMenu, renderConversationHeader = true, conversationId: controlledConversationId, chatKey: controlledChatKey, onConversationId: controlledOnConversationId, openShareSignal: controlledShareSignal, openReportSignal: controlledReportSignal, openSettingsSignal: controlledSettingsSignal, onManageCustomModels, gitStatusTick, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, isPro, isAnonymous, canEdit = true, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, className, }) {
7071
+ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessageSent, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, initialInputValue, hideConversationMenu, renderConversationHeader = true, conversationId: controlledConversationId, chatKey: controlledChatKey, onConversationId: controlledOnConversationId, openShareSignal: controlledShareSignal, openReportSignal: controlledReportSignal, openSettingsSignal: controlledSettingsSignal, onManageCustomModels, gitStatusTick, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, isPro, isAnonymous, canEdit = true, canShare, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, className, }) {
6858
7072
  const cm = getClassMap();
7073
+ // Share management may be gated ABOVE canEdit by the host (see
7074
+ // ChatPanelProps.canShare) — gates the built-in header share button here and
7075
+ // is threaded into ChatInner for the /share command + modal.
7076
+ const shareAllowed = canShare ?? canEdit !== false;
6859
7077
  const isNarrow = useNarrowViewport();
6860
7078
  const isCoarse = useCoarsePointer();
6861
7079
  const http = useHttpClient();
@@ -7008,11 +7226,11 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
7008
7226
  textOverflow: 'ellipsis',
7009
7227
  whiteSpace: 'nowrap',
7010
7228
  opacity: 0.7,
7011
- }, children: activeConv?.preview ?? 'Chat history' })] }), _jsx("button", { type: "button", "data-mol-id": "chat-share-button", onClick: () => setOpenShareSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.share.openShare', undefined, { defaultValue: 'Share project' }), "aria-label": t('ide.chat.share.openShare', undefined, {
7229
+ }, children: activeConv?.preview ?? 'Chat history' })] }), shareAllowed && (_jsx("button", { type: "button", "data-mol-id": "chat-share-button", onClick: () => setOpenShareSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.share.openShare', undefined, { defaultValue: 'Share project' }), "aria-label": t('ide.chat.share.openShare', undefined, {
7012
7230
  defaultValue: 'Share project',
7013
- }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "share", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", "data-mol-id": "chat-report-button", onClick: () => setOpenReportSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.report.openReport', undefined, { defaultValue: 'Report a bug' }), "aria-label": t('ide.chat.report.openReport', undefined, {
7231
+ }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "share", size: 14, "aria-hidden": "true" }) })), _jsx("button", { type: "button", "data-mol-id": "chat-report-button", onClick: () => setOpenReportSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.report.openReport', undefined, { defaultValue: 'Report a bug' }), "aria-label": t('ide.chat.report.openReport', undefined, {
7014
7232
  defaultValue: 'Report a bug',
7015
- }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "bug", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", "data-mol-id": "chat-settings-button", onClick: () => setOpenSettingsSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), "aria-label": t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "gear", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", onClick: handleNewChat, className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.newChat', undefined, { defaultValue: 'New chat' }), style: { flexShrink: 0 }, children: "+" }), showDropdown && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
7233
+ }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "bug", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", "data-mol-id": "chat-settings-button", onClick: () => setOpenSettingsSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), "aria-label": t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "gear", size: 14, "aria-hidden": "true" }) }), canEdit !== false && (_jsx("button", { type: "button", onClick: handleNewChat, className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.newChat', undefined, { defaultValue: 'New chat' }), style: { flexShrink: 0 }, children: "+" })), showDropdown && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
7016
7234
  position: 'absolute',
7017
7235
  top: '100%',
7018
7236
  left: 0,
@@ -7063,7 +7281,7 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
7063
7281
  textOverflow: 'ellipsis',
7064
7282
  whiteSpace: 'nowrap',
7065
7283
  width: '100%',
7066
- }, children: conv.preview ?? 'New conversation' }), _jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { opacity: 0.55 }, children: relativeTime(conv.updatedAt) })] }, conv.id)))] }))] })), _jsx(ChatInner, { projectId: projectId, endpoint: chatEndpoint, initialMessage: initialMessage, onInitialMessageSent: onInitialMessageSent, isPro: isPro, isAnonymous: isAnonymous, canEdit: canEdit, buildUpgradeCta: buildUpgradeCta, buildHelpUpgradeSection: buildHelpUpgradeSection, activeFile: activeFile, openTabs: openTabs, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: onFileRevert, onFileChange: onFileChange, onFileDeleted: onFileDeleted, onCommit: onCommit, onConversationId: reportConversationId, onActivityClick: onActivityClick, onRenderError: onRenderError, onProfileClick: onProfileClick, currentUserId: currentUserId, onReadyToBuild: onReadyToBuild, awaitingSandboxBoot: awaitingSandboxBoot, onClientAction: onClientAction, onTurnComplete: onTurnComplete, onLoadingChange: onLoadingChange, onNavigatePreview: onNavigatePreview, onRegisterPushHandler: onRegisterPushHandler, autoSubmitSignal: autoSubmitSignal, openSettingsSignal: effectiveSettingsSignal, onManageCustomModels: onManageCustomModels, openReportSignal: effectiveReportSignal, openShareSignal: effectiveShareSignal, initialInputValue: initialInputValue, pendingMessage: pendingMessage, pendingMessageKey: pendingMessageKey, pendingMessageSuppressUser: pendingMessageSuppressUser, pendingMessageUserInitiated: pendingMessageUserInitiated, userEditedFile: userEditedFile, userEditedFileKey: userEditedFileKey, gitStatusTick: gitStatusTick, discovery: hideConversationMenu, userAvatar: userAvatar, agentName: agentName, productName: productName, version: version, extraCommands: extraCommands, feedbackUrl: feedbackUrl }, chatKey)] }));
7284
+ }, children: conv.preview ?? 'New conversation' }), _jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { opacity: 0.55 }, children: relativeTime(conv.updatedAt) })] }, conv.id)))] }))] })), _jsx(ChatInner, { projectId: projectId, endpoint: chatEndpoint, initialMessage: initialMessage, onInitialMessageSent: onInitialMessageSent, isPro: isPro, isAnonymous: isAnonymous, canEdit: canEdit, canShare: shareAllowed, buildUpgradeCta: buildUpgradeCta, buildHelpUpgradeSection: buildHelpUpgradeSection, activeFile: activeFile, openTabs: openTabs, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: onFileRevert, onFileChange: onFileChange, onFileDeleted: onFileDeleted, onCommit: onCommit, onConversationId: reportConversationId, onActivityClick: onActivityClick, onRenderError: onRenderError, onProfileClick: onProfileClick, currentUserId: currentUserId, onReadyToBuild: onReadyToBuild, awaitingSandboxBoot: awaitingSandboxBoot, onClientAction: onClientAction, onTurnComplete: onTurnComplete, onLoadingChange: onLoadingChange, onNavigatePreview: onNavigatePreview, onRegisterPushHandler: onRegisterPushHandler, autoSubmitSignal: autoSubmitSignal, openSettingsSignal: effectiveSettingsSignal, onManageCustomModels: onManageCustomModels, openReportSignal: effectiveReportSignal, openShareSignal: effectiveShareSignal, initialInputValue: initialInputValue, pendingMessage: pendingMessage, pendingMessageKey: pendingMessageKey, pendingMessageSuppressUser: pendingMessageSuppressUser, pendingMessageUserInitiated: pendingMessageUserInitiated, userEditedFile: userEditedFile, userEditedFileKey: userEditedFileKey, gitStatusTick: gitStatusTick, discovery: hideConversationMenu, userAvatar: userAvatar, agentName: agentName, productName: productName, version: version, extraCommands: extraCommands, feedbackUrl: feedbackUrl }, chatKey)] }));
7067
7285
  }
7068
7286
  ChatPanel.displayName = 'ChatPanel';
7069
7287
  //# sourceMappingURL=ChatPanel.js.map