@molecule/app-ide-react 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +127 -4
  2. package/dist/command-metadata.d.ts +39 -0
  3. package/dist/command-metadata.d.ts.map +1 -1
  4. package/dist/command-metadata.js +46 -3
  5. package/dist/command-metadata.js.map +1 -1
  6. package/dist/components/ChatPanel.d.ts +20 -2
  7. package/dist/components/ChatPanel.d.ts.map +1 -1
  8. package/dist/components/ChatPanel.js +485 -122
  9. package/dist/components/ChatPanel.js.map +1 -1
  10. package/dist/components/EditorPanel.d.ts +1 -1
  11. package/dist/components/EditorPanel.d.ts.map +1 -1
  12. package/dist/components/EditorPanel.js +6 -1
  13. package/dist/components/EditorPanel.js.map +1 -1
  14. package/dist/components/FileExplorer.d.ts +1 -1
  15. package/dist/components/FileExplorer.d.ts.map +1 -1
  16. package/dist/components/FileExplorer.js +2 -2
  17. package/dist/components/FileExplorer.js.map +1 -1
  18. package/dist/components/FileExplorerContextMenu.d.ts +6 -1
  19. package/dist/components/FileExplorerContextMenu.d.ts.map +1 -1
  20. package/dist/components/FileExplorerContextMenu.js +19 -7
  21. package/dist/components/FileExplorerContextMenu.js.map +1 -1
  22. package/dist/components/SearchPanel.d.ts +1 -1
  23. package/dist/components/SearchPanel.d.ts.map +1 -1
  24. package/dist/components/SearchPanel.js +9 -6
  25. package/dist/components/SearchPanel.js.map +1 -1
  26. package/dist/components/ShareModal.d.ts +13 -1
  27. package/dist/components/ShareModal.d.ts.map +1 -1
  28. package/dist/components/ShareModal.js +2 -2
  29. package/dist/components/ShareModal.js.map +1 -1
  30. package/dist/components/TipCard.d.ts +7 -2
  31. package/dist/components/TipCard.d.ts.map +1 -1
  32. package/dist/components/TipCard.js +8 -6
  33. package/dist/components/TipCard.js.map +1 -1
  34. package/dist/components/ToolCallCard.d.ts.map +1 -1
  35. package/dist/components/ToolCallCard.js +6 -3
  36. package/dist/components/ToolCallCard.js.map +1 -1
  37. package/dist/components/UserAvatar.d.ts +10 -1
  38. package/dist/components/UserAvatar.d.ts.map +1 -1
  39. package/dist/components/UserAvatar.js +9 -6
  40. package/dist/components/UserAvatar.js.map +1 -1
  41. package/dist/types.d.ts +60 -2
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js.map +1 -1
  44. package/package.json +3 -3
@@ -16,7 +16,7 @@ import { ActivityCard } from './ActivityCard.js';
16
16
  import { AutoCommitBadge } from './AutoCommitBadge.js';
17
17
  import { AUTO_COMMIT_DISABLED, autoCommitReducer, isAutoCommitArmed, isAutoCommitDue, isAutoCommitEnabled, parseAutoCommitCommand, resolveAutoCommitSeconds, } from './chat-autocommit-utilities.js';
18
18
  import { CHAT_CARD_ICON_SIZE, chatCardBorder, chatCardStyle } from './chat-card-style.js';
19
- import { COMMAND_CATEGORIES, COMMANDS } from './chat-commands.js';
19
+ import { COMMAND_CATEGORIES, COMMANDS, matchesSideChannelCommand, } from './chat-commands.js';
20
20
  import { stripCommitCoauthorTrailer } from './chat-commit-utilities.js';
21
21
  import { cachedPromptTokens, formatTokenTotal } from './chat-cost-utilities.js';
22
22
  import { effortOptionsForModel, nativeEffortName, parseEffortCommand, resolveEffortArg, } from './chat-effort-utilities.js';
