@molecule/app-ide-react 1.6.0 → 1.8.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 +443 -169
  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 +3 -3
@@ -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
@@ -1265,16 +1301,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1265
1301
  clearInterval(autoFixIntervalRef.current);
1266
1302
  }, []);
1267
1303
  // Ref so the stream-event callback can push activity cards without depending
1268
- // on the state setter (mirrors appendCardMessageRef).
1304
+ // on the state setter.
1269
1305
  const addActivityCardRef = useRef(() => { });
1270
- // Ref to useChat's appendCardMessage, so the broadcast path (a teammate's `card` event,
1271
- // applied through handleStreamEvent) can append a card-message to the ONE message store.
1272
- // This client's OWN `card` events are appended internally by useChat's stream handler.
1273
- const appendCardMessageRef = useRef(() => { });
1274
- // Ref to useChat's appendCompleteMessage — same contract as appendCardMessageRef, for a
1275
- // teammate's broadcast `message` event (a complete non-streaming message, e.g. a
1276
- // human-only team note). This client's OWN `message` events append internally in useChat.
1277
- const appendCompleteMessageRef = useRef(() => { });
1306
+ // (A teammate's broadcast `card` / `message` events are ingested by
1307
+ // useChat.applyRemoteEvent applyPushedStreamEvent routes every pushed frame
1308
+ // through it before this panel's handler runs, so no append refs live here.)
1278
1309
  // Kept current each render so handleStreamEvent (memoized) always calls the latest.
1279
1310
  const onReadyToBuildRef = useRef(onReadyToBuild);
1280
1311
  onReadyToBuildRef.current = onReadyToBuild;
@@ -1292,9 +1323,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1292
1323
  contextWindow: event.usage.contextWindow,
1293
1324
  });
1294
1325
  }
1295
- // Capture verification errors to trigger countdown after stream ends.
1326
+ // Capture verification errors to trigger countdown after stream ends
1327
+ // SENDER-ONLY: a pushed frame from a teammate's turn must not arm this
1328
+ // client's auto-fix (the sender's client owns the follow-up; two clients
1329
+ // arming it would double-send the fix).
1296
1330
  // Also drop any deferred preview error — verification's countdown handles it.
1297
- if (event.type === 'verification_result' && event.status === 'error' && event.output) {
1331
+ if (!applyingPushedRef.current &&
1332
+ event.type === 'verification_result' &&
1333
+ event.status === 'error' &&
1334
+ event.output) {
1298
1335
  pendingVerificationRef.current = {
1299
1336
  output: event.output,
1300
1337
  categories: event.categories ?? [],
@@ -1312,22 +1349,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1312
1349
  if (event.type === 'verification_result' && event.status === 'ok') {
1313
1350
  pendingVerificationRef.current = null;
1314
1351
  }
1315
- // Inline transcript CARD (model / mode / skills / custom notice). The server records it
1316
- // into the ONE message transcript and emits it here; this client's OWN stream appends
1317
- // the card-message via useChat (case 'card'). Here we ONLY handle a TEAMMATE's broadcast
1318
- // (applied through applyPushedStreamEvent → handleStreamEvent): append it to the local
1319
- // store so it shows live for the collaborator too (de-duped by id; never re-persisted —
1320
- // the originating server already persisted it, and on reload it loads with the messages).
1321
- if (event.type === 'card' && applyingPushedRef.current) {
1322
- appendCardMessageRef.current(event.id, event.timestamp, event.card);
1323
- }
1324
- // A teammate's broadcast complete message (e.g. a human-only team note) — append it
1325
- // to the local store so it shows live for the collaborator too (de-duped by id;
1326
- // never re-persisted — the originating server already persisted it). This client's
1327
- // OWN `message` stream events are appended internally by useChat.
1328
- if (event.type === 'message' && applyingPushedRef.current && event.message) {
1329
- appendCompleteMessageRef.current(event.message);
1330
- }
1352
+ // NOTE: pushed `card` / `message` events are ingested into the message store by
1353
+ // useChat.applyRemoteEvent (applyPushedStreamEvent calls it before this handler),
1354
+ // through the same append helpers as an own stream no panel-side append here.
1331
1355
  // Captured outbound side effect (email/sms/push/webhook/channel) — push an
1332
1356
  // inline activity card into the timeline. Non-text card, mirroring how
1333
1357
  // system cards are appended.
@@ -1335,9 +1359,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1335
1359
  addActivityCardRef.current(activityFromEvent(event.activity));
1336
1360
  }
1337
1361
  // Discovery finished and the server selected a starting point — boot the
1338
- // sandbox. The template choice is internal; this event carries no
1339
- // user-facing payload and is never rendered in the transcript.
1340
- if (event.type === 'ready_to_build') {
1362
+ // sandbox. SENDER-ONLY: the sender's client drives the boot; a watching
1363
+ // client's host follows the project's sandbox status instead of racing a
1364
+ // second boot request. The template choice is internal; this event carries
1365
+ // no user-facing payload and is never rendered in the transcript.
1366
+ if (event.type === 'ready_to_build' && !applyingPushedRef.current) {
1341
1367
  onReadyToBuildRef.current?.();
1342
1368
  }
1343
1369
  // The agent asked the IDE to reload/navigate the preview, open a file, or drive the
@@ -1375,8 +1401,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1375
1401
  // still drives the phase STATE (useChat's setMode); it just no longer spawns a card.
1376
1402
  const cfg = soundsConfigRef.current;
1377
1403
  const eventType = event.type;
1378
- if (eventType in cfg && shouldPlaySound(cfg[eventType])) {
1379
- playTone();
1404
+ // 'message' is the team-note ping: it fires only for a TEAMMATE's note
1405
+ // (the pushed broadcast) — never for the sender's own SSE echo.
1406
+ const isOwnTeamNote = event.type === 'message' && !applyingPushedRef.current;
1407
+ if (!isOwnTeamNote && eventType in cfg && shouldPlaySound(cfg[eventType])) {
1408
+ playTone(eventType === 'message' ? 'team' : 'default');
1380
1409
  }
1381
1410
  }, [t]);
1382
1411
  // Countdown timer effect — ticks down and auto-sends fix message
@@ -1462,10 +1491,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1462
1491
  }
1463
1492
  onFileChange?.(path, content);
1464
1493
  }, [onFileChange, onFileOpen, autoFixCountdown]);