@@ -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, } = 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';
@@ -936,6 +951,11 @@ const MessageItem = memo(function MessageItem(props) {
936
951
  // A message sent automatically on the user's behalf (e.g. an auto-fix prompt):
937
952
  // it has role 'user' but must NOT look like the user typed it (C2).
938
953
  const isAutomatic = msg.role === 'user' && !!msg.automatic;
954
+ // A human-only team note (side channel, e.g. /teamsay): renders like a user
955
+ // message — author header, time, plain content — but with the gold team-only
956
+ // accent + badge, and never the user message's blue stripe. `role` is 'system'
957
+ // (the model never sees it), so this is checked before isUser.
958
+ const isTeamNote = !!msg.teamOnly;
939
959
  // A real, user-typed message (the only one styled with the blue border + the
940
960
  // user's own avatar).
941
961
  const isUser = msg.role === 'user' && !isAutomatic;
@@ -949,7 +969,7 @@ const MessageItem = memo(function MessageItem(props) {
949
969
  const wrapperSpacing = {
950
970
  marginBottom: `${discovery ? TIMELINE_ITEM_GAP_DISCOVERY : TIMELINE_ITEM_GAP}px`,
951
971
  };
952
- return (_jsx("div", { style: wrapperSpacing, children: isUser || isAutomatic ? (_jsxs("div", { className:
972
+ return (_jsx("div", { style: wrapperSpacing, children: isUser || isAutomatic || isTeamNote ? (_jsxs("div", { className:
953
973
  // Auto-sent card matches the info cards' `xs` body; a real user message keeps
954
974
  // its slightly larger `sm` (it's a different kind of row, not an info card).
955
975
  isAutomatic ? cm.textSize('xs') : cm.cn(cm.surfaceSecondary, cm.textSize('sm')), style: {
@@ -962,7 +982,9 @@ const MessageItem = memo(function MessageItem(props) {
962
982
  // drawn by the `::before` injected via USER_ACCENT_STYLE + gated on the
963
983
  // data-mol-id below, and the user's full-size avatar. An auto-sent message
964
984
  // is instead a green (success) tinted card — same chrome as the info cards
965
- // — so it's unmistakably agent-sent, not user-typed (C2).
985
+ // — so it's unmistakably agent-sent, not user-typed (C2). A team note keeps
986
+ // the user-message look but swaps the blue stripe (its data-mol-id differs,
987
+ // so USER_ACCENT_STYLE never applies) for the gold team-only border.
966
988
  ...(isAutomatic
967
989
  ? chatCardStyle(AUTO_SENT_ACCENT)
968
990
  : {
@@ -971,14 +993,58 @@ const MessageItem = memo(function MessageItem(props) {
971
993
  paddingTop: '10px',
972
994
  paddingBottom: '10px',
973
995
  paddingRight: '10px',
996
+ ...(isTeamNote ? { border: `1px solid ${NOTICE_TONE.gold.accent}` } : {}),
974
997
  }),
975
- }, "data-mol-id": isAutomatic ? 'chat-automatic-message' : 'chat-user-message', children: [isAutomatic ? (_jsx(Icon, { name: "sync", size: CHAT_CARD_ICON_SIZE, "aria-hidden": "true", style: { flexShrink: 0, marginTop: 1, color: AUTO_SENT_ACCENT } })) : (_jsx(UserAvatar, { userAvatar: msg.author?.avatar ?? userAvatar, size: 36, onClick: onAvatarClick })), _jsxs("div", { style: { flex: 1, minWidth: 0, marginTop: 1 }, children: [isAutomatic ? null : (_jsxs("div", { style: {
998
+ }, "data-mol-id": isTeamNote
999
+ ? 'chat-team-message'
1000
+ : isAutomatic
1001
+ ? 'chat-automatic-message'
1002
+ : 'chat-user-message', children: [isAutomatic ? (_jsx(Icon, { name: "sync", size: CHAT_CARD_ICON_SIZE, "aria-hidden": "true", style: { flexShrink: 0, marginTop: 1, color: AUTO_SENT_ACCENT } })) : (_jsx(UserAvatar
1003
+ // An AUTHORED message shows that author's avatar — or their initial
1004
+ // when they have none — NEVER the viewing user's picture (which made
1005
+ // a teammate's avatar-less message wear the viewer's own face). Only
1006
+ // an author-less message (the local optimistic echo, legacy rows) is
1007
+ // the signed-in user's own and uses their avatar.
1008
+ , {
1009
+ // An AUTHORED message shows that author's avatar — or their initial
1010
+ // when they have none — NEVER the viewing user's picture (which made
1011
+ // a teammate's avatar-less message wear the viewer's own face). Only
1012
+ // an author-less message (the local optimistic echo, legacy rows) is
1013
+ // the signed-in user's own and uses their avatar.
1014
+ userAvatar: msg.author ? msg.author.avatar : userAvatar, name: msg.author?.name ?? undefined, size: 36, onClick: onAvatarClick })), _jsxs("div", { style: { flex: 1, minWidth: 0, marginTop: 1 }, children: [isAutomatic ? null : (_jsxs("div", { style: {
976
1015
  display: 'flex',
977
1016
  alignItems: 'baseline',
978
1017
  gap: 8,
979
1018
  lineHeight: 1.3,
980
1019
  marginBottom: 1,
981
- }, children: [_jsx("span", { style: { fontWeight: 600 }, children: msg.author?.name ?? t('ide.chat.you', undefined, { defaultValue: 'You' }) }), 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/')
1020
+ }, children: [_jsx("span", { style: { fontWeight: 600 }, children: msg.author?.name ?? t('ide.chat.you', undefined, { defaultValue: 'You' }) }), isTeamNote && (_jsx("span", { title: t('ide.chat.teamOnly.badge', { agentName: agentName ?? 'the assistant' }, {
1021
+ defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
1022
+ }), "aria-label": t('ide.chat.teamOnly.badge', { agentName: agentName ?? 'the assistant' }, {
1023
+ defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
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.
1043
+ , {
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/')
982
1048
  ? '\uD83D\uDDBC\uFE0F'
983
1049
  : att.mediaType.startsWith('audio/')
984
1050
  ? '\uD83C\uDFB5'
@@ -1057,11 +1123,11 @@ const MessageItem = memo(function MessageItem(props) {
1057
1123
  const tc = msg.toolCalls?.find((c) => c.id === block.id);
1058
1124
  if (!tc)
1059
1125
  return null;
1060
- 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));
1061
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 &&
1062
1128
  msg.toolCalls.length > 0 &&
1063
1129
  (!msg.blocks || msg.blocks.length === 0) &&
1064
- 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, {
1065
1131
  defaultValue: 'Response stopped',
1066
1132
  }) })), msg.loopLimitReached &&
1067
1133
  !msg.isStreaming &&
@@ -1073,7 +1139,7 @@ const MessageItem = memo(function MessageItem(props) {
1073
1139
  }),
1074
1140
  action: () => {
1075
1141
  setInputAndCursorEnd('/model ');
1076
- setModelPicker({ selectedIdx: -1 });
1142
+ setModelPicker({ selectedIdx: -1, mode: chatMode });
1077
1143
  },
1078
1144
  },
1079
1145
  {
@@ -1152,11 +1218,15 @@ const MessageItem = memo(function MessageItem(props) {
1152
1218
  * @param props - Component props (see {@link MessageItemProps}).
1153
1219
  * @returns The rendered chat inner component.
1154
1220
  */
1155
- function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, buildUpgradeCta, buildHelpUpgradeSection, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onConversationId, onActivityClick, onRenderError, onProfileClick, 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,
1156
1222
  // feedbackUrl: prop kept for back-compat (callers still pass it), but no longer
1157
1223
  // consumed here — its only use was the command-menu footer link removed in P3-21.
1158
1224
  }) {
1159
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;
1160
1230
  const themeMode = useThemeMode();
1161
1231
  const isLight = themeMode === 'light';
1162
1232
  // Phone-width / touch-first branches: popovers cap with dvh, hover-revealed
@@ -1171,10 +1241,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1171
1241
  // visible; 420px bounds it on small tablets.
1172
1242
  const popupMaxHeight = isNarrow ? 'min(50dvh, 420px)' : '70vh';
1173
1243
  const http = useHttpClient();
1174
- // Bind the host's profile-click callback to the clicked user's identity once
1175
- // (the chat is solo, so every user avatar is the signed-in user — the only
1176
- // known identity is `userAvatar`). Stable so MessageItem's memo isn't broken;
1177
- // `undefined` when the host opts out, which keeps every avatar non-interactive.
1244
+ // Bind the host's profile-click callback to the SIGNED-IN user's identity once.
1245
+ // Hosts open the *own*-profile surface from this, so it is only ever attached to
1246
+ // the signed-in user's OWN messages (see the per-message gate at the MessageItem
1247
+ // call site a teammate's avatar must never open the viewer's profile). Stable
1248
+ // so MessageItem's memo isn't broken; `undefined` when the host opts out, which
1249
+ // keeps every avatar non-interactive.
1178
1250
  const onUserAvatarClick = useMemo(() => onProfileClick
1179
1251
  ? () => onProfileClick({ avatar: userAvatar })
1180
1252
  : undefined, [onProfileClick, userAvatar]);
@@ -1235,6 +1307,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1235
1307
  // applied through handleStreamEvent) can append a card-message to the ONE message store.
1236
1308
  // This client's OWN `card` events are appended internally by useChat's stream handler.
1237
1309
  const appendCardMessageRef = useRef(() => { });
1310
+ // Ref to useChat's appendCompleteMessage — same contract as appendCardMessageRef, for a
1311
+ // teammate's broadcast `message` event (a complete non-streaming message, e.g. a
1312
+ // human-only team note). This client's OWN `message` events append internally in useChat.
1313
+ const appendCompleteMessageRef = useRef(() => { });
1238
1314
  // Kept current each render so handleStreamEvent (memoized) always calls the latest.
1239
1315
  const onReadyToBuildRef = useRef(onReadyToBuild);
1240
1316
  onReadyToBuildRef.current = onReadyToBuild;
@@ -1281,6 +1357,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1281
1357
  if (event.type === 'card' && applyingPushedRef.current) {
1282
1358
  appendCardMessageRef.current(event.id, event.timestamp, event.card);
1283
1359
  }
1360
+ // A teammate's broadcast complete message (e.g. a human-only team note) — append it
1361
+ // to the local store so it shows live for the collaborator too (de-duped by id;
1362
+ // never re-persisted — the originating server already persisted it). This client's
1363
+ // OWN `message` stream events are appended internally by useChat.
1364
+ if (event.type === 'message' && applyingPushedRef.current && event.message) {
1365
+ appendCompleteMessageRef.current(event.message);
1366
+ }
1284
1367
  // Captured outbound side effect (email/sms/push/webhook/channel) — push an
1285
1368
  // inline activity card into the timeline. Non-text card, mirroring how
1286
1369
  // system cards are appended.
@@ -1328,8 +1411,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1328
1411
  // still drives the phase STATE (useChat's setMode); it just no longer spawns a card.
1329
1412
  const cfg = soundsConfigRef.current;
1330
1413
  const eventType = event.type;
1331
- if (eventType in cfg && shouldPlaySound(cfg[eventType])) {
1332
- playTone();
1414
+ // 'message' is the team-note ping: it fires only for a TEAMMATE's note
1415
+ // (the pushed broadcast) — never for the sender's own SSE echo.
1416
+ const isOwnTeamNote = event.type === 'message' && !applyingPushedRef.current;
1417
+ if (!isOwnTeamNote && eventType in cfg && shouldPlaySound(cfg[eventType])) {
1418
+ playTone(eventType === 'message' ? 'team' : 'default');
1333
1419
  }
1334
1420
  }, [t]);
1335
1421
  // Countdown timer effect — ticks down and auto-sends fix message
@@ -1415,7 +1501,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1415
1501
  }
1416
1502
  onFileChange?.(path, content);
1417
1503
  }, [onFileChange, onFileOpen, autoFixCountdown]);
1418
- const { messages, isLoading, isRemoteStreaming, noteRemoteStreamEvent, error, errorMeta, mode, fastMode, streamingStatus, setMode, setFastMode, sendMessage, abort, clearHistory, editQueuedMessage, deleteQueuedMessage, clearQueuedForFile, appendCardMessage, retryCountdown, cancelRetry, } = useChat({
1504
+ const { messages, isLoading, isRemoteStreaming, noteRemoteStreamEvent, error, errorMeta, mode, fastMode, streamingStatus, setMode, setFastMode, sendMessage, abort, clearHistory, editQueuedMessage, deleteQueuedMessage, clearQueuedForFile, appendCardMessage, appendCompleteMessage, retryCountdown, cancelRetry, } = useChat({
1419
1505
  endpoint,
1420
1506
  projectId,
1421
1507
  agentName,
@@ -1436,11 +1522,21 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1436
1522
  onConversationId,
1437
1523
  onStreamEvent: handleStreamEvent,
1438
1524
  });
1525
+ // The mode a model CHANGE should target when the user hasn't scoped one
1526
+ // explicitly (no --plan/--execute flag, no picker-mode choice): the LIVE
1527
+ // conversation mode. Discovery runs in plan mode (aiContext.mode is 'plan'
1528
+ // throughout discovery), so it maps to 'plan' — changing the model while
1529
+ // discovering/planning changes the plan model, while building the execute
1530
+ // model. This replaces the old default of writing the legacy `chatModel`
1531
+ // (which silently moved BOTH modes).
1532
+ const liveModelMode = mode === 'plan' ? 'plan' : 'execute';
1439
1533
  // Keep sendMessageRef in sync so the countdown effect can call the latest sendMessage
1440
1534
  sendMessageRef.current = sendMessage;
1441
1535
  // Keep the card-append ref current so handleStreamEvent (memoized) can append a teammate's
1442
1536
  // broadcast `card` event to the message store.
1443
1537
  appendCardMessageRef.current = appendCardMessage;
1538
+ // Same for a teammate's broadcast complete `message` event (e.g. a team note).
1539
+ appendCompleteMessageRef.current = appendCompleteMessage;
1444
1540
  // User Stop. A stop is a user decision the platform must not overrule — so
1445
1541
  // beyond killing the stream (useChat.abort also records the stop client-side
1446
1542
  // and, via chat-abort's userInitiated flag, server-side), drop every pending
@@ -1578,7 +1674,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1578
1674
  useEffect(() => {
1579
1675
  if (typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches)
1580
1676
  return;
1581
- textareaRef.current?.focus();
1677
+ const ta = textareaRef.current;
1678
+ if (!ta)
1679
+ return;
1680
+ ta.focus();
1681
+ // The textarea can mount with content already in it (defaultValue carries a
1682
+ // persisted draft, and a viewer's box holds the '/teamsay ' prefill) — a
1683
+ // fresh element's selection is (0,0), so without this the caret sits BEFORE
1684
+ // the text and typing lands in front of '/teamsay'.
1685
+ ta.setSelectionRange(ta.value.length, ta.value.length);
1582
1686
  }, []);
1583
1687
  /** Update the ref, the DOM element, and the hasInput flag without re-rendering the parent. */
1584
1688
  const setInputValue = useCallback((val) => {
@@ -1586,7 +1690,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1586
1690
  const ta = textareaRef.current;
1587
1691
  if (ta && ta.value !== val)
1588
1692
  ta.value = val;
1589
- setHasInput(Boolean(val.trim()));
1693
+ // A viewer's composer is pre-filled with '/teamsay ' — the BARE prefix is
1694
+ // not sendable content, so it must not light the Send button.
1695
+ const bareSideChannel = canEdit === false && /^\/(?:teamsay|t)$/i.test(val.trim());
1696
+ setHasInput(Boolean(val.trim()) && !bareSideChannel);
1590
1697
  autoResize();
1591
1698
  // Clear persisted draft when input is emptied (e.g. on submit)
1592
1699
  if (!val) {
@@ -1597,7 +1704,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1597
1704
  /* sessionStorage unavailable — safe to ignore, draft simply persists */
1598
1705
  }
1599
1706
  }
1600
- }, [draftKey, autoResize]);
1707
+ }, [draftKey, autoResize, canEdit]);
1601
1708
  // Persist draft text to sessionStorage so it survives refresh (debounced)
1602
1709
  const draftTimerRef = useRef(null);
1603
1710
  const persistDraft = useCallback(() => {
@@ -2211,6 +2318,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2211
2318
  activityCardsLoadedConvRef.current = null;
2212
2319
  if (!conversationId)
2213
2320
  return;
2321
+ // Activity cards carry recipients/summaries and their routes are editor+ by
2322
+ // design — a viewer's fetch/persist would just 403 on every panel open
2323
+ // (observed live in prod logs). Skip both for read-only viewers.
2324
+ if (canEdit === false)
2325
+ return;
2214
2326
  if (prevConv && prevConv !== conversationId)
2215
2327
  setActivityCards([]);
2216
2328
  let cancelled = false;
@@ -2240,10 +2352,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2240
2352
  return () => {
2241
2353
  cancelled = true;
2242
2354
  };
2243
- }, [conversationId, projectId, http]);
2355
+ }, [conversationId, projectId, http, canEdit]);
2244
2356
  useEffect(() => {
2245
2357
  if (!conversationId || activityCardsLoadedConvRef.current !== conversationId)
2246
2358
  return;
2359
+ if (canEdit === false)
2360
+ return;
2247
2361
  void http
2248
2362
  .put(`/projects/${projectId}/conversations/${conversationId}/activity-cards`, {
2249
2363
  activityCards: activityCards.filter((c) => !c.received),
@@ -2252,7 +2366,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2252
2366
  // Best-effort save (bound as _error per Rule 14): the in-memory cards remain the
2253
2367
  // source of truth this session if the PUT fails.
2254
2368
  });
2255
- }, [activityCards, conversationId, projectId, http]);
2369
+ }, [activityCards, conversationId, projectId, http, canEdit]);
2256
2370
  // ── Auto-tips (dismissable onboarding hints) ──────────────────────────────
2257
2371
  // Two surfaces (see chat-tips-utilities): an ENTRY_TIP shown once on a fresh
2258
2372
  // conversation so a brand-new user always sees how to drive the agent, plus an
@@ -2282,6 +2396,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2282
2396
  shownTipIdsRef.current = [];
2283
2397
  // Re-arm the entry tip so a freshly-started chat shows the onboarding hint.
2284
2398
  entryTipShownRef.current = false;
2399
+ // Re-arm the viewer composer prefill ('/teamsay ') for the new conversation.
2400
+ viewerPrefillDoneRef.current = false;
2285
2401
  }
2286
2402
  }, [conversationId]);
2287
2403
  // Entry tip: the onboarding moment. Show ONE high-value hint as soon as a fresh
@@ -2303,6 +2419,31 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2303
2419
  const text = t(`ide.chat.tip.${ENTRY_TIP.id}`, { agentName }, { defaultValue: ENTRY_TIP.text });
2304
2420
  setTipCards((prev) => [...prev, { id: crypto.randomUUID(), text, timestamp: Date.now() }]);
2305
2421
  }, [conversationId, messages.length, agentName, discovery]);
2422
+ // Viewer orientation tip: a read-only member's explainer — GOLD like the
2423
+ // team-only messages and led by the same `people` icon it explains, so the
2424
+ // badge on a team note is self-describing. A regular dismissable tip (never a
2425
+ // permanent composer note); shown once per panel mount, skipped in discovery
2426
+ // like the other tips (mvp B1). Gated on an EXPLICIT canEdit === false so
2427
+ // hosts that don't do roles never see it.
2428
+ const viewerTipShownRef = useRef(false);
2429
+ useEffect(() => {
2430
+ if (canEdit !== false || discovery || viewerTipShownRef.current)
2431
+ return;
2432
+ viewerTipShownRef.current = true;
2433
+ const text = t('ide.chat.tip.viewerTeamOnly', { agentName }, {
2434
+ defaultValue: 'View-only access — read along and /teamsay (or just /t) the team. This gold icon marks team-only messages ({{agentName}} ignores them). Running the assistant and changing the model or settings need editor access.',
2435
+ });
2436
+ setTipCards((prev) => [
2437
+ ...prev,
2438
+ {
2439
+ id: crypto.randomUUID(),
2440
+ text,
2441
+ timestamp: Date.now(),
2442
+ accent: NOTICE_TONE.gold.accent,
2443
+ icon: 'people',
2444
+ },
2445
+ ]);
2446
+ }, [canEdit, discovery, agentName]);
2306
2447
  // Surface an occasional idle tip. This effect re-runs on every message change, so
2307
2448
  // the idle timer is continually reset by activity; it only fires once the
2308
2449
  // conversation has been quiet for TIP_IDLE_MS — and even then only when the
@@ -2335,7 +2476,31 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2335
2476
  }, [messages, discovery]);
2336
2477
  // ── Sounds picker (shown when /sounds is executed) ────────────────────────
2337
2478
  const [soundsPicker, setSoundsPicker] = useState(null);
2338
- const [soundsConfig, setSoundsConfig] = useState({ ...DEFAULT_SOUNDS_CONFIG });
2479
+ // Notification sounds are a PER-DEVICE, per-user preference localStorage,
2480
+ // never project settings. They used to persist via PATCH /projects settings
2481
+ // (shared with every member, and unreachable for read-only viewers, whose
2482
+ // PATCH 403s); the old project-level value is still read once as a migration
2483
+ // fallback when this device has no local preference yet.
2484
+ const [soundsConfig, setSoundsConfig] = useState(() => {
2485
+ try {
2486
+ const raw = localStorage.getItem(SOUNDS_STORAGE_KEY);
2487
+ if (raw)
2488
+ return { ...DEFAULT_SOUNDS_CONFIG, ...JSON.parse(raw) };
2489
+ }
2490
+ catch (_error) {
2491
+ // localStorage unavailable / bad JSON — fall through to the defaults.
2492
+ }
2493
+ return { ...DEFAULT_SOUNDS_CONFIG };
2494
+ });
2495
+ const soundsLocallySetRef = useRef(false);
2496
+ useEffect(() => {
2497
+ try {
2498
+ soundsLocallySetRef.current = localStorage.getItem(SOUNDS_STORAGE_KEY) != null;
2499
+ }
2500
+ catch (_error) {
2501
+ // localStorage unavailable — treat as unset; the project fallback may apply.
2502
+ }
2503
+ }, []);
2339
2504
  // ── Panel overlay (/skills, /scripts, /settings — closeable popups) ──────────
2340
2505
  // These three commands open a closeable overlay above the composer (mirrors how
2341
2506
  // /model and /sounds own this popup region) instead of dropping an inline card
@@ -2373,16 +2538,16 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2373
2538
  updated = { ...soundsConfig, [eventType]: next };
2374
2539
  }
2375
2540
  setSoundsConfig(updated);
2541
+ // Per-device preference — localStorage, never a project PATCH (see the
2542
+ // state's comment; this also makes /sounds fully usable for viewers).
2376
2543
  try {
2377
- await http.patch(`/projects/${projectId}`, { settings: { sounds: updated } });
2544
+ localStorage.setItem(SOUNDS_STORAGE_KEY, JSON.stringify(updated));
2545
+ soundsLocallySetRef.current = true;
2378
2546
  }
2379
- catch (error) {
2380
- logger.warn('Failed to persist sound settings to server', { error });
2381
- addSystemCard(t('ide.chat.soundsError', undefined, {
2382
- defaultValue: 'Failed to update sound settings.',
2383
- }));
2547
+ catch (_error) {
2548
+ // localStorage unavailable the choice still applies for this session.
2384
2549
  }
2385
- }, [soundsConfig, http, projectId, addSystemCard]);
2550
+ }, [soundsConfig]);
2386
2551
  // ── Current project settings (model + maxloops + sounds) ──────────────────
2387
2552
  // Project-scoped so any custom (bring-your-own AI) models the project has
2388
2553
  // configured are included alongside the platform catalog.
@@ -2498,7 +2663,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2498
2663
  if (typeof s?.autoApproveCommands === 'boolean') {
2499
2664
  setAutoApproveCommandsEnabled(s.autoApproveCommands);
2500
2665
  }
2501
- if (s?.sounds && typeof s.sounds === 'object') {
2666
+ // Legacy migration only: sounds are per-device (localStorage) now — apply
2667
+ // the old project-level value only when this device has no local pref.
2668
+ if (s?.sounds && typeof s.sounds === 'object' && !soundsLocallySetRef.current) {
2502
2669
  setSoundsConfig((prev) => ({ ...prev, ...s.sounds }));
2503
2670
  }
2504
2671
  // Restore the persisted auto-commit cadence in the paused state (it
@@ -2591,7 +2758,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2591
2758
  return;
2592
2759
  removedModelNotifiedRef.current = currentModel;
2593
2760
  const removedId = currentModel;
2594
- const fallback = FREE_TIER_MODEL || AVAILABLE_MODELS[0]?.id;
2761
+ // Prefer a still-available model from the SAME custom provider before a
2762
+ // platform model: when a BYO provider's model is renamed (custom/<prov>/A →
2763
+ // custom/<prov>/B), keep the user on their own endpoint rather than bouncing
2764
+ // them to a platform free model.
2765
+ const sameProviderPrefix = removedId.match(/^(custom\/[^/]+\/)/)?.[1];
2766
+ const sameProviderModel = sameProviderPrefix
2767
+ ? AVAILABLE_MODELS.find((m) => m.id.startsWith(sameProviderPrefix))?.id
2768
+ : undefined;
2769
+ const fallback = sameProviderModel || FREE_TIER_MODEL || AVAILABLE_MODELS[0]?.id;
2595
2770
  addSystemCard(fallback
2596
2771
  ? t('ide.chat.modelRemoved', { removed: removedId, fallback }, {
2597
2772
  defaultValue: 'Your selected model "{{removed}}" is no longer available. Switched to "{{fallback}}". Type /model to pick another.',
@@ -2602,9 +2777,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2602
2777
  if (fallback) {
2603
2778
  setCurrentModel(fallback);
2604
2779
  setSavedChatModel(fallback);
2605
- http.patch(`/projects/${projectId}`, { settings: { chatModel: fallback } }).catch(() => {
2606
- /* persistence is best-effort; the in-memory switch is what matters */
2607
- });
2780
+ // Persisting the swap writes project settings editor+ server-side, so a
2781
+ // viewer's tab keeps the in-memory switch (all this session needs) and
2782
+ // skips a PATCH that could only 403.
2783
+ if (canEdit !== false) {
2784
+ http.patch(`/projects/${projectId}`, { settings: { chatModel: fallback } }).catch(() => {
2785
+ /* persistence is best-effort; the in-memory switch is what matters */
2786
+ });
2787
+ }
2608
2788
  }
2609
2789
  }, [
2610
2790
  modelsLoading,
@@ -2614,6 +2794,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2614
2794
  addSystemCard,
2615
2795
  http,
2616
2796
  projectId,
2797
+ canEdit,
2617
2798
  ]);
2618
2799
  // ── Queued message editing ──────────────────────────────────────────────────
2619
2800
  const [editingQueuedId, setEditingQueuedId] = useState(null);
@@ -2780,10 +2961,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2780
2961
  useEffect(() => {
2781
2962
  if (initialMessage && !hasConversation && sentInitialRef.current !== initialMessage) {
2782
2963
  sentInitialRef.current = initialMessage;
2783
- sendMessage(initialMessage);
2964
+ // Same rule as handleSubmit: a host side-channel command (e.g. /teamsay)
2965
+ // renders no optimistic echo — the server's `message` event is the message.
2966
+ const sideChannel = matchesSideChannelCommand(extraCommands?.length ? [...COMMANDS, ...extraCommands] : COMMANDS, initialMessage);
2967
+ sendMessage(initialMessage, undefined, sideChannel ? { suppressUserMessage: true } : undefined);
2784
2968
  onInitialMessageSent?.();
2785
2969
  }
2786
- }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent]);
2970
+ }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent, extraCommands]);
2787
2971
  // ── Auto-send pending message (e.g. "Fix with AI", preview errors) ──────
2788
2972
  // Defers sending while the AI is streaming to avoid queueing up auto-fix
2789
2973
  // messages during active work. Messages are sent once streaming ends.
@@ -2794,6 +2978,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2794
2978
  pendingMessageKey !== undefined &&
2795
2979
  pendingMessageKey !== lastPendingKeyRef.current) {
2796
2980
  lastPendingKeyRef.current = pendingMessageKey;
2981
+ // A read-only VIEWER never dispatches platform-composed sends (build
2982
+ // kickoffs, auto-fixes, preview-failure reports): the server 403s them
2983
+ // and the denial used to surface as a red error in the viewer's chat.
2984
+ // Consume the key (above) so the same message isn't retried.
2985
+ if (canEdit === false)
2986
+ return;
2797
2987
  // A pending message is ALWAYS system-composed (an auto-fix prompt, a preview-failure
2798
2988
  // report, a "Fix with AI" request) — never text the user typed. So it must NEVER
2799
2989
  // render as a normal user bubble: either it's suppressed entirely (hidden build
@@ -2827,6 +3017,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2827
3017
  pendingMessageUserInitiated,
2828
3018
  sendMessage,
2829
3019
  isLoading,
3020
+ canEdit,
2830
3021
  ]);
2831
3022
  // When streaming ends, send any deferred message. (A user Stop also ends
2832
3023
  // streaming, but handleAbort drops the deferred message first — and useChat's
@@ -2840,6 +3031,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2840
3031
  deferredPendingRef.current = null;
2841
3032
  deferredPendingSuppressRef.current = false;
2842
3033
  deferredPendingUserInitiatedRef.current = false;
3034
+ // A viewer never dispatches platform-composed sends (same as the
3035
+ // immediate path above) — the refs are already drained, so it just drops.
3036
+ if (canEdit === false)
3037
+ return;
2843
3038
  // Same rule as the immediate path: a deferred pending message is system-composed —
2844
3039
  // always `automatic` (suppressed or visible), never a plain user bubble.
2845
3040
  sendMessage(msg, undefined, {
@@ -2848,7 +3043,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2848
3043
  ...(userInitiated ? { userInitiated: true } : {}),
2849
3044
  });
2850
3045
  }
2851
- }, [isLoading, sendMessage]);
3046
+ }, [isLoading, sendMessage, canEdit]);
2852
3047
  // ── Auto-delete queued autofix messages when user edits a relevant file ────
2853
3048
  const lastUserEditKeyRef = useRef(userEditedFileKey);
2854
3049
  useEffect(() => {
@@ -3343,11 +3538,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3343
3538
  return;
3344
3539
  }
3345
3540
  setFilePicker(null);
3346
- // Show model picker when typing "/model <filter>" — scoped to a mode when
3347
- // the input carries a --plan / --execute flag.
3541
+ // Show model picker when typing "/model <filter>" — scoped to the mode a
3542
+ // --plan / --execute flag names, else to the LIVE conversation mode
3543
+ // (discovery = plan), so a plain pick changes the mode the user is in.
3348
3544
  const modelMatch = val.match(/^\/model\s+/i);
3349
3545
  if (modelMatch) {
3350
- setModelPicker({ selectedIdx: -1, mode: parseModelModeCommand(val)?.mode });
3546
+ setModelPicker({ selectedIdx: -1, mode: parseModelModeCommand(val)?.mode ?? liveModelMode });
3351
3547
  setCommandMenu(null);
3352
3548
  return;
3353
3549
  }
@@ -3358,7 +3554,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3358
3554
  else {
3359
3555
  setCommandMenu(null);
3360
3556
  }
3361
- }, [openFilePicker, autoResize, persistDraft]);
3557
+ }, [openFilePicker, autoResize, persistDraft, liveModelMode]);
3362
3558
  // ── Execute command ────────────────────────────────────────────────────────
3363
3559
  /** Sets textarea value and moves cursor to the end. */
3364
3560
  const setInputAndCursorEnd = useCallback((val) => {
@@ -3372,6 +3568,17 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3372
3568
  }
3373
3569
  }, 0);
3374
3570
  }, [setInputValue, autoResize]);
3571
+ // Viewer composer prefill: the box IS the team-chat input, so it starts with
3572
+ // "/teamsay " already typed (and handleSubmit re-fills it after each sent team
3573
+ // message). Once per mount, and never over text the user already has.
3574
+ const viewerPrefillDoneRef = useRef(false);
3575
+ useEffect(() => {
3576
+ if (canEdit !== false || viewerPrefillDoneRef.current)
3577
+ return;
3578
+ viewerPrefillDoneRef.current = true;
3579
+ if (!inputRef.current.trim())
3580
+ setInputAndCursorEnd('/teamsay ');
3581
+ }, [canEdit, setInputAndCursorEnd, conversationId]);
3375
3582
  /**
3376
3583
  * Select and apply a model by ID. When `mode` is given the choice persists to
3377
3584
  * that mode's per-mode field (`planModel` / `executeModel` /
@@ -3459,6 +3666,20 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3459
3666
  setInputAndCursorEnd(`/${id} `);
3460
3667
  return;
3461
3668
  }
3669
+ // A read-only VIEWER may run only viewerSafe commands (read / view-only /
3670
+ // per-user preference). Everything else writes shared project state or
3671
+ // triggers a Synthase turn the server 403s, so surface a read-only note
3672
+ // instead of a dead control that silently fails. Default-deny: an
3673
+ // unflagged command is unavailable to viewers.
3674
+ if (!canEdit) {
3675
+ const def = allCommands.find((c) => c.id === id);
3676
+ if (def && !def.viewerSafe) {
3677
+ addSystemCard(t('ide.chat.viewerReadOnlyCommand', undefined, {
3678
+ defaultValue: 'You have view-only access, so this command is unavailable. Ask an editor to make changes.',
3679
+ }));
3680
+ return;
3681
+ }
3682
+ }
3462
3683
  if (id === 'clear') {
3463
3684
  setInputValue('');
3464
3685
  await clearHistory();
@@ -3469,7 +3690,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3469
3690
  }
3470
3691
  else if (id === 'model') {
3471
3692
  setInputAndCursorEnd('/model ');
3472
- setModelPicker({ selectedIdx: -1 });
3693
+ setModelPicker({ selectedIdx: -1, mode: liveModelMode });
3473
3694
  }
3474
3695
  else if (id === 'mic' || id === 'dictate') {
3475
3696
  setInputValue('');
@@ -3561,7 +3782,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3561
3782
  defaultValue: "Today's AI allowance is used up — refreshes {{when}}.",
3562
3783
  })
3563
3784
  : t('ide.chat.usageAllowanceTodayLine', { percent: d.allowancePercent }, {
3564
- defaultValue: "You've used ~{{percent}}% of today's AI allowance.",
3785
+ // Neutral phrasing on purpose: the allowance belongs to
3786
+ // the PROJECT (its owner's plan window) — "you've used"
3787
+ // misattributed it to whichever teammate ran /cost.
3788
+ defaultValue: "~{{percent}}% of today's AI allowance used.",
3565
3789
  }))
3566
3790
  : '';
3567
3791
  // `inputTokens` counts only the UNCACHED prompt: every bond normalizes
@@ -3787,7 +4011,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3787
4011
  }
3788
4012
  else if (id === 'share') {
3789
4013
  setInputValue('');
3790
- setShareModal({ role: DEFAULT_SHARE_ROLE });
4014
+ if (shareAllowed)
4015
+ setShareModal({ role: DEFAULT_SHARE_ROLE });
3791
4016
  }
3792
4017
  }, [
3793
4018
  clearHistory,
@@ -3804,6 +4029,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3804
4029
  refreshGitStatus,
3805
4030
  openPanelOverlay,
3806
4031
  extraCommandIds,
4032
+ canEdit,
4033
+ allCommands,
4034
+ liveModelMode,
4035
+ t,
3807
4036
  ]);
3808
4037
  // When the auto-commit countdown reaches zero, fire the existing /commit path
3809
4038
  // (no new backend) and pause until the next file change re-arms it. /commit
@@ -3839,6 +4068,26 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3839
4068
  const trimmed = inputRef.current.trim();
3840
4069
  if (!trimmed && attachedFiles.length === 0)
3841
4070
  return;
4071
+ // Read-only VIEWER guard. A viewer cannot run the assistant (the server 403s
4072
+ // the chat route) or any project-mutating command, so block a plain message
4073
+ // or a write-command here with a read-only note instead of a silent 403 —
4074
+ // but let team side-channel messages (/teamsay) and viewerSafe commands
4075
+ // through so viewers can still talk to the team and read. (Menu-dispatched
4076
+ // commands are gated in executeCommand; this covers composer typing.)
4077
+ if (!canEdit) {
4078
+ const token = trimmed.match(/^\/(\S+)/)?.[1]?.toLowerCase();
4079
+ const def = token
4080
+ ? allCommands.find((c) => c.id.toLowerCase() === token || c.aliases?.includes(token))
4081
+ : undefined;
4082
+ const allowed = matchesSideChannelCommand(allCommands, trimmed) || def?.viewerSafe === true;
4083
+ if (!allowed) {
4084
+ setInputValue('');
4085
+ addSystemCard(t('ide.chat.viewerReadOnly', undefined, {
4086
+ 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.",
4087
+ }));
4088
+ return;
4089
+ }
4090
+ }
3842
4091
  // Handle /autofix toggle locally
3843
4092
  if (/^\/autofix$/i.test(trimmed)) {
3844
4093
  void executeCommand('autofix');
@@ -3901,10 +4150,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3901
4150
  return;
3902
4151
  }
3903
4152
  // Handle /share [role] locally — opens the share-link modal at the requested
3904
- // role (default viewer). An unrecognized role shows usage instead.
4153
+ // role (default viewer). An unrecognized role shows usage instead. Below the
4154
+ // host's share capability (e.g. an editor where minting is admin+), say so
4155
+ // plainly instead of opening a modal whose every request 403s.
3905
4156
  const shareMatch = parseShareCommand(trimmed);
3906
4157
  if (shareMatch) {
3907
4158
  setInputValue('');
4159
+ if (!shareAllowed) {
4160
+ addSystemCard(t('ide.chat.share.notAllowed', undefined, {
4161
+ defaultValue: 'Managing share links needs an admin role on this project.',
4162
+ }));
4163
+ return;
4164
+ }
3908
4165
  if (shareMatch.kind === 'invalid') {
3909
4166
  addSystemCard(t('ide.chat.share.usage', { roles: SHARE_ROLES.join(', ') }, {
3910
4167
  defaultValue: 'Usage: /share [role] — create a public link. Roles: {{roles}} (default viewer).',
@@ -3986,20 +4243,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3986
4243
  { action: buildUpgradeCta?.({}) ?? undefined });
3987
4244
  }
3988
4245
  else {
3989
- try {
3990
- await http.patch(`/projects/${projectId}`, { settings: { chatModel: name } });
3991
- setCurrentModel(name);
3992
- setSavedChatModel(name);
3993
- addSystemCard(t('ide.chat.modelSet', { name: resolved?.label ?? name }, {
3994
- defaultValue: `Chat model set to ${resolved?.label ?? name}`,
3995
- }));
3996
- }
3997
- catch (error) {
3998
- logger.warn('Failed to update chat model via /model command', { error });
3999
- addSystemCard(t('ide.chat.modelError', undefined, {
4000
- defaultValue: 'Failed to update chat model.',
4001
- }));
4002
- }
4246
+ // Unscoped /model targets the CURRENT conversation mode (discovery =
4247
+ // plan), same as /effort never the legacy both-modes chatModel.
4248
+ await selectModel(name, resolved?.label ?? name, liveModelMode);
4003
4249
  }
4004
4250
  }
4005
4251
  setInputValue('');
@@ -4157,6 +4403,21 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4157
4403
  sendMessage(prompt);
4158
4404
  return;
4159
4405
  }
4406
+ // A host side-channel command (CommandDef.sideChannel, matched by id or alias — e.g.
4407
+ // molecule.dev's /teamsay + /t): send the raw text to the host's server intercept but
4408
+ // suppress the optimistic user bubble. The server emits the canonical `message` stream
4409
+ // event (persisted + broadcast to every member), which IS the visible message — so the
4410
+ // transcript never shows the literal "/command" text, and never shows it twice.
4411
+ if (matchesSideChannelCommand(allCommands, trimmed)) {
4412
+ // A viewer's composer is the team-chat box — re-fill the /teamsay prefix
4413
+ // after each sent team message so the next one is one keystroke away.
4414
+ if (canEdit === false)
4415
+ setInputAndCursorEnd('/teamsay ');
4416
+ else
4417
+ setInputValue('');
4418
+ sendMessage(trimmed, undefined, { suppressUserMessage: true });
4419
+ return;
4420
+ }
4160
4421
  // Rewrite /explain with attachments into a proper prompt (attachments processed below)
4161
4422
  let message = trimmed;
4162
4423
  if (explainMatch) {
@@ -4203,7 +4464,17 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4203
4464
  setAttachedFiles([]);
4204
4465
  setAttachmentError(null);
4205
4466
  sendMessage(message, chatAttachments.length > 0 ? chatAttachments : undefined);
4206
- }, [attachedFiles, http, projectId, sendMessage, setInputValue, runSavedScript]);
4467
+ }, [
4468
+ attachedFiles,
4469
+ http,
4470
+ projectId,
4471
+ sendMessage,
4472
+ setInputValue,
4473
+ runSavedScript,
4474
+ allCommands,
4475
+ selectModel,
4476
+ liveModelMode,
4477
+ ]);
4207
4478
  // External auto-submit. When the signal changes, submit the current input —
4208
4479
  // used by the prompt → chat morph to send the prefilled prompt once the chat
4209
4480
  // has docked into place (handleSubmit clears the input as it sends).
@@ -4239,12 +4510,22 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4239
4510
  useEffect(() => {
4240
4511
  if (openShareSignal !== undefined && openShareSignal !== lastOpenShareRef.current) {
4241
4512
  lastOpenShareRef.current = openShareSignal;
4242
- setShareModal({ role: DEFAULT_SHARE_ROLE });
4513
+ if (shareAllowed)
4514
+ setShareModal({ role: DEFAULT_SHARE_ROLE });
4243
4515
  }
4244
- }, [openShareSignal]);
4516
+ }, [openShareSignal, shareAllowed]);
4245
4517
  // ── Keyboard ───────────────────────────────────────────────────────────────
4246
4518
  const filteredCmds = commandMenu
4247
- ? allCommands.filter((c) => c.label.startsWith(inputRef.current))
4519
+ ? allCommands.filter((c) =>
4520
+ // A showAll menu (slash-button toggle over existing text) lists every
4521
+ // command; a typed menu filters by the composer's text.
4522
+ (commandMenu.showAll === true || c.label.startsWith(inputRef.current)) &&
4523
+ // Viewers see only commands they can actually run (viewer-safe reads +
4524
+ // the /teamsay side channel) — no dead entries in the menu.
4525
+ (canEdit !== false || c.viewerSafe === true || c.sideChannel === true) &&
4526
+ // /share is gated separately: hosts commonly mint at admin+, so an
4527
+ // editor without the capability gets no dead menu entry either.
4528
+ (c.id !== 'share' || shareAllowed))
4248
4529
  : [];
4249
4530
  const filteredModels = useMemo(() => {
4250
4531
  if (!modelPicker)
@@ -4259,7 +4540,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4259
4540
  if (!q)
4260
4541
  return AVAILABLE_MODELS;
4261
4542
  return AVAILABLE_MODELS.filter((m) => m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q));
4262
- }, [modelPicker]);
4543
+ // AVAILABLE_MODELS MUST be a dep: when a custom provider is edited (a model
4544
+ // renamed/added, a URL changed) the catalog refreshes in place, and without
4545
+ // this the picker keeps rendering the pre-edit snapshot — the new model
4546
+ // never appears and can't be selected. (The query is read from inputRef and
4547
+ // stays fresh because each keystroke re-sets modelPicker.)
4548
+ }, [modelPicker, AVAILABLE_MODELS]);
4263
4549
  // ── Older models section ────────────────────────────────────────────────────
4264
4550
  // Deprecated entries fold into a collapsed "Older models ⌄" section under the
4265
4551
  // current models. The section auto-expands when the user's currentModel is in
@@ -4526,9 +4812,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4526
4812
  e.preventDefault();
4527
4813
  const idx = modelPicker.selectedIdx >= 0 ? modelPicker.selectedIdx : 0;
4528
4814
  if (onManageCustomModels && idx === visibleModels.length) {
4815
+ const manageMode = modelPicker.mode === 'plan' || modelPicker.mode === 'execute'
4816
+ ? modelPicker.mode
4817
+ : liveModelMode;
4529
4818
  setModelPicker(null);
4530
4819
  setInputValue('');
4531
- onManageCustomModels();
4820
+ onManageCustomModels({ mode: manageMode });
4532
4821
  return;
4533
4822
  }
4534
4823
  const model = visibleModels[idx];
@@ -4569,20 +4858,28 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4569
4858
  if (commandMenu && filteredCmds.length > 0) {
4570
4859
  if (e.key === 'ArrowDown') {
4571
4860
  e.preventDefault();
4572
- setCommandMenu((m) => m ? { selectedIdx: wrapIdx(m.selectedIdx, 1, filteredCmds.length) } : null);
4861
+ setCommandMenu((m) => m ? { ...m, selectedIdx: wrapIdx(m.selectedIdx, 1, filteredCmds.length) } : null);
4573
4862
  return;
4574
4863
  }
4575
4864
  if (e.key === 'ArrowUp') {
4576
4865
  e.preventDefault();
4577
- setCommandMenu((m) => m ? { selectedIdx: wrapIdx(m.selectedIdx, -1, filteredCmds.length) } : null);
4866
+ setCommandMenu((m) => m ? { ...m, selectedIdx: wrapIdx(m.selectedIdx, -1, filteredCmds.length) } : null);
4578
4867
  return;
4579
4868
  }
4580
4869
  if (e.key === 'Enter' || e.key === 'Tab') {
4581
- e.preventDefault();
4582
- const cmd = filteredCmds[commandMenu.selectedIdx >= 0 ? commandMenu.selectedIdx : 0];
4583
- if (cmd)
4584
- void executeCommand(cmd.id);
4585
- return;
4870
+ // A showAll menu (opened by the slash BUTTON over existing text) only
4871
+ // executes an explicitly highlighted command a bare Enter closes it
4872
+ // and falls through to submit the composer text as typed.
4873
+ if (commandMenu.showAll && commandMenu.selectedIdx < 0 && e.key === 'Enter') {
4874
+ setCommandMenu(null);
4875
+ }
4876
+ else {
4877
+ e.preventDefault();
4878
+ const cmd = filteredCmds[commandMenu.selectedIdx >= 0 ? commandMenu.selectedIdx : 0];
4879
+ if (cmd)
4880
+ void executeCommand(cmd.id);
4881
+ return;
4882
+ }
4586
4883
  }
4587
4884
  }
4588
4885
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -4636,8 +4933,16 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4636
4933
  };
4637
4934
  }
4638
4935
  case 'mode':
4639
- // Only the plan→build handoff renders a card; the server records a mode card solely
4640
- // for 'execute' ("🔨 Building your app"). Anything else renders nothing.
4936
+ // The plan→build handoff ("🔨 Building your app") and the plan-mode
4937
+ // announcement a new conversation is seeded with ("📝 Plan mode")
4938
+ // the server records mode cards only for those two.
4939
+ if (cardEvent.mode === 'plan') {
4940
+ return {
4941
+ id,
4942
+ text: t('ide.chat.phasePlanning', undefined, { defaultValue: '📝 Plan mode' }),
4943
+ timestamp,
4944
+ };
4945
+ }
4641
4946
  if (cardEvent.mode !== 'execute')
4642
4947
  return null;
4643
4948
  return {
@@ -4699,6 +5004,20 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4699
5004
  // reading the guest limit they just escaped. Same reasoning the host's
4700
5005
  // `upgrade_prompt` card factory already applies to the recorded guest card.
4701
5006
  const isStaleAnonymousLimit = errorMeta?.requiresSignup === true && isAnonymous === false;
5007
+ // A read-only viewer's access-denied chat error must never sit as the
5008
+ // persistent red banner — every legitimate viewer action is already gated
5009
+ // client-side, so this only fires when something slipped through to the
5010
+ // server (or an older tab raced a role change). Flash a calm, gold
5011
+ // view-only notice for a few seconds instead, then clear.
5012
+ const isViewerAccessDenied = canEdit === false && !!error && /access denied/i.test(error);
5013
+ const [viewerDeniedFlash, setViewerDeniedFlash] = useState(false);
5014
+ useEffect(() => {
5015
+ if (!isViewerAccessDenied)
5016
+ return;
5017
+ setViewerDeniedFlash(true);
5018
+ const timer = setTimeout(() => setViewerDeniedFlash(false), 6000);
5019
+ return () => clearTimeout(timer);
5020
+ }, [isViewerAccessDenied, error]);
4702
5021
  const timeline = useMemo(() => {
4703
5022
  const items = [];
4704
5023
  // A card-message (role:'system' carrying a cardEvent) renders as a SYSTEM CARD, not a chat
@@ -4866,11 +5185,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4866
5185
  defaultValue: 'Show earlier messages',
4867
5186
  }) }) })), (timeline.length > maxVisibleItems ? timeline.slice(-maxVisibleItems) : timeline).map((item) => (_jsx(ChatItemBoundary, { onError: onRenderError, render: () => {
4868
5187
  if (item.kind === 'commit')
4869
- return (_jsx(CommitCardItem, { card: item.card, onRevert: handleRevertCommit }, item.card.id));
5188
+ return (_jsx(CommitCardItem, { card: item.card, onRevert: canEdit === false ? undefined : handleRevertCommit }, item.card.id));
4870
5189
  if (item.kind === 'activity')
4871
5190
  return (_jsx(ActivityCard, { activity: item.card.activity, onActivityClick: onActivityClick }, item.card.id));
4872
5191
  if (item.kind === 'tip')
4873
- return (_jsx(TipCard, { text: item.card.text, onDismiss: () => dismissTip(item.card.id) }, item.card.id));
5192
+ return (_jsx(TipCard, { text: item.card.text, accent: item.card.accent, icon: item.card.icon, onDismiss: () => dismissTip(item.card.id) }, item.card.id));
4874
5193
  if (item.kind === 'system') {
4875
5194
  if (item.card.variant === 'settings') {
4876
5195
  // Legacy inline branch — kept so any 'settings' card persisted
@@ -4971,12 +5290,33 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4971
5290
  timestamp: msg.timestamp,
4972
5291
  status: 'done',
4973
5292
  hash,
4974
- }, onRevert: handleRevertCommit }, msg.id));
5293
+ }, onRevert: canEdit === false ? undefined : handleRevertCommit }, msg.id));
4975
5294
  }
4976
- 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, onAvatarClick: onUserAvatarClick, discovery: discovery, buildUpgradeCta: buildUpgradeCta }, msg.id));
5295
+ return (_jsx(MessageItem, { msg: msg, sendMessage: sendMessage, handleAskUserResponse: handleAskUserResponse, isLoading: isLoading, streamingStatus: streamingStatus, onNavigatePreview: onNavigatePreview, undoneTcIds: undoneTcIds, handleUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, handleFileRevert: handleFileRevert, setInputAndCursorEnd: setInputAndCursorEnd, setModelPicker: setModelPicker, chatMode: liveModelMode, userAvatar: userAvatar,
5296
+ // Avatar clicks open the signed-in user's OWN profile, so only
5297
+ // their own messages get the handler: author-less ones (the
5298
+ // local echo, legacy solo rows) or an author matching
5299
+ // currentUserId. A teammate's avatar stays non-interactive —
5300
+ // clicking Test's face must not open Luke's profile editor.
5301
+ onAvatarClick: !msg.author?.id || (currentUserId != null && msg.author.id === currentUserId)
5302
+ ? onUserAvatarClick
5303
+ : undefined, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName, canEdit: canEdit }, msg.id));
4977
5304
  } }, item.kind === 'message' ? item.msg.id : item.card.id))), error &&
4978
5305
  !isStaleAnonymousLimit &&
4979
- (errorMeta?.limitType ? (_jsx(ResourceLimitBanner, { message: error, action: buildUpgradeCta?.({ requiresSignup: errorMeta.requiresSignup }) })) : (_jsx("div", { className: cm.cn(cm.textSize('sm'), cm.sp('p', 2), cm.sp('mb', 2), cm.bgErrorSubtle, cm.textError), style: { borderRadius: '6px' }, children: error }))), (() => {
5306
+ (errorMeta?.limitType ? (_jsx(ResourceLimitBanner, { message: error, action: buildUpgradeCta?.({ requiresSignup: errorMeta.requiresSignup }) })) : isViewerAccessDenied ? (
5307
+ // A viewer's denial is expected state, not an alarm: a brief, calm,
5308
+ // gold view-only notice (matching the viewer tip / team-note
5309
+ // treatment) that clears itself — never the persistent red banner.
5310
+ viewerDeniedFlash ? (_jsxs("div", { "data-mol-id": "chat-viewer-denied-notice", className: cm.cn(cm.textSize('xs'), cm.textMuted), style: {
5311
+ display: 'flex',
5312
+ alignItems: 'flex-start',
5313
+ gap: 8,
5314
+ marginBottom: 8,
5315
+ ...chatCardStyle(NOTICE_TONE.gold.accent),
5316
+ lineHeight: 1.5,
5317
+ }, children: [_jsx(Icon, { name: "people", size: CHAT_CARD_ICON_SIZE, "aria-hidden": "true", style: { flexShrink: 0, marginTop: 1, color: NOTICE_TONE.gold.accent } }), _jsx("span", { style: { flex: 1 }, children: t('ide.chat.viewerReadOnly', undefined, {
5318
+ defaultValue: "You have view-only access, so you can't run the assistant here. You can still read along and use /teamsay to message the team.",
5319
+ }) })] })) : null) : (_jsx("div", { className: cm.cn(cm.textSize('sm'), cm.sp('p', 2), cm.sp('mb', 2), cm.bgErrorSubtle, cm.textError), style: { borderRadius: '6px' }, children: error }))), (() => {
4980
5320
  const showActivity = isLoading || awaitingSandboxBoot;
4981
5321
  const streamingMsg = isLoading
4982
5322
  ? [...visibleMessages].reverse().find((m) => m.isStreaming)
@@ -5775,9 +6115,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5775
6115
  cursor: 'pointer',
5776
6116
  textAlign: 'left',
5777
6117
  }, children: t('ide.chat.olderModelsExpand', { count: deprecatedModels.length }, { defaultValue: 'Older models ⌄ ({{count}})' }) }))] })), onManageCustomModels && (_jsx("button", { type: "button", "data-mol-id": "chat-model-manage-custom", onClick: () => {
6118
+ const manageMode = modelPicker.mode === 'plan' || modelPicker.mode === 'execute'
6119
+ ? modelPicker.mode
6120
+ : liveModelMode;
5778
6121
  setModelPicker(null);
5779
6122
  setInputValue('');
5780
- onManageCustomModels();
6123
+ onManageCustomModels({ mode: manageMode });
5781
6124
  }, onMouseEnter: (e) => {
5782
6125
  ;
5783
6126
  e.currentTarget.style.background =
@@ -6173,7 +6516,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6173
6516
  }, onMouseLeave: (e) => {
6174
6517
  e.currentTarget.style.opacity = '0.7';
6175
6518
  e.currentTarget.style.background = 'none';
6176
- }, children: _jsx("svg", { width: "13", height: "13", viewBox: "0 0 12 12", style: { display: 'block' }, children: _jsx("path", { d: "M 2.5,6.5 L 6,3 L 9.5,6.5 M 6,3.5 L 6,10", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) }))] }, qm.id))) })] })), pendingFiles != null &&
6519
+ }, children: _jsx("svg", { width: "13", height: "13", viewBox: "0 0 12 12", style: { display: 'block' }, children: _jsx("path", { d: "M 2.5,6.5 L 6,3 L 9.5,6.5 M 6,3.5 L 6,10", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) }))] }, qm.id))) })] })), canEdit !== false &&
6520
+ pendingFiles != null &&
6177
6521
  pendingFiles.length > 0 &&
6178
6522
  !commandMenu &&
6179
6523
  !modelPicker &&
@@ -6319,7 +6663,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6319
6663
  opacity: 0.5,
6320
6664
  transition: 'opacity 100ms, background 100ms',
6321
6665
  ...(!(f.additions || f.deletions) ? { marginLeft: 'auto' } : {}),
6322
- }, children: _jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "12", height: "12", fill: "currentColor", children: _jsx("path", { d: "M1.22 6.28a.749.749 0 0 1 0-1.06l3.5-3.5a.749.749 0 1 1 1.06 1.06L3.561 5h7.188l.001.007L10.749 5c.058 0 .116.007.171.019A4.501 4.501 0 0 1 10.5 14H8.796a.75.75 0 0 1 0-1.5H10.5a3 3 0 1 0 0-6H3.561L5.78 8.72a.749.749 0 1 1-1.06 1.06l-3.5-3.5Z" }) }) }))] }, f.path))) }))] })), _jsxs("div", { className: cm.surfaceSecondary, style: {
6666
+ }, children: _jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "12", height: "12", fill: "currentColor", children: _jsx("path", { d: "M1.22 6.28a.749.749 0 0 1 0-1.06l3.5-3.5a.749.749 0 1 1 1.06 1.06L3.561 5h7.188l.001.007L10.749 5c.058 0 .116.007.171.019A4.501 4.501 0 0 1 10.5 14H8.796a.75.75 0 0 1 0-1.5H10.5a3 3 0 1 0 0-6H3.561L5.78 8.72a.749.749 0 1 1-1.06 1.06l-3.5-3.5Z" }) }) }))] }, f.path))) }))] })), _jsxs("div", { className: cm.surfaceSecondary, ...(canEdit === false ? { 'data-mol-viewer': '' } : {}), style: {
6323
6667
  // Round the TOP corners only (8px) so the composer reads as a self-contained
6324
6668
  // input with its own border on all sides, flush at the bottom-left/right with
6325
6669
  // the panel's bottom edge. Discovery rounds ALL FOUR corners (the centered
@@ -6328,11 +6672,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6328
6672
  borderRadius: discovery ? 8 : '8px 8px 0 0',
6329
6673
  padding: '8px 10px',
6330
6674
  cursor: 'text',
6675
+ // View-only mode: the composer IS the team-chat box, so it wears the
6676
+ // team-message gold border (and the box is pre-filled with /teamsay).
6677
+ ...(canEdit === false ? { border: `1px solid ${NOTICE_TONE.gold.accent}` } : {}),
6331
6678
  }, onClick: (e) => {
6332
6679
  if (!e.target.closest('button')) {
6333
6680
  textareaRef.current?.focus();
6334
6681
  }
6335
- }, children: [_jsx("textarea", { ref: textareaRef, "data-mol-chat-input": "", defaultValue: inputRef.current, autoComplete: "off", onChange: handleInputChange, onPaste: handlePaste, placeholder: t('ide.chat.placeholder'), rows: 1, className: cm.textSize('sm'), style: {
6682
+ }, children: [_jsx("textarea", { ref: textareaRef, "data-mol-chat-input": "", defaultValue: inputRef.current, autoComplete: "off", onChange: handleInputChange, onPaste: handlePaste, placeholder: canEdit === false
6683
+ ? t('ide.chat.placeholderViewer', undefined, {
6684
+ defaultValue: 'Message your team',
6685
+ })
6686
+ : t('ide.chat.placeholder'), rows: 1, className: cm.textSize('sm'), style: {
6336
6687
  width: '100%',
6337
6688
  display: 'block',
6338
6689
  padding: 0,
@@ -6358,7 +6709,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6358
6709
  alignItems: 'center',
6359
6710
  marginTop: '6px',
6360
6711
  gap: '4px',
6361
- }, children: [_jsx("button", { type: "button", onClick: () => {
6712
+ }, children: [canEdit !== false && (_jsx("button", { type: "button", onClick: () => {
6362
6713
  const newMode = mode === 'plan' ? 'execute' : 'plan';
6363
6714
  setMode(newMode);
6364
6715
  http
@@ -6392,9 +6743,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6392
6743
  ? `1px solid ${isLight ? 'rgba(217,119,6,0.55)' : 'rgba(234,179,8,0.5)'}`
6393
6744
  : 'none',
6394
6745
  borderRadius: '3px',
6395
- cursor: 'pointer',
6746
+ cursor: canEdit ? 'pointer' : 'not-allowed',
6396
6747
  color: mode === 'plan' ? (isLight ? '#d97706' : '#eab308') : 'inherit',
6397
- opacity: mode === 'plan' ? 1 : 0.4,
6748
+ opacity: !canEdit ? 0.4 : mode === 'plan' ? 1 : 0.4,
6398
6749
  padding: 0,
6399
6750
  transition: 'opacity 100ms, color 100ms',
6400
6751
  }, onMouseEnter: (e) => {
@@ -6403,7 +6754,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6403
6754
  }, onMouseLeave: (e) => {
6404
6755
  if (mode !== 'plan')
6405
6756
  e.currentTarget.style.opacity = '0.4';
6406
- }, 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", onClick: () => {
6757
+ }, children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" }) }) })), fastModeAvailable && canEdit !== false && (_jsx("button", { type: "button", "data-mol-id": "chat-fast-mode-toggle", disabled: !canEdit, onClick: () => {
6407
6758
  const next = !fastMode;
6408
6759
  setFastMode(next);
6409
6760
  http
@@ -6468,7 +6819,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6468
6819
  }, onMouseLeave: (e) => {
6469
6820
  if (!isListening)
6470
6821
  e.currentTarget.style.opacity = '0.4';
6471
- }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "16", height: "16", style: { display: 'block' }, children: [_jsx("rect", { x: "6", y: "1", width: "4", height: "8", rx: "2", fill: isListening ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: "1.25" }), _jsx("path", { d: "M4 7v1a4 4 0 008 0V7", fill: "none", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "8", y1: "12", x2: "8", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "6", y1: "15", x2: "10", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" })] }) }), _jsx("button", { type: "button", onClick: () => fileInputRef.current?.click(), title: t('ide.chat.attachFile', undefined, { defaultValue: 'Attach file' }), style: {
6822
+ }, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "16", height: "16", style: { display: 'block' }, children: [_jsx("rect", { x: "6", y: "1", width: "4", height: "8", rx: "2", fill: isListening ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: "1.25" }), _jsx("path", { d: "M4 7v1a4 4 0 008 0V7", fill: "none", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "8", y1: "12", x2: "8", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" }), _jsx("line", { x1: "6", y1: "15", x2: "10", y2: "15", stroke: "currentColor", strokeWidth: "1.25", strokeLinecap: "round" })] }) }), canEdit !== false && (_jsx("button", { type: "button", onClick: () => fileInputRef.current?.click(), title: t('ide.chat.attachFile', undefined, { defaultValue: 'Attach file' }), style: {
6472
6823
  display: 'inline-flex',
6473
6824
  alignItems: 'center',
6474
6825
  justifyContent: 'center',
@@ -6486,7 +6837,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6486
6837
  e.currentTarget.style.opacity = '0.85';
6487
6838
  }, onMouseLeave: (e) => {
6488
6839
  e.currentTarget.style.opacity = '0.4';
6489
- }, children: _jsx("svg", { width: "15", height: "15", viewBox: "0 0 16 16", fill: "currentColor", style: { display: 'block' }, children: _jsx("path", { d: "M12.212 3.02a1.753 1.753 0 0 0-2.478.003l-5.83 5.83a3.007 3.007 0 0 0-.88 2.127c0 .795.315 1.551.88 2.116.567.567 1.333.89 2.126.89.79 0 1.548-.321 2.116-.89l5.48-5.48a.75.75 0 0 1 1.061 1.06l-5.48 5.48a4.492 4.492 0 0 1-3.177 1.33c-1.2 0-2.345-.487-3.187-1.33a4.483 4.483 0 0 1-1.32-3.177c0-1.195.475-2.341 1.32-3.186l5.83-5.83a3.25 3.25 0 0 1 5.553 2.297c0 .863-.343 1.691-.953 2.301L7.439 12.39c-.375.377-.884.59-1.416.593a1.998 1.998 0 0 1-1.412-.593 1.992 1.992 0 0 1 0-2.828l5.48-5.48a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-5.48 5.48a.492.492 0 0 0 0 .707.499.499 0 0 0 .352.154.51.51 0 0 0 .356-.154l5.833-5.827a1.755 1.755 0 0 0 0-2.481Z" }) }) }), [
6840
+ }, children: _jsx("svg", { width: "15", height: "15", viewBox: "0 0 16 16", fill: "currentColor", style: { display: 'block' }, children: _jsx("path", { d: "M12.212 3.02a1.753 1.753 0 0 0-2.478.003l-5.83 5.83a3.007 3.007 0 0 0-.88 2.127c0 .795.315 1.551.88 2.116.567.567 1.333.89 2.126.89.79 0 1.548-.321 2.116-.89l5.48-5.48a.75.75 0 0 1 1.061 1.06l-5.48 5.48a4.492 4.492 0 0 1-3.177 1.33c-1.2 0-2.345-.487-3.187-1.33a4.483 4.483 0 0 1-1.32-3.177c0-1.195.475-2.341 1.32-3.186l5.83-5.83a3.25 3.25 0 0 1 5.553 2.297c0 .863-.343 1.691-.953 2.301L7.439 12.39c-.375.377-.884.59-1.416.593a1.998 1.998 0 0 1-1.412-.593 1.992 1.992 0 0 1 0-2.828l5.48-5.48a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-5.48 5.48a.492.492 0 0 0 0 .707.499.499 0 0 0 .352.154.51.51 0 0 0 .356-.154l5.833-5.827a1.755 1.755 0 0 0 0-2.481Z" }) }) })), [
6490
6841
  {
6491
6842
  sym: '@',
6492
6843
  nudgeY: 0,
@@ -6515,34 +6866,38 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6515
6866
  defaultValue: 'Slash commands',
6516
6867
  }),
6517
6868
  onClick: () => {
6518
- const cur = inputRef.current;
6519
- if (cur === '/') {
6520
- // Toggle off remove the slash this button added
6521
- setInputValue('');
6522
- autoResize();
6869
+ // TOGGLE the menu regardless of the composer's current text —
6870
+ // never clobbering it. With text present the menu opens in
6871
+ // showAll mode (every command listed; picking one replaces the
6872
+ // input via executeCommand's prefill). An empty box still gets
6873
+ // the type-ahead '/' so filtering-by-typing works as before.
6874
+ if (commandMenu) {
6523
6875
  setCommandMenu(null);
6876
+ if (inputRef.current === '/') {
6877
+ // Remove the slash this button added on open.
6878
+ setInputValue('');
6879
+ autoResize();
6880
+ }
6524
6881
  setTimeout(() => {
6525
6882
  textareaRef.current?.focus();
6526
6883
  }, 0);
6884
+ return;
6527
6885
  }
6528
- else if (!cur) {
6529
- // Cursor lands AFTER the slash so the user can type the
6530
- // command immediately (or backspace to remove it).
6531
- // Popups are one-at-a-time — close siblings first.
6532
- setMicPicker(null);
6533
- setModelPicker(null);
6534
- setSoundsPicker(null);
6886
+ // Popups are one-at-a-time — close siblings first.
6887
+ setMicPicker(null);
6888
+ setModelPicker(null);
6889
+ setSoundsPicker(null);
6890
+ const cur = inputRef.current;
6891
+ if (!cur)
6535
6892
  setInputAndCursorEnd('/');
6536
- setCommandMenu({ selectedIdx: -1 });
6537
- }
6538
- else {
6539
- setTimeout(() => {
6540
- textareaRef.current?.focus();
6541
- }, 0);
6542
- }
6893
+ setCommandMenu({ selectedIdx: -1, showAll: cur !== '' && cur !== '/' });
6543
6894
  },
6544
6895
  },
6545
- ].map(({ sym, nudgeY, size: fontSize, title, onClick }) => (_jsx("button", { type: "button", onClick: onClick, title: title, style: {
6896
+ ]
6897
+ // Viewers keep the / shortcut (viewer-safe commands + /teamsay) but not @ —
6898
+ // file mentions only feed Synthase turns.
6899
+ .filter(({ sym }) => canEdit !== false || sym !== '@')
6900
+ .map(({ sym, nudgeY, size: fontSize, title, onClick }) => (_jsx("button", { type: "button", onClick: onClick, title: title, style: {
6546
6901
  display: 'inline-flex',
6547
6902
  alignItems: 'center',
6548
6903
  justifyContent: 'center',
@@ -6563,7 +6918,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6563
6918
  e.currentTarget.style.opacity = '0.85';
6564
6919
  }, onMouseLeave: (e) => {
6565
6920
  e.currentTarget.style.opacity = '0.4';
6566
- }, children: sym === 'slash' ? (_jsx("svg", { width: "9", height: "13", viewBox: "0 0 9 13", style: { display: 'block', position: 'relative', top: `${nudgeY}px` }, children: _jsx("line", { x1: "8", y1: "1", x2: "1", y2: "12", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) })) : (_jsx("span", { style: { position: 'relative', top: `${nudgeY}px` }, children: sym })) }, sym))), contextUsage &&
6921
+ }, children: sym === 'slash' ? (_jsx("svg", { width: "9", height: "13", viewBox: "0 0 9 13", style: { display: 'block', position: 'relative', top: `${nudgeY}px` }, children: _jsx("line", { x1: "8", y1: "1", x2: "1", y2: "12", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) })) : (_jsx("span", { style: { position: 'relative', top: `${nudgeY}px` }, children: sym })) }, sym))), canEdit !== false &&
6922
+ contextUsage &&
6567
6923
  (() => {
6568
6924
  // The ring represents usage toward the auto-compaction threshold,
6569
6925
  // not the raw context window. 100% = compaction will trigger.
@@ -6632,11 +6988,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6632
6988
  transition: 'opacity 150ms',
6633
6989
  }, children: [pct, "%"] }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, style: { display: 'block', transform: 'rotate(-90deg)', flexShrink: 0 }, children: [_jsx("circle", { cx: size / 2, cy: size / 2, r: r, fill: "none", stroke: "currentColor", strokeWidth: stroke, opacity: 0.15 }), _jsx("circle", { cx: size / 2, cy: size / 2, r: r, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${c}`, strokeDashoffset: `${dashOffset}`, strokeLinecap: "round" })] })] }));
6634
6990
  })(), _jsxs("div", { style: {
6635
- marginLeft: contextUsage ? '4px' : 'auto',
6991
+ // 'auto' pins the stop/send cluster right; the context ring
6992
+ // (hidden for viewers even when contextUsage exists) otherwise
6993
+ // carries the auto margin and this becomes a 4px gap.
6994
+ marginLeft: canEdit !== false && contextUsage ? '4px' : 'auto',
6636
6995
  display: 'flex',
6637
6996
  gap: '4px',
6638
6997
  alignItems: 'center',
6639
- }, children: [(isLoading || isRemoteStreaming) && (_jsx("button", { type: "button", onClick: handleAbort, title: t('ide.chat.stop', undefined, { defaultValue: 'Stop' }), onMouseEnter: (e) => {
6998
+ }, children: [canEdit !== false && (isLoading || isRemoteStreaming) && (_jsx("button", { type: "button", onClick: handleAbort, title: t('ide.chat.stop', undefined, { defaultValue: 'Stop' }), onMouseEnter: (e) => {
6640
6999
  e.currentTarget.style.background = 'rgba(248,81,73,0.3)';
6641
7000
  e.currentTarget.style.borderColor = 'rgba(248,81,73,0.65)';
6642
7001
  }, onMouseLeave: (e) => {
@@ -6690,7 +7049,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6690
7049
  }
6691
7050
  : undefined,
6692
7051
  });
6693
- } })), shareModal && (_jsx(ShareModal, { projectId: projectId, initialRole: shareModal.role, onClose: () => setShareModal(null), onCreated: (result) => {
7052
+ } })), shareModal && (_jsx(ShareModal, { projectId: projectId, initialRole: shareModal.role, canManage: shareAllowed, onClose: () => setShareModal(null), onCreated: (result) => {
6694
7053
  // Surface the created link in the timeline so it persists after the
6695
7054
  // modal closes — the role label and the public URL are both shown.
6696
7055
  addSystemCard(t('ide.chat.share.created', { role: result.role }, { defaultValue: 'Created a {{role}} share link.' }), {
@@ -6709,8 +7068,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6709
7068
  * @param props - Component props (see {@link MessageItemProps}).
6710
7069
  * @returns The rendered chat panel element.
6711
7070
  */
6712
- export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessageSent, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onActivityClick, onRenderError, onProfileClick, 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, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, className, }) {
7071
+ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessageSent, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, initialInputValue, hideConversationMenu, renderConversationHeader = true, conversationId: controlledConversationId, chatKey: controlledChatKey, onConversationId: controlledOnConversationId, openShareSignal: controlledShareSignal, openReportSignal: controlledReportSignal, openSettingsSignal: controlledSettingsSignal, onManageCustomModels, gitStatusTick, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, isPro, isAnonymous, canEdit = true, canShare, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, className, }) {
6713
7072
  const cm = getClassMap();
7073
+ // Share management may be gated ABOVE canEdit by the host (see
7074
+ // ChatPanelProps.canShare) — gates the built-in header share button here and
7075
+ // is threaded into ChatInner for the /share command + modal.
7076
+ const shareAllowed = canShare ?? canEdit !== false;
6714
7077
  const isNarrow = useNarrowViewport();
6715
7078
  const isCoarse = useCoarsePointer();
6716
7079
  const http = useHttpClient();
@@ -6863,11 +7226,11 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
6863
7226
  textOverflow: 'ellipsis',
6864
7227
  whiteSpace: 'nowrap',
6865
7228
  opacity: 0.7,
6866
- }, children: activeConv?.preview ?? 'Chat history' })] }), _jsx("button", { type: "button", "data-mol-id": "chat-share-button", onClick: () => setOpenShareSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.share.openShare', undefined, { defaultValue: 'Share project' }), "aria-label": t('ide.chat.share.openShare', undefined, {
7229
+ }, children: activeConv?.preview ?? 'Chat history' })] }), shareAllowed && (_jsx("button", { type: "button", "data-mol-id": "chat-share-button", onClick: () => setOpenShareSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.share.openShare', undefined, { defaultValue: 'Share project' }), "aria-label": t('ide.chat.share.openShare', undefined, {
6867
7230
  defaultValue: 'Share project',
6868
- }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "share", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", "data-mol-id": "chat-report-button", onClick: () => setOpenReportSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.report.openReport', undefined, { defaultValue: 'Report a bug' }), "aria-label": t('ide.chat.report.openReport', undefined, {
7231
+ }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "share", size: 14, "aria-hidden": "true" }) })), _jsx("button", { type: "button", "data-mol-id": "chat-report-button", onClick: () => setOpenReportSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.report.openReport', undefined, { defaultValue: 'Report a bug' }), "aria-label": t('ide.chat.report.openReport', undefined, {
6869
7232
  defaultValue: 'Report a bug',
6870
- }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "bug", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", "data-mol-id": "chat-settings-button", onClick: () => setOpenSettingsSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), "aria-label": t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "gear", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", onClick: handleNewChat, className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.newChat', undefined, { defaultValue: 'New chat' }), style: { flexShrink: 0 }, children: "+" }), showDropdown && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
7233
+ }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "bug", size: 14, "aria-hidden": "true" }) }), _jsx("button", { type: "button", "data-mol-id": "chat-settings-button", onClick: () => setOpenSettingsSignal((n) => n + 1), className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), "aria-label": t('ide.chat.openSettings', undefined, { defaultValue: 'Settings' }), style: { flexShrink: 0, display: 'inline-flex', alignItems: 'center' }, children: _jsx(Icon, { name: "gear", size: 14, "aria-hidden": "true" }) }), canEdit !== false && (_jsx("button", { type: "button", onClick: handleNewChat, className: cm.cn(cm.button({ variant: 'ghost', size: 'xs' }), cm.touchTarget), title: t('ide.chat.newChat', undefined, { defaultValue: 'New chat' }), style: { flexShrink: 0 }, children: "+" })), showDropdown && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
6871
7234
  position: 'absolute',