1465
- const { messages, isLoading, isRemoteStreaming, noteRemoteStreamEvent, error, errorMeta, mode, fastMode, streamingStatus, setMode, setFastMode, sendMessage, abort, clearHistory, editQueuedMessage, deleteQueuedMessage, clearQueuedForFile, appendCardMessage, appendCompleteMessage, retryCountdown, cancelRetry, } = useChat({
1494
+ const { messages, isLoading, isRemoteStreaming, noteRemoteStreamEvent, error, errorMeta, mode, fastMode, streamingStatus, setMode, setFastMode, sendMessage, abort, clearHistory, editQueuedMessage, deleteQueuedMessage, clearQueuedForFile, applyRemoteEvent, retryCountdown, cancelRetry, } = useChat({
1466
1495
  endpoint,
1467
1496
  projectId,
1468
1497
  agentName,
1498
+ // A read-only viewer never issues chat POSTs (no resume/retry) — a live turn
1499
+ // is watched via pushed frames + the reconcile poll instead.
1500
+ readOnly: canEdit === false,
1469
1501
  // ALWAYS load history on mount. A persisted conversation MUST restore on refresh,
1470
1502
  // even when an initialMessage / initialInputValue is also present. The old condition
1471
1503
  // suppressed the load whenever a fresh message was about to be auto-sent — but on a
@@ -1483,13 +1515,23 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1483
1515
  onConversationId,
1484
1516
  onStreamEvent: handleStreamEvent,
1485
1517
  });
1518
+ // The mode a model CHANGE should target when the user hasn't scoped one
1519
+ // explicitly (no --plan/--execute flag, no picker-mode choice): the LIVE
1520
+ // conversation mode. Discovery runs in plan mode (aiContext.mode is 'plan'
1521
+ // throughout discovery), so it maps to 'plan' — changing the model while
1522
+ // discovering/planning changes the plan model, while building the execute
1523
+ // model. This replaces the old default of writing the legacy `chatModel`
1524
+ // (which silently moved BOTH modes).
1525
+ const liveModelMode = mode === 'plan' ? 'plan' : 'execute';
1526
+ // Target for a settings PATCH that changes agent behavior (model / effort /
1527
+ // max loops / region / auto-fix / auto-approve): name the OPEN conversation so
1528
+ // the server's shared setting card lands in the transcript members are
1529
+ // actually watching (a project can hold several conversations).
1530
+ const settingsPatchUrl = useCallback(() => `/projects/${projectId}${conversationIdRef.current
1531
+ ? `?conversationId=${encodeURIComponent(conversationIdRef.current)}`
1532
+ : ''}`, [projectId]);
1486
1533
  // Keep sendMessageRef in sync so the countdown effect can call the latest sendMessage
1487
1534
  sendMessageRef.current = sendMessage;
1488
- // Keep the card-append ref current so handleStreamEvent (memoized) can append a teammate's
1489
- // broadcast `card` event to the message store.
1490
- appendCardMessageRef.current = appendCardMessage;
1491
- // Same for a teammate's broadcast complete `message` event (e.g. a team note).
1492
- appendCompleteMessageRef.current = appendCompleteMessage;
1493
1535
  // User Stop. A stop is a user decision the platform must not overrule — so
1494
1536
  // beyond killing the stream (useChat.abort also records the stop client-side
1495
1537
  // and, via chat-abort's userInitiated flag, server-side), drop every pending
@@ -1627,7 +1669,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1627
1669
  useEffect(() => {
1628
1670
  if (typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches)
1629
1671
  return;
1630
- textareaRef.current?.focus();
1672
+ const ta = textareaRef.current;
1673
+ if (!ta)
1674
+ return;
1675
+ ta.focus();
1676
+ // The textarea can mount with content already in it (defaultValue carries a
1677
+ // persisted draft, and a viewer's box holds the '/teamsay ' prefill) — a
1678
+ // fresh element's selection is (0,0), so without this the caret sits BEFORE
1679
+ // the text and typing lands in front of '/teamsay'.
1680
+ ta.setSelectionRange(ta.value.length, ta.value.length);
1631
1681
  }, []);
1632
1682
  /** Update the ref, the DOM element, and the hasInput flag without re-rendering the parent. */
1633
1683
  const setInputValue = useCallback((val) => {
@@ -1635,7 +1685,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1635
1685
  const ta = textareaRef.current;
1636
1686
  if (ta && ta.value !== val)
1637
1687
  ta.value = val;
1638
- setHasInput(Boolean(val.trim()));
1688
+ // A viewer's composer is pre-filled with '/teamsay ' — the BARE prefix is
1689
+ // not sendable content, so it must not light the Send button.
1690
+ const bareSideChannel = canEdit === false && /^\/(?:teamsay|t)$/i.test(val.trim());
1691
+ setHasInput(Boolean(val.trim()) && !bareSideChannel);
1639
1692
  autoResize();
1640
1693
  // Clear persisted draft when input is emptied (e.g. on submit)
1641
1694
  if (!val) {
@@ -1646,7 +1699,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1646
1699
  /* sessionStorage unavailable — safe to ignore, draft simply persists */
1647
1700
  }
1648
1701
  }
1649
- }, [draftKey, autoResize]);
1702
+ }, [draftKey, autoResize, canEdit]);
1650
1703
  // Persist draft text to sessionStorage so it survives refresh (debounced)
1651
1704
  const draftTimerRef = useRef(null);
1652
1705
  const persistDraft = useCallback(() => {
@@ -2168,17 +2221,24 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2168
2221
  }
2169
2222
  }, []);
2170
2223
  // Apply a chat event broadcast by another project member (the chat push channel). The
2171
- // originating member's SERVER already persisted everything (the transcript card-messages and
2172
- // the activity cards); this viewer only renders the broadcast live and gets the durable copy
2173
- // on reload. A pushed `card` event appends an ephemeral card-message (never persisted by this
2174
- // viewer); a pushed `activity` is flagged `received` so it renders but is not re-PUT.
2224
+ // originating member's SERVER already persisted everything; this client renders the
2225
+ // broadcast live and gets the durable copy on reload. EVERY frame of a remote turn
2226
+ // arrives here text/thinking deltas, tool events, verification, cards, the user
2227
+ // message, done and is ingested into the message store through useChat's
2228
+ // applyRemoteEvent (the same content applier as an own SSE stream), so watching a
2229
+ // teammate's turn looks exactly like running one. handleStreamEvent then handles the
2230
+ // panel-level concerns (context usage, sounds, activity cards) with the
2231
+ // applyingPushed flag gating the sender-only side effects (auto-fix, ready_to_build).
2175
2232
  const applyPushedStreamEvent = useCallback((frameConversationId, event) => {
2176
2233
  // Only apply broadcasts for the conversation this panel has open.
2177
2234
  if (frameConversationId !== conversationIdRef.current)
2178
2235
  return;
2179
- // Flag the window so a pushed activity card is marked `received` (rendered, not re-PUT);
2180
- // a pushed card-message is appended ephemerally. handleStreamEvent is synchronous, so the
2181
- // flag is set for exactly this event's handling.
2236
+ // Store ingestion first, so the transcript is current before any panel
2237
+ // side effect reads it.
2238
+ applyRemoteEvent(event);
2239
+ // Flag the window so a pushed activity card is marked `received` (rendered, not
2240
+ // re-PUT) and sender-only side effects are skipped. handleStreamEvent is
2241
+ // synchronous, so the flag is set for exactly this event's handling.
2182
2242
  applyingPushedRef.current = true;
2183
2243
  try {
2184
2244
  handleStreamEvent(event);
@@ -2191,7 +2251,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2191
2251
  // continuation), tell useChat so it confirms against the server's streaming
2192
2252
  // flag and keeps the Stop button visible + functional for the remote turn.
2193
2253
  noteRemoteStreamEvent();
2194
- }, [handleStreamEvent, noteRemoteStreamEvent]);
2254
+ }, [applyRemoteEvent, handleStreamEvent, noteRemoteStreamEvent]);
2195
2255
  // Register the pushed-event handler with the parent (Workspace) so it can deliver
2196
2256
  // broadcast chat events from other project members; deregister on unmount.
2197
2257
  useEffect(() => {
@@ -2289,10 +2349,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2289
2349
  return () => {
2290
2350
  cancelled = true;
2291
2351
  };
2292
- }, [conversationId, projectId, http]);
2352
+ }, [conversationId, projectId, http, canEdit]);
2293
2353
  useEffect(() => {
2294
2354
  if (!conversationId || activityCardsLoadedConvRef.current !== conversationId)
2295
2355
  return;
2356
+ if (canEdit === false)
2357
+ return;
2296
2358
  void http
2297
2359
  .put(`/projects/${projectId}/conversations/${conversationId}/activity-cards`, {
2298
2360
  activityCards: activityCards.filter((c) => !c.received),
@@ -2301,7 +2363,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2301
2363
  // Best-effort save (bound as _error per Rule 14): the in-memory cards remain the
2302
2364
  // source of truth this session if the PUT fails.
2303
2365
  });
2304
- }, [activityCards, conversationId, projectId, http]);
2366
+ }, [activityCards, conversationId, projectId, http, canEdit]);
2305
2367
  // ── Auto-tips (dismissable onboarding hints) ──────────────────────────────