6872
7235
  top: '100%',
6873
7236
  left: 0,
@@ -6918,7 +7281,7 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
6918
7281
  textOverflow: 'ellipsis',
6919
7282
  whiteSpace: 'nowrap',
6920
7283
  width: '100%',
6921
- }, 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, 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, onReadyToBuild: onReadyToBuild, awaitingSandboxBoot: awaitingSandboxBoot, onClientAction: onClientAction, onTurnComplete: onTurnComplete, onLoadingChange: onLoadingChange, onNavigatePreview: onNavigatePreview, onRegisterPushHandler: onRegisterPushHandler, autoSubmitSignal: autoSubmitSignal, openSettingsSignal: effectiveSettingsSignal, onManageCustomModels: onManageCustomModels, openReportSignal: effectiveReportSignal, openShareSignal: effectiveShareSignal, initialInputValue: initialInputValue, pendingMessage: pendingMessage, pendingMessageKey: pendingMessageKey, pendingMessageSuppressUser: pendingMessageSuppressUser, pendingMessageUserInitiated: pendingMessageUserInitiated, userEditedFile: userEditedFile, userEditedFileKey: userEditedFileKey, gitStatusTick: gitStatusTick, discovery: hideConversationMenu, userAvatar: userAvatar, agentName: agentName, productName: productName, version: version, extraCommands: extraCommands, feedbackUrl: feedbackUrl }, chatKey)] }));
7284
+ }, children: conv.preview ?? 'New conversation' }), _jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { opacity: 0.55 }, children: relativeTime(conv.updatedAt) })] }, conv.id)))] }))] })), _jsx(ChatInner, { projectId: projectId, endpoint: chatEndpoint, initialMessage: initialMessage, onInitialMessageSent: onInitialMessageSent, isPro: isPro, isAnonymous: isAnonymous, canEdit: canEdit, canShare: shareAllowed, buildUpgradeCta: buildUpgradeCta, buildHelpUpgradeSection: buildHelpUpgradeSection, activeFile: activeFile, openTabs: openTabs, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: onFileRevert, onFileChange: onFileChange, onFileDeleted: onFileDeleted, onCommit: onCommit, onConversationId: reportConversationId, onActivityClick: onActivityClick, onRenderError: onRenderError, onProfileClick: onProfileClick, currentUserId: currentUserId, onReadyToBuild: onReadyToBuild, awaitingSandboxBoot: awaitingSandboxBoot, onClientAction: onClientAction, onTurnComplete: onTurnComplete, onLoadingChange: onLoadingChange, onNavigatePreview: onNavigatePreview, onRegisterPushHandler: onRegisterPushHandler, autoSubmitSignal: autoSubmitSignal, openSettingsSignal: effectiveSettingsSignal, onManageCustomModels: onManageCustomModels, openReportSignal: effectiveReportSignal, openShareSignal: effectiveShareSignal, initialInputValue: initialInputValue, pendingMessage: pendingMessage, pendingMessageKey: pendingMessageKey, pendingMessageSuppressUser: pendingMessageSuppressUser, pendingMessageUserInitiated: pendingMessageUserInitiated, userEditedFile: userEditedFile, userEditedFileKey: userEditedFileKey, gitStatusTick: gitStatusTick, discovery: hideConversationMenu, userAvatar: userAvatar, agentName: agentName, productName: productName, version: version, extraCommands: extraCommands, feedbackUrl: feedbackUrl }, chatKey)] }));
6922
7285
  }
6923
7286
  ChatPanel.displayName = 'ChatPanel';
6924
7287
  //# sourceMappingURL=ChatPanel.js.map