2306
2368
  // Two surfaces (see chat-tips-utilities): an ENTRY_TIP shown once on a fresh
2307
2369
  // conversation so a brand-new user always sees how to drive the agent, plus an
@@ -2331,6 +2393,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2331
2393
  shownTipIdsRef.current = [];
2332
2394
  // Re-arm the entry tip so a freshly-started chat shows the onboarding hint.
2333
2395
  entryTipShownRef.current = false;
2396
+ // Re-arm the viewer composer prefill ('/teamsay ') for the new conversation.
2397
+ viewerPrefillDoneRef.current = false;
2334
2398
  }
2335
2399
  }, [conversationId]);
2336
2400
  // Entry tip: the onboarding moment. Show ONE high-value hint as soon as a fresh
@@ -2364,7 +2428,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2364
2428
  return;
2365
2429
  viewerTipShownRef.current = true;
2366
2430
  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.',
2431
+ 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
2432
  });
2369
2433
  setTipCards((prev) => [
2370
2434
  ...prev,
@@ -2409,7 +2473,31 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2409
2473
  }, [messages, discovery]);
2410
2474
  // ── Sounds picker (shown when /sounds is executed) ────────────────────────
2411
2475
  const [soundsPicker, setSoundsPicker] = useState(null);
2412
- const [soundsConfig, setSoundsConfig] = useState({ ...DEFAULT_SOUNDS_CONFIG });
2476
+ // Notification sounds are a PER-DEVICE, per-user preference localStorage,
2477
+ // never project settings. They used to persist via PATCH /projects settings
2478
+ // (shared with every member, and unreachable for read-only viewers, whose
2479
+ // PATCH 403s); the old project-level value is still read once as a migration
2480
+ // fallback when this device has no local preference yet.
2481
+ const [soundsConfig, setSoundsConfig] = useState(() => {
2482
+ try {
2483
+ const raw = localStorage.getItem(SOUNDS_STORAGE_KEY);
2484
+ if (raw)
2485
+ return { ...DEFAULT_SOUNDS_CONFIG, ...JSON.parse(raw) };
2486
+ }
2487
+ catch (_error) {
2488
+ // localStorage unavailable / bad JSON — fall through to the defaults.
2489
+ }
2490
+ return { ...DEFAULT_SOUNDS_CONFIG };
2491
+ });
2492
+ const soundsLocallySetRef = useRef(false);
2493
+ useEffect(() => {
2494
+ try {
2495
+ soundsLocallySetRef.current = localStorage.getItem(SOUNDS_STORAGE_KEY) != null;
2496
+ }
2497
+ catch (_error) {
2498
+ // localStorage unavailable — treat as unset; the project fallback may apply.
2499
+ }
2500
+ }, []);
2413
2501
  // ── Panel overlay (/skills, /scripts, /settings — closeable popups) ──────────
2414
2502
  // These three commands open a closeable overlay above the composer (mirrors how
2415
2503
  // /model and /sounds own this popup region) instead of dropping an inline card
@@ -2447,16 +2535,16 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2447
2535
  updated = { ...soundsConfig, [eventType]: next };
2448
2536
  }
2449
2537
  setSoundsConfig(updated);
2538
+ // Per-device preference — localStorage, never a project PATCH (see the
2539
+ // state's comment; this also makes /sounds fully usable for viewers).
2450
2540
  try {
2451
- await http.patch(`/projects/${projectId}`, { settings: { sounds: updated } });
2541
+ localStorage.setItem(SOUNDS_STORAGE_KEY, JSON.stringify(updated));
2542
+ soundsLocallySetRef.current = true;
2452
2543
  }
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
- }));
2544
+ catch (_error) {
2545
+ // localStorage unavailable the choice still applies for this session.
2458
2546
  }
2459
- }, [soundsConfig, http, projectId, addSystemCard]);
2547
+ }, [soundsConfig]);
2460
2548
  // ── Current project settings (model + maxloops + sounds) ──────────────────
2461
2549
  // Project-scoped so any custom (bring-your-own AI) models the project has
2462
2550
  // configured are included alongside the platform catalog.
@@ -2572,7 +2660,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2572
2660
  if (typeof s?.autoApproveCommands === 'boolean') {
2573
2661
  setAutoApproveCommandsEnabled(s.autoApproveCommands);
2574
2662
  }
2575
- if (s?.sounds && typeof s.sounds === 'object') {
2663
+ // Legacy migration only: sounds are per-device (localStorage) now — apply
2664
+ // the old project-level value only when this device has no local pref.
2665
+ if (s?.sounds && typeof s.sounds === 'object' && !soundsLocallySetRef.current) {
2576
2666
  setSoundsConfig((prev) => ({ ...prev, ...s.sounds }));
2577
2667
  }
2578
2668
  // Restore the persisted auto-commit cadence in the paused state (it
@@ -2684,9 +2774,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2684
2774
  if (fallback) {
2685
2775
  setCurrentModel(fallback);
2686
2776
  setSavedChatModel(fallback);
2687
- http.patch(`/projects/${projectId}`, { settings: { chatModel: fallback } }).catch(() => {
2688
- /* persistence is best-effort; the in-memory switch is what matters */
2689
- });
2777
+ // Persisting the swap writes project settings editor+ server-side, so a
2778
+ // viewer's tab keeps the in-memory switch (all this session needs) and
2779
+ // skips a PATCH that could only 403.
2780
+ if (canEdit !== false) {
2781
+ http.patch(settingsPatchUrl(), { settings: { chatModel: fallback } }).catch(() => {
2782
+ /* persistence is best-effort; the in-memory switch is what matters */
2783
+ });
2784
+ }
2690
2785
  }
2691
2786
  }, [
2692
2787
  modelsLoading,
@@ -2696,6 +2791,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2696
2791
  addSystemCard,
2697
2792
  http,
2698
2793
  projectId,
2794
+ canEdit,
2699
2795
  ]);
2700
2796
  // ── Queued message editing ──────────────────────────────────────────────────
2701
2797
  const [editingQueuedId, setEditingQueuedId] = useState(null);
@@ -2879,6 +2975,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2879
2975
  pendingMessageKey !== undefined &&
2880
2976
  pendingMessageKey !== lastPendingKeyRef.current) {
2881
2977
  lastPendingKeyRef.current = pendingMessageKey;
2978
+ // A read-only VIEWER never dispatches platform-composed sends (build
2979
+ // kickoffs, auto-fixes, preview-failure reports): the server 403s them
2980
+ // and the denial used to surface as a red error in the viewer's chat.
2981
+ // Consume the key (above) so the same message isn't retried.
2982
+ if (canEdit === false)
2983
+ return;
2882
2984
  // A pending message is ALWAYS system-composed (an auto-fix prompt, a preview-failure
2883
2985
  // report, a "Fix with AI" request) — never text the user typed. So it must NEVER
2884
2986
  // render as a normal user bubble: either it's suppressed entirely (hidden build
@@ -2912,6 +3014,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2912
3014
  pendingMessageUserInitiated,
2913
3015
  sendMessage,
2914
3016
  isLoading,
3017
+ canEdit,
2915
3018
  ]);
2916
3019
  // When streaming ends, send any deferred message. (A user Stop also ends
2917
3020
  // streaming, but handleAbort drops the deferred message first — and useChat's
@@ -2925,6 +3028,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2925
3028
  deferredPendingRef.current = null;
2926
3029
  deferredPendingSuppressRef.current = false;
2927
3030
  deferredPendingUserInitiatedRef.current = false;
3031
+ // A viewer never dispatches platform-composed sends (same as the
3032
+ // immediate path above) — the refs are already drained, so it just drops.
3033
+ if (canEdit === false)
3034
+ return;
2928
3035
  // Same rule as the immediate path: a deferred pending message is system-composed —
2929
3036
  // always `automatic` (suppressed or visible), never a plain user bubble.
2930
3037
  sendMessage(msg, undefined, {
@@ -2933,7 +3040,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2933
3040
  ...(userInitiated ? { userInitiated: true } : {}),
2934
3041
  });
2935
3042
  }
2936
- }, [isLoading, sendMessage]);
3043
+ }, [isLoading, sendMessage, canEdit]);
2937
3044
  // ── Auto-delete queued autofix messages when user edits a relevant file ────
2938
3045
  const lastUserEditKeyRef = useRef(userEditedFileKey);
2939
3046
  useEffect(() => {
@@ -3428,11 +3535,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3428
3535
  return;
3429
3536
  }
3430
3537
  setFilePicker(null);
3431
- // Show model picker when typing "/model <filter>" — scoped to a mode when
3432
- // the input carries a --plan / --execute flag.
3538
+ // Show model picker when typing "/model <filter>" — scoped to the mode a
3539
+ // --plan / --execute flag names, else to the LIVE conversation mode
3540
+ // (discovery = plan), so a plain pick changes the mode the user is in.
3433
3541
  const modelMatch = val.match(/^\/model\s+/i);
3434
3542
  if (modelMatch) {
3435
- setModelPicker({ selectedIdx: -1, mode: parseModelModeCommand(val)?.mode });
3543
+ setModelPicker({ selectedIdx: -1, mode: parseModelModeCommand(val)?.mode ?? liveModelMode });
3436
3544
  setCommandMenu(null);
3437
3545
  return;
3438
3546
  }
@@ -3443,7 +3551,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3443
3551
  else {
3444
3552
  setCommandMenu(null);
3445
3553
  }
3446
- }, [openFilePicker, autoResize, persistDraft]);
3554
+ }, [openFilePicker, autoResize, persistDraft, liveModelMode]);
3447
3555
  // ── Execute command ────────────────────────────────────────────────────────
3448
3556
  /** Sets textarea value and moves cursor to the end. */
3449
3557
  const setInputAndCursorEnd = useCallback((val) => {
@@ -3457,6 +3565,17 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3457
3565
  }
3458
3566
  }, 0);
3459
3567
  }, [setInputValue, autoResize]);
3568
+ // Viewer composer prefill: the box IS the team-chat input, so it starts with
3569
+ // "/teamsay " already typed (and handleSubmit re-fills it after each sent team
3570
+ // message). Once per mount, and never over text the user already has.
3571
+ const viewerPrefillDoneRef = useRef(false);
3572
+ useEffect(() => {
3573
+ if (canEdit !== false || viewerPrefillDoneRef.current)
3574
+ return;
3575
+ viewerPrefillDoneRef.current = true;
3576
+ if (!inputRef.current.trim())
3577
+ setInputAndCursorEnd('/teamsay ');
3578
+ }, [canEdit, setInputAndCursorEnd, conversationId]);
3460
3579
  /**
3461
3580
  * Select and apply a model by ID. When `mode` is given the choice persists to
3462
3581
  * that mode's per-mode field (`planModel` / `executeModel` /
@@ -3469,7 +3588,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3469
3588
  const name = displayName ?? modelId;
3470
3589
  try {
3471
3590
  if (mode) {
3472
- await http.patch(`/projects/${projectId}`, {
3591
+ await http.patch(settingsPatchUrl(), {
3473
3592
  settings: { [modeSettingKey(mode)]: modelId },
3474
3593
  });
3475
3594
  if (mode === 'plan')
@@ -3489,7 +3608,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3489
3608
  : t('ide.chat.compactModelSet', { name }, { defaultValue: 'Compaction model set to {{name}}' }));
3490
3609
  }
3491
3610
  else {
3492
- await http.patch(`/projects/${projectId}`, { settings: { chatModel: modelId } });
3611
+ await http.patch(settingsPatchUrl(), { settings: { chatModel: modelId } });
3493
3612
  setCurrentModel(modelId);
3494
3613
  setSavedChatModel(modelId);
3495
3614
  addSystemCard(t('ide.chat.modelSet', { name }, {
@@ -3513,7 +3632,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3513
3632
  const nextRegions = { ...modelRegions, [modelId]: region };
3514
3633
  setModelRegions(nextRegions);
3515
3634
  try {
3516
- await http.patch(`/projects/${projectId}`, {
3635
+ await http.patch(settingsPatchUrl(), {
3517
3636
  settings: { modelRegions: nextRegions },
3518
3637
  });
3519
3638
  }
@@ -3568,7 +3687,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3568
3687
  }
3569
3688
  else if (id === 'model') {
3570
3689
  setInputAndCursorEnd('/model ');
3571
- setModelPicker({ selectedIdx: -1 });
3690
+ setModelPicker({ selectedIdx: -1, mode: liveModelMode });
3572
3691
  }
3573
3692
  else if (id === 'mic' || id === 'dictate') {
3574
3693
  setInputValue('');
@@ -3660,7 +3779,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3660
3779
  defaultValue: "Today's AI allowance is used up — refreshes {{when}}.",
3661
3780
  })
3662
3781
  : t('ide.chat.usageAllowanceTodayLine', { percent: d.allowancePercent }, {
3663
- defaultValue: "You've used ~{{percent}}% of today's AI allowance.",
3782
+ // Neutral phrasing on purpose: the allowance belongs to
3783
+ // the PROJECT (its owner's plan window) — "you've used"
3784
+ // misattributed it to whichever teammate ran /cost.
3785
+ defaultValue: "~{{percent}}% of today's AI allowance used.",
3664
3786
  }))
3665
3787
  : '';
3666
3788
  // `inputTokens` counts only the UNCACHED prompt: every bond normalizes
@@ -3802,7 +3924,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3802
3924
  setInputValue('');
3803
3925
  const newValue = !autoFixEnabled;
3804
3926
  try {
3805
- await http.patch(`/projects/${projectId}`, { settings: { autoFix: newValue } });
3927
+ await http.patch(settingsPatchUrl(), { settings: { autoFix: newValue } });
3806
3928
  setAutoFixEnabled(newValue);
3807
3929
  addSystemCard(newValue
3808
3930
  ? t('ide.chat.autoFixEnabled', undefined, {
@@ -3823,7 +3945,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3823
3945
  setInputValue('');
3824
3946
  const newValue = !autoApproveCommandsEnabled;
3825
3947
  try {
3826
- await http.patch(`/projects/${projectId}`, {
3948
+ await http.patch(settingsPatchUrl(), {
3827
3949
  settings: { autoApproveCommands: newValue },
3828
3950
  });
3829
3951
  setAutoApproveCommandsEnabled(newValue);
@@ -3886,7 +4008,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3886
4008
  }
3887
4009
  else if (id === 'share') {
3888
4010
  setInputValue('');
3889
- setShareModal({ role: DEFAULT_SHARE_ROLE });
4011
+ if (shareAllowed)
4012
+ setShareModal({ role: DEFAULT_SHARE_ROLE });
3890
4013
  }
3891
4014
  }, [
3892
4015
  clearHistory,
@@ -3905,6 +4028,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3905
4028
  extraCommandIds,
3906
4029
  canEdit,
3907
4030
  allCommands,
4031
+ liveModelMode,
3908
4032
  t,
3909
4033
  ]);
3910
4034
  // When the auto-commit countdown reaches zero, fire the existing /commit path
@@ -4023,10 +4147,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4023
4147
  return;
4024
4148
  }
4025
4149
  // Handle /share [role] locally — opens the share-link modal at the requested
4026
- // role (default viewer). An unrecognized role shows usage instead.
4150
+ // role (default viewer). An unrecognized role shows usage instead. Below the
4151
+ // host's share capability (e.g. an editor where minting is admin+), say so
4152
+ // plainly instead of opening a modal whose every request 403s.
4027
4153
  const shareMatch = parseShareCommand(trimmed);
4028
4154
  if (shareMatch) {
4029
4155
  setInputValue('');
4156
+ if (!shareAllowed) {
4157
+ addSystemCard(t('ide.chat.share.notAllowed', undefined, {
4158
+ defaultValue: 'Managing share links needs an admin role on this project.',
4159
+ }));
4160
+ return;
4161
+ }
4030
4162
  if (shareMatch.kind === 'invalid') {
4031
4163
  addSystemCard(t('ide.chat.share.usage', { roles: SHARE_ROLES.join(', ') }, {
4032
4164
  defaultValue: 'Usage: /share [role] — create a public link. Roles: {{roles}} (default viewer).',
@@ -4108,20 +4240,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4108
4240
  { action: buildUpgradeCta?.({}) ?? undefined });
4109
4241
  }
4110
4242
  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
- }
4243
+ // Unscoped /model targets the CURRENT conversation mode (discovery =
4244
+ // plan), same as /effort never the legacy both-modes chatModel.
4245
+ await selectModel(name, resolved?.label ?? name, liveModelMode);
4125
4246
  }
4126
4247
  }
4127
4248
  setInputValue('');
@@ -4196,7 +4317,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4196
4317
  }
4197
4318
  try {
4198
4319
  const nextByMode = { ...effortByMode, [targetMode]: level };
4199
- await http.patch(`/projects/${projectId}`, { settings: { effortByMode: nextByMode } });
4320
+ await http.patch(settingsPatchUrl(), { settings: { effortByMode: nextByMode } });
4200
4321
  setEffortByMode(nextByMode);
4201
4322
  addSystemCard(t('ide.chat.effort.setMode', {
4202
4323
  mode: targetMode,
@@ -4218,7 +4339,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4218
4339
  if (maxLoopsMatch) {
4219
4340
  const n = Math.max(1, Number(maxLoopsMatch[1]));
4220
4341
  try {
4221
- await http.patch(`/projects/${projectId}`, { settings: { maxToolLoops: n } });
4342
+ await http.patch(settingsPatchUrl(), { settings: { maxToolLoops: n } });
4222
4343
  setCurrentMaxLoops(n);
4223
4344
  addSystemCard(t('ide.chat.maxLoopsSet', { n }, {
4224
4345
  defaultValue: `Max tool iterations set to ${n}`,
@@ -4285,7 +4406,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4285
4406
  // event (persisted + broadcast to every member), which IS the visible message — so the
4286
4407
  // transcript never shows the literal "/command" text, and never shows it twice.
4287
4408
  if (matchesSideChannelCommand(allCommands, trimmed)) {
4288
- setInputValue('');
4409
+ // A viewer's composer is the team-chat box — re-fill the /teamsay prefix
4410
+ // after each sent team message so the next one is one keystroke away.
4411
+ if (canEdit === false)
4412
+ setInputAndCursorEnd('/teamsay ');
4413
+ else
4414
+ setInputValue('');
4289
4415
  sendMessage(trimmed, undefined, { suppressUserMessage: true });
4290
4416
  return;
4291
4417
  }
@@ -4335,7 +4461,17 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4335
4461
  setAttachedFiles([]);
4336
4462
  setAttachmentError(null);
4337
4463
  sendMessage(message, chatAttachments.length > 0 ? chatAttachments : undefined);
4338
- }, [attachedFiles, http, projectId, sendMessage, setInputValue, runSavedScript, allCommands]);
4464
+ }, [
4465
+ attachedFiles,
4466
+ http,
4467
+ projectId,
4468
+ sendMessage,
4469
+ setInputValue,
4470
+ runSavedScript,
4471
+ allCommands,
4472
+ selectModel,
4473
+ liveModelMode,
4474
+ ]);
4339
4475
  // External auto-submit. When the signal changes, submit the current input —
4340
4476
  // used by the prompt → chat morph to send the prefilled prompt once the chat
4341
4477
  // has docked into place (handleSubmit clears the input as it sends).
@@ -4371,12 +4507,22 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4371
4507
  useEffect(() => {
4372
4508
  if (openShareSignal !== undefined && openShareSignal !== lastOpenShareRef.current) {
4373
4509
  lastOpenShareRef.current = openShareSignal;
4374
- setShareModal({ role: DEFAULT_SHARE_ROLE });
4510
+ if (shareAllowed)
4511
+ setShareModal({ role: DEFAULT_SHARE_ROLE });
4375
4512
  }
4376
- }, [openShareSignal]);
4513
+ }, [openShareSignal, shareAllowed]);
4377
4514
  // ── Keyboard ───────────────────────────────────────────────────────────────
4378
4515
  const filteredCmds = commandMenu
4379
- ? allCommands.filter((c) => c.label.startsWith(inputRef.current))
4516
+ ? allCommands.filter((c) =>
4517
+ // A showAll menu (slash-button toggle over existing text) lists every
4518
+ // command; a typed menu filters by the composer's text.
4519
+ (commandMenu.showAll === true || c.label.startsWith(inputRef.current)) &&
4520
+ // Viewers see only commands they can actually run (viewer-safe reads +
4521
+ // the /teamsay side channel) — no dead entries in the menu.
4522
+ (canEdit !== false || c.viewerSafe === true || c.sideChannel === true) &&
4523
+ // /share is gated separately: hosts commonly mint at admin+, so an
4524
+ // editor without the capability gets no dead menu entry either.
4525
+ (c.id !== 'share' || shareAllowed))
4380
4526
  : [];
4381
4527
  const filteredModels = useMemo(() => {
4382
4528
  if (!modelPicker)
@@ -4663,9 +4809,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4663
4809
  e.preventDefault();
4664
4810
  const idx = modelPicker.selectedIdx >= 0 ? modelPicker.selectedIdx : 0;
4665
4811
  if (onManageCustomModels && idx === visibleModels.length) {
4812
+ const manageMode = modelPicker.mode === 'plan' || modelPicker.mode === 'execute'
4813
+ ? modelPicker.mode
4814
+ : liveModelMode;
4666
4815
  setModelPicker(null);
4667
4816
  setInputValue('');
4668
- onManageCustomModels();
4817
+ onManageCustomModels({ mode: manageMode });
4669
4818
  return;
4670
4819
  }
4671
4820
  const model = visibleModels[idx];
@@ -4706,20 +4855,28 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4706
4855
  if (commandMenu && filteredCmds.length > 0) {
4707
4856
  if (e.key === 'ArrowDown') {
4708
4857
  e.preventDefault();
4709
- setCommandMenu((m) => m ? { selectedIdx: wrapIdx(m.selectedIdx, 1, filteredCmds.length) } : null);
4858
+ setCommandMenu((m) => m ? { ...m, selectedIdx: wrapIdx(m.selectedIdx, 1, filteredCmds.length) } : null);
4710
4859
  return;
4711
4860
  }
4712
4861
  if (e.key === 'ArrowUp') {
4713
4862
  e.preventDefault();
4714
- setCommandMenu((m) => m ? { selectedIdx: wrapIdx(m.selectedIdx, -1, filteredCmds.length) } : null);
4863
+ setCommandMenu((m) => m ? { ...m, selectedIdx: wrapIdx(m.selectedIdx, -1, filteredCmds.length) } : null);
4715
4864
  return;
4716
4865
  }
4717
4866
  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;
4867
+ // A showAll menu (opened by the slash BUTTON over existing text) only
4868
+ // executes an explicitly highlighted command a bare Enter closes it
4869
+ // and falls through to submit the composer text as typed.
4870
+ if (commandMenu.showAll && commandMenu.selectedIdx < 0 && e.key === 'Enter') {
4871
+ setCommandMenu(null);
4872
+ }
4873
+ else {
4874
+ e.preventDefault();
4875
+ const cmd = filteredCmds[commandMenu.selectedIdx >= 0 ? commandMenu.selectedIdx : 0];
4876
+ if (cmd)
4877
+ void executeCommand(cmd.id);
4878
+ return;
4879
+ }
4723
4880
  }
4724
4881
  }
4725
4882
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -4755,6 +4912,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4755
4912
  // unrenderable card (a custom event with no registered factory, or a non-execute mode) so
4756
4913
  // the timeline simply omits it.
4757
4914
  const cardEventToSystemCard = useCallback((cardEvent, id, timestamp) => {
4915
+ // Suffix a card's copy with the member who made the change, when the
4916
+ // server attributed one — so a teammate watching knows WHO flipped the
4917
+ // model/effort/mode, live and on reload.
4918
+ const withBy = (text) => {
4919
+ const by = cardEvent.by;
4920
+ return by
4921
+ ? t('ide.chat.cardBy', { text, name: by }, { defaultValue: '{{text}} — {{name}}' })
4922
+ : text;
4923
+ };
4758
4924
  switch (cardEvent.kind) {
4759
4925
  case 'model': {
4760
4926
  const label = cardEvent.label || cardEvent.model;
@@ -4766,22 +4932,77 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4766
4932
  const regionCode = def && def.provider !== 'custom' ? effectiveModelRegion(def).toUpperCase() : null;
4767
4933
  return {
4768
4934
  id,
4769
- text: regionCode
4935
+ text: withBy(regionCode
4770
4936
  ? t('ide.chat.modelInUseRegion', { model: label, region: regionCode }, { defaultValue: 'Now using {{model}} ({{region}})' })
4771
- : t('ide.chat.modelInUse', { model: label }, { defaultValue: 'Now using {{model}}' }),
4937
+ : t('ide.chat.modelInUse', { model: label }, { defaultValue: 'Now using {{model}}' })),
4772
4938
  timestamp,
4773
4939
  };
4774
4940
  }
4775
4941
  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.
4942
+ // The plan→build handoff ("🔨 Building your app") and the plan-mode
4943
+ // announcement a new conversation is seeded with ("📝 Plan mode")
4944
+ // the server records mode cards only for those two.
4945
+ if (cardEvent.mode === 'plan') {
4946
+ return {
4947
+ id,
4948
+ text: withBy(t('ide.chat.phasePlanning', undefined, { defaultValue: '📝 Plan mode' })),
4949
+ timestamp,
4950
+ };
4951
+ }
4778
4952
  if (cardEvent.mode !== 'execute')
4779
4953
  return null;
4780
4954
  return {
4781
4955
  id,
4782
- text: t('ide.chat.phaseBuilding', undefined, { defaultValue: '🔨 Building your app' }),
4956
+ text: withBy(t('ide.chat.phaseBuilding', undefined, { defaultValue: '🔨 Building your app' })),
4783
4957
  timestamp,
4784
4958
  };
4959
+ case 'setting': {
4960
+ // A Synthase-altering setting changed (effort / fast mode / max loops /
4961
+ // region / auto-fix / auto-approve) — announced to every member, live
4962
+ // and on reload. Copy reuses the same localized strings as the local
4963
+ // confirmation cards, so the shared card reads identically.
4964
+ let text = null;
4965
+ if (cardEvent.setting === 'effort') {
4966
+ const level = String(cardEvent.value ?? 'default');
4967
+ const cardMode = cardEvent.mode ?? '';
4968
+ text = cardEvent.label
4969
+ ? t('ide.chat.effort.setMode', { mode: cardMode, level, model: cardEvent.label }, { defaultValue: 'Reasoning effort for {{mode}} set to {{level}} ({{model}}).' })
4970
+ : t('ide.chat.setting.effort', { mode: cardMode, level }, { defaultValue: 'Reasoning effort for {{mode}} set to {{level}}.' });
4971
+ }
4972
+ else if (cardEvent.setting === 'fastMode') {
4973
+ text = cardEvent.value
4974
+ ? t('ide.chat.fastModeOn', undefined, {
4975
+ defaultValue: 'Fast mode on — faster responses at a higher rate',
4976
+ })
4977
+ : t('ide.chat.fastModeOff', undefined, { defaultValue: 'Fast mode off' });
4978
+ }
4979
+ else if (cardEvent.setting === 'maxToolLoops') {
4980
+ text = t('ide.chat.maxLoopsSet', { n: Number(cardEvent.value ?? 0) }, { defaultValue: 'Max tool iterations set to {{n}}' });
4981
+ }
4982
+ else if (cardEvent.setting === 'region') {
4983
+ text = t('ide.chat.modelInUseRegion', {
4984
+ model: cardEvent.label ?? '',
4985
+ region: String(cardEvent.value ?? '').toUpperCase(),
4986
+ }, { defaultValue: 'Now using {{model}} ({{region}})' });
4987
+ }
4988
+ else if (cardEvent.setting === 'autoFix') {
4989
+ text = cardEvent.value
4990
+ ? t('ide.chat.autoFixEnabled', undefined, { defaultValue: 'Auto-fix enabled.' })
4991
+ : t('ide.chat.autoFixDisabled', undefined, { defaultValue: 'Auto-fix disabled.' });
4992
+ }
4993
+ else if (cardEvent.setting === 'autoApprove') {
4994
+ text = cardEvent.value
4995
+ ? t('ide.chat.autoApproveEnabled', undefined, {
4996
+ defaultValue: 'Auto-approve on — destructive commands run without asking. The exfiltration guard still asks. Turn off with /autoapprove.',
4997
+ })
4998
+ : t('ide.chat.autoApproveDisabled', undefined, {
4999
+ defaultValue: 'Auto-approve off — destructive commands ask before running.',
5000
+ });
5001
+ }
5002
+ if (!text)
5003
+ return null;
5004
+ return { id, text: withBy(text), timestamp };
5005
+ }
4785
5006
  case 'skills':
4786
5007
  return {
4787
5008
  id,
@@ -4836,6 +5057,20 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4836
5057
  // reading the guest limit they just escaped. Same reasoning the host's
4837
5058
  // `upgrade_prompt` card factory already applies to the recorded guest card.
4838
5059
  const isStaleAnonymousLimit = errorMeta?.requiresSignup === true && isAnonymous === false;
5060
+ // A read-only viewer's access-denied chat error must never sit as the
5061
+ // persistent red banner — every legitimate viewer action is already gated
5062
+ // client-side, so this only fires when something slipped through to the
5063
+ // server (or an older tab raced a role change). Flash a calm, gold
5064
+ // view-only notice for a few seconds instead, then clear.
5065
+ const isViewerAccessDenied = canEdit === false && !!error && /access denied/i.test(error);
5066
+ const [viewerDeniedFlash, setViewerDeniedFlash] = useState(false);
5067
+ useEffect(() => {
5068
+ if (!isViewerAccessDenied)
5069
+ return;
5070
+ setViewerDeniedFlash(true);
5071
+ const timer = setTimeout(() => setViewerDeniedFlash(false), 6000);
5072
+ return () => clearTimeout(timer);
5073
+ }, [isViewerAccessDenied, error]);
4839
5074
  const timeline = useMemo(() => {
4840
5075
  const items = [];
4841
5076
  // A card-message (role:'system' carrying a cardEvent) renders as a SYSTEM CARD, not a chat
@@ -5003,7 +5238,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5003
5238
  defaultValue: 'Show earlier messages',
5004
5239
  }) }) })), (timeline.length > maxVisibleItems ? timeline.slice(-maxVisibleItems) : timeline).map((item) => (_jsx(ChatItemBoundary, { onError: onRenderError, render: () => {
5005
5240
  if (item.kind === 'commit')
5006
- return (_jsx(CommitCardItem, { card: item.card, onRevert: handleRevertCommit }, item.card.id));
5241
+ return (_jsx(CommitCardItem, { card: item.card, onRevert: canEdit === false ? undefined : handleRevertCommit }, item.card.id));
5007
5242
  if (item.kind === 'activity')
5008
5243
  return (_jsx(ActivityCard, { activity: item.card.activity, onActivityClick: onActivityClick }, item.card.id));
5009
5244
  if (item.kind === 'tip')
@@ -5108,9 +5343,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5108
5343
  timestamp: msg.timestamp,
5109
5344
  status: 'done',
5110
5345
  hash,
5111
- }, onRevert: handleRevertCommit }, msg.id));
5346
+ }, onRevert: canEdit === false ? undefined : handleRevertCommit }, msg.id));
5112
5347
  }
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,
5348
+ 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
5349
  // Avatar clicks open the signed-in user's OWN profile, so only
5115
5350
  // their own messages get the handler: author-less ones (the
5116
5351
  // local echo, legacy solo rows) or an author matching
@@ -5118,11 +5353,27 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5118
5353
  // clicking Test's face must not open Luke's profile editor.
5119
5354
  onAvatarClick: !msg.author?.id || (currentUserId != null && msg.author.id === currentUserId)
5120
5355
  ? onUserAvatarClick
5121
- : undefined, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName }, msg.id));
5356
+ : undefined, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName, canEdit: canEdit }, msg.id));
5122
5357
  } }, item.kind === 'message' ? item.msg.id : item.card.id))), error &&
5123
5358
  !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 }))), (() => {
5125
- const showActivity = isLoading || awaitingSandboxBoot;
5359
+ (errorMeta?.limitType ? (_jsx(ResourceLimitBanner, { message: error, action: buildUpgradeCta?.({ requiresSignup: errorMeta.requiresSignup }) })) : isViewerAccessDenied ? (
5360
+ // A viewer's denial is expected state, not an alarm: a brief, calm,
5361
+ // gold view-only notice (matching the viewer tip / team-note
5362
+ // treatment) that clears itself — never the persistent red banner.
5363
+ viewerDeniedFlash ? (_jsxs("div", { "data-mol-id": "chat-viewer-denied-notice", className: cm.cn(cm.textSize('xs'), cm.textMuted), style: {
5364
+ display: 'flex',
5365
+ alignItems: 'flex-start',
5366
+ gap: 8,
5367
+ marginBottom: 8,
5368
+ ...chatCardStyle(NOTICE_TONE.gold.accent),
5369
+ lineHeight: 1.5,
5370
+ }, 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, {
5371
+ 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.",
5372
+ }) })] })) : 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 }))), (() => {
5373
+ // A remote turn (a teammate's send, another tab, a server-side
5374
+ // continuation) shows the same live activity indicator as an own send —
5375
+ // watchers see Synthase working, not a frozen transcript.
5376
+ const showActivity = isLoading || awaitingSandboxBoot || isRemoteStreaming;
5126
5377
  const streamingMsg = isLoading
5127
5378
  ? [...visibleMessages].reverse().find((m) => m.isStreaming)
5128
5379
  : undefined;
@@ -5920,9 +6171,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5920
6171
  cursor: 'pointer',
5921
6172
  textAlign: 'left',
5922
6173
  }, 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: () => {
6174
+ const manageMode = modelPicker.mode === 'plan' || modelPicker.mode === 'execute'
6175
+ ? modelPicker.mode
6176
+ : liveModelMode;
5923
6177
  setModelPicker(null);
5924
6178
  setInputValue('');
5925
- onManageCustomModels();
6179
+ onManageCustomModels({ mode: manageMode });
5926
6180
  }, onMouseEnter: (e) => {
5927
6181
  ;
5928
6182
  e.currentTarget.style.background =
@@ -6318,7 +6572,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6318
6572
  }, onMouseLeave: (e) => {
6319
6573
  e.currentTarget.style.opacity = '0.7';
6320
6574
  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 &&
6575
+ }, 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 &&
6576
+ pendingFiles != null &&
6322
6577
  pendingFiles.length > 0 &&
6323
6578
  !commandMenu &&
6324
6579
  !modelPicker &&
@@ -6464,7 +6719,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6464
6719
  opacity: 0.5,
6465
6720
  transition: 'opacity 100ms, background 100ms',
6466
6721
  ...(!(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: {
6722
+ }, 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
6723
  // Round the TOP corners only (8px) so the composer reads as a self-contained
6469
6724
  // input with its own border on all sides, flush at the bottom-left/right with
6470
6725
  // the panel's bottom edge. Discovery rounds ALL FOUR corners (the centered
@@ -6473,11 +6728,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6473
6728
  borderRadius: discovery ? 8 : '8px 8px 0 0',
6474
6729
  padding: '8px 10px',
6475
6730
  cursor: 'text',
6731
+ // View-only mode: the composer IS the team-chat box, so it wears the
6732
+ // team-message gold border (and the box is pre-filled with /teamsay).
6733
+ ...(canEdit === false ? { border: `1px solid ${NOTICE_TONE.gold.accent}` } : {}),
6476
6734
  }, onClick: (e) => {
6477
6735
  if (!e.target.closest('button')) {
6478
6736
  textareaRef.current?.focus();
6479
6737
  }
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: {
6738
+ }, children: [_jsx("textarea", { ref: textareaRef, "data-mol-chat-input": "", defaultValue: inputRef.current, autoComplete: "off", onChange: handleInputChange, onPaste: handlePaste, placeholder: canEdit === false
6739
+ ? t('ide.chat.placeholderViewer', undefined, {
6740
+ defaultValue: 'Message your team',
6741
+ })
6742
+ : t('ide.chat.placeholder'), rows: 1, className: cm.textSize('sm'), style: {
6481
6743
  width: '100%',
6482
6744
  display: 'block',
6483
6745
  padding: 0,
@@ -6503,7 +6765,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6503
6765
  alignItems: 'center',
6504
6766
  marginTop: '6px',
6505
6767
  gap: '4px',
6506
- }, children: [_jsx("button", { type: "button", disabled: !canEdit, onClick: () => {
6768
+ }, children: [canEdit !== false && (_jsx("button", { type: "button", onClick: () => {
6507
6769
  const newMode = mode === 'plan' ? 'execute' : 'plan';
6508
6770
  setMode(newMode);
6509
6771
  http
@@ -6548,7 +6810,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6548
6810
  }, onMouseLeave: (e) => {
6549
6811
  if (mode !== 'plan')
6550
6812
  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: () => {
6813
+ }, 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
6814
  const next = !fastMode;
6553
6815
  setFastMode(next);
6554
6816
  http
@@ -6613,7 +6875,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6613
6875
  }, onMouseLeave: (e) => {
6614
6876
  if (!isListening)
6615
6877
  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: {
6878
+ }, 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
6879
  display: 'inline-flex',
6618
6880
  alignItems: 'center',
6619
6881
  justifyContent: 'center',
@@ -6631,7 +6893,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6631
6893
  e.currentTarget.style.opacity = '0.85';
6632
6894
  }, onMouseLeave: (e) => {
6633
6895
  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" }) }) }), [
6896
+ }, 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
6897
  {
6636
6898
  sym: '@',
6637
6899
  nudgeY: 0,
@@ -6660,34 +6922,38 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6660
6922
  defaultValue: 'Slash commands',
6661
6923
  }),
6662
6924
  onClick: () => {
6663
- const cur = inputRef.current;
6664
- if (cur === '/') {
6665
- // Toggle off remove the slash this button added
6666
- setInputValue('');
6667
- autoResize();
6925
+ // TOGGLE the menu regardless of the composer's current text —
6926
+ // never clobbering it. With text present the menu opens in
6927
+ // showAll mode (every command listed; picking one replaces the
6928
+ // input via executeCommand's prefill). An empty box still gets
6929
+ // the type-ahead '/' so filtering-by-typing works as before.
6930
+ if (commandMenu) {
6668
6931
  setCommandMenu(null);
6932
+ if (inputRef.current === '/') {
6933
+ // Remove the slash this button added on open.
6934
+ setInputValue('');
6935
+ autoResize();
6936
+ }
6669
6937
  setTimeout(() => {
6670
6938
  textareaRef.current?.focus();
6671
6939
  }, 0);
6940
+ return;
6672
6941
  }
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);
6942
+ // Popups are one-at-a-time — close siblings first.
6943
+ setMicPicker(null);
6944
+ setModelPicker(null);
6945
+ setSoundsPicker(null);
6946
+ const cur = inputRef.current;
6947
+ if (!cur)
6680
6948
  setInputAndCursorEnd('/');
6681
- setCommandMenu({ selectedIdx: -1 });
6682
- }
6683
- else {
6684
- setTimeout(() => {
6685
- textareaRef.current?.focus();
6686
- }, 0);
6687
- }
6949
+ setCommandMenu({ selectedIdx: -1, showAll: cur !== '' && cur !== '/' });
6688
6950
  },
6689
6951
  },
6690
- ].map(({ sym, nudgeY, size: fontSize, title, onClick }) => (_jsx("button", { type: "button", onClick: onClick, title: title, style: {
6952
+ ]
6953
+ // Viewers keep the / shortcut (viewer-safe commands + /teamsay) but not @ —
6954
+ // file mentions only feed Synthase turns.
6955
+ .filter(({ sym }) => canEdit !== false || sym !== '@')
6956
+ .map(({ sym, nudgeY, size: fontSize, title, onClick }) => (_jsx("button", { type: "button", onClick: onClick, title: title, style: {
6691
6957
  display: 'inline-flex',
6692
6958
  alignItems: 'center',
6693
6959
  justifyContent: 'center',
@@ -6708,7 +6974,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6708
6974
  e.currentTarget.style.opacity = '0.85';
6709
6975
  }, onMouseLeave: (e) => {
6710
6976
  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 &&
6977
+ }, 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 &&
6978
+ contextUsage &&
6712
6979
  (() => {
6713
6980
  // The ring represents usage toward the auto-compaction threshold,
6714
6981
  // not the raw context window. 100% = compaction will trigger.
@@ -6777,11 +7044,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6777
7044
  transition: 'opacity 150ms',
6778
7045
  }, 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
7046
  })(), _jsxs("div", { style: {
6780
- marginLeft: contextUsage ? '4px' : 'auto',
7047
+ // 'auto' pins the stop/send cluster right; the context ring
7048
+ // (hidden for viewers even when contextUsage exists) otherwise
7049
+ // carries the auto margin and this becomes a 4px gap.
7050
+ marginLeft: canEdit !== false && contextUsage ? '4px' : 'auto',
6781
7051
  display: 'flex',
6782
7052
  gap: '4px',
6783
7053
  alignItems: 'center',
6784
- }, children: [(isLoading || isRemoteStreaming) && (_jsx("button", { type: "button", onClick: handleAbort, title: t('ide.chat.stop', undefined, { defaultValue: 'Stop' }), onMouseEnter: (e) => {
7054
+ }, children: [canEdit !== false && (isLoading || isRemoteStreaming) && (_jsx("button", { type: "button", onClick: handleAbort, title: t('ide.chat.stop', undefined, { defaultValue: 'Stop' }), onMouseEnter: (e) => {
6785
7055
  e.currentTarget.style.background = 'rgba(248,81,73,0.3)';
6786
7056
  e.currentTarget.style.borderColor = 'rgba(248,81,73,0.65)';
6787
7057
  }, onMouseLeave: (e) => {
@@ -6835,7 +7105,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6835
7105
  }
6836
7106
  : undefined,
6837
7107
  });
6838
- } })), shareModal && (_jsx(ShareModal, { projectId: projectId, initialRole: shareModal.role, onClose: () => setShareModal(null), onCreated: (result) => {
7108
+ } })), shareModal && (_jsx(ShareModal, { projectId: projectId, initialRole: shareModal.role, canManage: shareAllowed, onClose: () => setShareModal(null), onCreated: (result) => {
6839
7109
  // Surface the created link in the timeline so it persists after the
6840
7110
  // modal closes — the role label and the public URL are both shown.
6841
7111
  addSystemCard(t('ide.chat.share.created', { role: result.role }, { defaultValue: 'Created a {{role}} share link.' }), {
@@ -6854,8 +7124,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6854
7124
  * @param props - Component props (see {@link MessageItemProps}).
6855
7125
  * @returns The rendered chat panel element.
6856
7126
  */
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, }) {
7127
+ 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
7128
  const cm = getClassMap();
7129
+ // Share management may be gated ABOVE canEdit by the host (see
7130
+ // ChatPanelProps.canShare) — gates the built-in header share button here and
7131
+ // is threaded into ChatInner for the /share command + modal.
7132
+ const shareAllowed = canShare ?? canEdit !== false;
6859
7133
  const isNarrow = useNarrowViewport();
6860
7134
  const isCoarse = useCoarsePointer();
6861
7135
  const http = useHttpClient();
@@ -7008,11 +7282,11 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
7008
7282
  textOverflow: 'ellipsis',
7009
7283
  whiteSpace: 'nowrap',
7010
7284
  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, {
7285
+ }, 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
7286
  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, {
7287
+ }), 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
7288
  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: {
7289
+ }), 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
7290
  position: 'absolute',
7017
7291
  top: '100%',
7018
7292
  left: 0,
@@ -7063,7 +7337,7 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
7063
7337
  textOverflow: 'ellipsis',
7064
7338
  whiteSpace: 'nowrap',
7065
7339
  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)] }));
7340
+ }, 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
7341
  }
7068
7342
  ChatPanel.displayName = 'ChatPanel';
7069
7343
  //# sourceMappingURL=ChatPanel.js.map