@molecule/app-ide-react 1.5.0 → 1.6.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.
@@ -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';
@@ -928,7 +928,7 @@ export function CommitCardItem({ card, onRevert, }) {
928
928
  * @returns The rendered message item.
929
929
  */
930
930
  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;
931
+ const { msg, sendMessage, handleAskUserResponse, isLoading, streamingStatus, onNavigatePreview, undoneTcIds, handleUndoToggle, onFileOpen, onFileDoubleClick, onFileDiff, handleFileRevert, setInputAndCursorEnd, setModelPicker, userAvatar, onAvatarClick, discovery, buildUpgradeCta, agentName, } = props;
932
932
  const cm = getClassMap();
933
933
  const themeMode = useThemeMode();
934
934
  const isLight = themeMode === 'light';
@@ -936,6 +936,11 @@ const MessageItem = memo(function MessageItem(props) {
936
936
  // A message sent automatically on the user's behalf (e.g. an auto-fix prompt):
937
937
  // it has role 'user' but must NOT look like the user typed it (C2).
938
938
  const isAutomatic = msg.role === 'user' && !!msg.automatic;
939
+ // A human-only team note (side channel, e.g. /teamsay): renders like a user
940
+ // message — author header, time, plain content — but with the gold team-only
941
+ // accent + badge, and never the user message's blue stripe. `role` is 'system'
942
+ // (the model never sees it), so this is checked before isUser.
943
+ const isTeamNote = !!msg.teamOnly;
939
944
  // A real, user-typed message (the only one styled with the blue border + the
940
945
  // user's own avatar).
941
946
  const isUser = msg.role === 'user' && !isAutomatic;
@@ -949,7 +954,7 @@ const MessageItem = memo(function MessageItem(props) {
949
954
  const wrapperSpacing = {
950
955
  marginBottom: `${discovery ? TIMELINE_ITEM_GAP_DISCOVERY : TIMELINE_ITEM_GAP}px`,
951
956
  };
952
- return (_jsx("div", { style: wrapperSpacing, children: isUser || isAutomatic ? (_jsxs("div", { className:
957
+ return (_jsx("div", { style: wrapperSpacing, children: isUser || isAutomatic || isTeamNote ? (_jsxs("div", { className:
953
958
  // Auto-sent card matches the info cards' `xs` body; a real user message keeps
954
959
  // its slightly larger `sm` (it's a different kind of row, not an info card).
955
960
  isAutomatic ? cm.textSize('xs') : cm.cn(cm.surfaceSecondary, cm.textSize('sm')), style: {
@@ -962,7 +967,9 @@ const MessageItem = memo(function MessageItem(props) {
962
967
  // drawn by the `::before` injected via USER_ACCENT_STYLE + gated on the
963
968
  // data-mol-id below, and the user's full-size avatar. An auto-sent message
964
969
  // is instead a green (success) tinted card — same chrome as the info cards
965
- // — so it's unmistakably agent-sent, not user-typed (C2).
970
+ // — so it's unmistakably agent-sent, not user-typed (C2). A team note keeps
971
+ // the user-message look but swaps the blue stripe (its data-mol-id differs,
972
+ // so USER_ACCENT_STYLE never applies) for the gold team-only border.
966
973
  ...(isAutomatic
967
974
  ? chatCardStyle(AUTO_SENT_ACCENT)
968
975
  : {
@@ -971,14 +978,41 @@ const MessageItem = memo(function MessageItem(props) {
971
978
  paddingTop: '10px',
972
979
  paddingBottom: '10px',
973
980
  paddingRight: '10px',
981
+ ...(isTeamNote ? { border: `1px solid ${NOTICE_TONE.gold.accent}` } : {}),
974
982
  }),
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: {
983
+ }, "data-mol-id": isTeamNote
984
+ ? 'chat-team-message'
985
+ : isAutomatic
986
+ ? 'chat-automatic-message'
987
+ : '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
988
+ // An AUTHORED message shows that author's avatar — or their initial
989
+ // when they have none — NEVER the viewing user's picture (which made
990
+ // a teammate's avatar-less message wear the viewer's own face). Only
991
+ // an author-less message (the local optimistic echo, legacy rows) is
992
+ // the signed-in user's own and uses their avatar.
993
+ , {
994
+ // An AUTHORED message shows that author's avatar — or their initial
995
+ // when they have none — NEVER the viewing user's picture (which made
996
+ // a teammate's avatar-less message wear the viewer's own face). Only
997
+ // an author-less message (the local optimistic echo, legacy rows) is
998
+ // the signed-in user's own and uses their avatar.
999
+ 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
1000
  display: 'flex',
977
1001
  alignItems: 'baseline',
978
1002
  gap: 8,
979
1003
  lineHeight: 1.3,
980
1004
  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/')
1005
+ }, 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' }, {
1006
+ defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
1007
+ }), "aria-label": t('ide.chat.teamOnly.badge', { agentName: agentName ?? 'the assistant' }, {
1008
+ defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
1009
+ }), style: { display: 'inline-flex', alignSelf: 'center', flexShrink: 0 }, "data-mol-id": "chat-team-only-badge", children: _jsx(Icon
1010
+ // Same size as the username text (textSize('sm') ≈ 14px) so the
1011
+ // badge reads as part of the header line, not a footnote.
1012
+ , {
1013
+ // Same size as the username text (textSize('sm') ≈ 14px) so the
1014
+ // badge reads as part of the header line, not a footnote.
1015
+ name: "people", size: 14, "aria-hidden": "true", style: { color: NOTICE_TONE.gold.accent } }) })), typeof msg.timestamp === 'number' && (_jsx("span", { className: cm.textMuted, style: { fontSize: 11 }, children: relativeTimeLong(msg.timestamp) }))] })), _jsx(CollapsibleUserMessage, { content: msg.content, isLight: isLight }), msg.attachments && msg.attachments.length > 0 && (_jsx("div", { style: { marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }, children: msg.attachments.map((att, ai) => (_jsxs("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [att.mediaType.startsWith('image/')
982
1016
  ? '\uD83D\uDDBC\uFE0F'
983
1017
  : att.mediaType.startsWith('audio/')
984
1018
  ? '\uD83C\uDFB5'
@@ -1152,7 +1186,7 @@ const MessageItem = memo(function MessageItem(props) {
1152
1186
  * @param props - Component props (see {@link MessageItemProps}).
1153
1187
  * @returns The rendered chat inner component.
1154
1188
  */
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,
1189
+ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, canEdit, buildUpgradeCta, buildHelpUpgradeSection, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onConversationId, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, openSettingsSignal, onManageCustomModels, modelSelectionSignal, openReportSignal, openShareSignal, initialInputValue, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, gitStatusTick: externalGitStatusTick, discovery, userAvatar, agentName = DEFAULT_AGENT_NAME, productName = DEFAULT_PRODUCT_NAME, version, extraCommands,
1156
1190
  // feedbackUrl: prop kept for back-compat (callers still pass it), but no longer
1157
1191
  // consumed here — its only use was the command-menu footer link removed in P3-21.
1158
1192
  }) {
@@ -1171,10 +1205,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1171
1205
  // visible; 420px bounds it on small tablets.
1172
1206
  const popupMaxHeight = isNarrow ? 'min(50dvh, 420px)' : '70vh';
1173
1207
  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.
1208
+ // Bind the host's profile-click callback to the SIGNED-IN user's identity once.
1209
+ // Hosts open the *own*-profile surface from this, so it is only ever attached to
1210
+ // the signed-in user's OWN messages (see the per-message gate at the MessageItem
1211
+ // call site a teammate's avatar must never open the viewer's profile). Stable
1212
+ // so MessageItem's memo isn't broken; `undefined` when the host opts out, which
1213
+ // keeps every avatar non-interactive.
1178
1214
  const onUserAvatarClick = useMemo(() => onProfileClick
1179
1215
  ? () => onProfileClick({ avatar: userAvatar })
1180
1216
  : undefined, [onProfileClick, userAvatar]);
@@ -1235,6 +1271,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1235
1271
  // applied through handleStreamEvent) can append a card-message to the ONE message store.
1236
1272
  // This client's OWN `card` events are appended internally by useChat's stream handler.
1237
1273
  const appendCardMessageRef = useRef(() => { });
1274
+ // Ref to useChat's appendCompleteMessage — same contract as appendCardMessageRef, for a
1275
+ // teammate's broadcast `message` event (a complete non-streaming message, e.g. a
1276
+ // human-only team note). This client's OWN `message` events append internally in useChat.
1277
+ const appendCompleteMessageRef = useRef(() => { });
1238
1278
  // Kept current each render so handleStreamEvent (memoized) always calls the latest.
1239
1279
  const onReadyToBuildRef = useRef(onReadyToBuild);
1240
1280
  onReadyToBuildRef.current = onReadyToBuild;
@@ -1281,6 +1321,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1281
1321
  if (event.type === 'card' && applyingPushedRef.current) {
1282
1322
  appendCardMessageRef.current(event.id, event.timestamp, event.card);
1283
1323
  }
1324
+ // A teammate's broadcast complete message (e.g. a human-only team note) — append it
1325
+ // to the local store so it shows live for the collaborator too (de-duped by id;
1326
+ // never re-persisted — the originating server already persisted it). This client's
1327
+ // OWN `message` stream events are appended internally by useChat.
1328
+ if (event.type === 'message' && applyingPushedRef.current && event.message) {
1329
+ appendCompleteMessageRef.current(event.message);
1330
+ }
1284
1331
  // Captured outbound side effect (email/sms/push/webhook/channel) — push an
1285
1332
  // inline activity card into the timeline. Non-text card, mirroring how
1286
1333
  // system cards are appended.
@@ -1415,7 +1462,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1415
1462
  }
1416
1463
  onFileChange?.(path, content);
1417
1464
  }, [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({
1465
+ const { messages, isLoading, isRemoteStreaming, noteRemoteStreamEvent, error, errorMeta, mode, fastMode, streamingStatus, setMode, setFastMode, sendMessage, abort, clearHistory, editQueuedMessage, deleteQueuedMessage, clearQueuedForFile, appendCardMessage, appendCompleteMessage, retryCountdown, cancelRetry, } = useChat({
1419
1466
  endpoint,
1420
1467
  projectId,
1421
1468
  agentName,
@@ -1441,6 +1488,8 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1441
1488
  // Keep the card-append ref current so handleStreamEvent (memoized) can append a teammate's
1442
1489
  // broadcast `card` event to the message store.
1443
1490
  appendCardMessageRef.current = appendCardMessage;
1491
+ // Same for a teammate's broadcast complete `message` event (e.g. a team note).
1492
+ appendCompleteMessageRef.current = appendCompleteMessage;
1444
1493
  // User Stop. A stop is a user decision the platform must not overrule — so
1445
1494
  // beyond killing the stream (useChat.abort also records the stop client-side
1446
1495
  // and, via chat-abort's userInitiated flag, server-side), drop every pending
@@ -2303,6 +2352,31 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2303
2352
  const text = t(`ide.chat.tip.${ENTRY_TIP.id}`, { agentName }, { defaultValue: ENTRY_TIP.text });
2304
2353
  setTipCards((prev) => [...prev, { id: crypto.randomUUID(), text, timestamp: Date.now() }]);
2305
2354
  }, [conversationId, messages.length, agentName, discovery]);
2355
+ // Viewer orientation tip: a read-only member's explainer — GOLD like the
2356
+ // team-only messages and led by the same `people` icon it explains, so the
2357
+ // badge on a team note is self-describing. A regular dismissable tip (never a
2358
+ // permanent composer note); shown once per panel mount, skipped in discovery
2359
+ // like the other tips (mvp B1). Gated on an EXPLICIT canEdit === false so
2360
+ // hosts that don't do roles never see it.
2361
+ const viewerTipShownRef = useRef(false);
2362
+ useEffect(() => {
2363
+ if (canEdit !== false || discovery || viewerTipShownRef.current)
2364
+ return;
2365
+ viewerTipShownRef.current = true;
2366
+ const text = t('ide.chat.tip.viewerTeamOnly', { agentName }, {
2367
+ defaultValue: 'View-only access — read along and /teamsay the team. This gold icon marks team-only messages ({{agentName}} ignores them). Running the assistant and changing the model or settings need editor access.',
2368
+ });
2369
+ setTipCards((prev) => [
2370
+ ...prev,
2371
+ {
2372
+ id: crypto.randomUUID(),
2373
+ text,
2374
+ timestamp: Date.now(),
2375
+ accent: NOTICE_TONE.gold.accent,
2376
+ icon: 'people',
2377
+ },
2378
+ ]);
2379
+ }, [canEdit, discovery, agentName]);
2306
2380
  // Surface an occasional idle tip. This effect re-runs on every message change, so
2307
2381
  // the idle timer is continually reset by activity; it only fires once the
2308
2382
  // conversation has been quiet for TIP_IDLE_MS — and even then only when the
@@ -2591,7 +2665,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2591
2665
  return;
2592
2666
  removedModelNotifiedRef.current = currentModel;
2593
2667
  const removedId = currentModel;
2594
- const fallback = FREE_TIER_MODEL || AVAILABLE_MODELS[0]?.id;
2668
+ // Prefer a still-available model from the SAME custom provider before a
2669
+ // platform model: when a BYO provider's model is renamed (custom/<prov>/A →
2670
+ // custom/<prov>/B), keep the user on their own endpoint rather than bouncing
2671
+ // them to a platform free model.
2672
+ const sameProviderPrefix = removedId.match(/^(custom\/[^/]+\/)/)?.[1];
2673
+ const sameProviderModel = sameProviderPrefix
2674
+ ? AVAILABLE_MODELS.find((m) => m.id.startsWith(sameProviderPrefix))?.id
2675
+ : undefined;
2676
+ const fallback = sameProviderModel || FREE_TIER_MODEL || AVAILABLE_MODELS[0]?.id;
2595
2677
  addSystemCard(fallback
2596
2678
  ? t('ide.chat.modelRemoved', { removed: removedId, fallback }, {
2597
2679
  defaultValue: 'Your selected model "{{removed}}" is no longer available. Switched to "{{fallback}}". Type /model to pick another.',
@@ -2780,10 +2862,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2780
2862
  useEffect(() => {
2781
2863
  if (initialMessage && !hasConversation && sentInitialRef.current !== initialMessage) {
2782
2864
  sentInitialRef.current = initialMessage;
2783
- sendMessage(initialMessage);
2865
+ // Same rule as handleSubmit: a host side-channel command (e.g. /teamsay)
2866
+ // renders no optimistic echo — the server's `message` event is the message.
2867
+ const sideChannel = matchesSideChannelCommand(extraCommands?.length ? [...COMMANDS, ...extraCommands] : COMMANDS, initialMessage);
2868
+ sendMessage(initialMessage, undefined, sideChannel ? { suppressUserMessage: true } : undefined);
2784
2869
  onInitialMessageSent?.();
2785
2870
  }
2786
- }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent]);
2871
+ }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent, extraCommands]);
2787
2872
  // ── Auto-send pending message (e.g. "Fix with AI", preview errors) ──────
2788
2873
  // Defers sending while the AI is streaming to avoid queueing up auto-fix
2789
2874
  // messages during active work. Messages are sent once streaming ends.
@@ -3459,6 +3544,20 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3459
3544
  setInputAndCursorEnd(`/${id} `);
3460
3545
  return;
3461
3546
  }
3547
+ // A read-only VIEWER may run only viewerSafe commands (read / view-only /
3548
+ // per-user preference). Everything else writes shared project state or
3549
+ // triggers a Synthase turn the server 403s, so surface a read-only note
3550
+ // instead of a dead control that silently fails. Default-deny: an
3551
+ // unflagged command is unavailable to viewers.
3552
+ if (!canEdit) {
3553
+ const def = allCommands.find((c) => c.id === id);
3554
+ if (def && !def.viewerSafe) {
3555
+ addSystemCard(t('ide.chat.viewerReadOnlyCommand', undefined, {
3556
+ defaultValue: 'You have view-only access, so this command is unavailable. Ask an editor to make changes.',
3557
+ }));
3558
+ return;
3559
+ }
3560
+ }
3462
3561
  if (id === 'clear') {
3463
3562
  setInputValue('');
3464
3563
  await clearHistory();
@@ -3804,6 +3903,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3804
3903
  refreshGitStatus,
3805
3904
  openPanelOverlay,
3806
3905
  extraCommandIds,
3906
+ canEdit,
3907
+ allCommands,
3908
+ t,
3807
3909
  ]);
3808
3910
  // When the auto-commit countdown reaches zero, fire the existing /commit path
3809
3911
  // (no new backend) and pause until the next file change re-arms it. /commit
@@ -3839,6 +3941,26 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3839
3941
  const trimmed = inputRef.current.trim();
3840
3942
  if (!trimmed && attachedFiles.length === 0)
3841
3943
  return;
3944
+ // Read-only VIEWER guard. A viewer cannot run the assistant (the server 403s
3945
+ // the chat route) or any project-mutating command, so block a plain message
3946
+ // or a write-command here with a read-only note instead of a silent 403 —
3947
+ // but let team side-channel messages (/teamsay) and viewerSafe commands
3948
+ // through so viewers can still talk to the team and read. (Menu-dispatched
3949
+ // commands are gated in executeCommand; this covers composer typing.)
3950
+ if (!canEdit) {
3951
+ const token = trimmed.match(/^\/(\S+)/)?.[1]?.toLowerCase();
3952
+ const def = token
3953
+ ? allCommands.find((c) => c.id.toLowerCase() === token || c.aliases?.includes(token))
3954
+ : undefined;
3955
+ const allowed = matchesSideChannelCommand(allCommands, trimmed) || def?.viewerSafe === true;
3956
+ if (!allowed) {
3957
+ setInputValue('');
3958
+ addSystemCard(t('ide.chat.viewerReadOnly', undefined, {
3959
+ 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.",
3960
+ }));
3961
+ return;
3962
+ }
3963
+ }
3842
3964
  // Handle /autofix toggle locally
3843
3965
  if (/^\/autofix$/i.test(trimmed)) {
3844
3966
  void executeCommand('autofix');
@@ -4157,6 +4279,16 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4157
4279
  sendMessage(prompt);
4158
4280
  return;
4159
4281
  }
4282
+ // A host side-channel command (CommandDef.sideChannel, matched by id or alias — e.g.
4283
+ // molecule.dev's /teamsay + /t): send the raw text to the host's server intercept but
4284
+ // suppress the optimistic user bubble. The server emits the canonical `message` stream
4285
+ // event (persisted + broadcast to every member), which IS the visible message — so the
4286
+ // transcript never shows the literal "/command" text, and never shows it twice.
4287
+ if (matchesSideChannelCommand(allCommands, trimmed)) {
4288
+ setInputValue('');
4289
+ sendMessage(trimmed, undefined, { suppressUserMessage: true });
4290
+ return;
4291
+ }
4160
4292
  // Rewrite /explain with attachments into a proper prompt (attachments processed below)
4161
4293
  let message = trimmed;
4162
4294
  if (explainMatch) {
@@ -4203,7 +4335,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4203
4335
  setAttachedFiles([]);
4204
4336
  setAttachmentError(null);
4205
4337
  sendMessage(message, chatAttachments.length > 0 ? chatAttachments : undefined);
4206
- }, [attachedFiles, http, projectId, sendMessage, setInputValue, runSavedScript]);
4338
+ }, [attachedFiles, http, projectId, sendMessage, setInputValue, runSavedScript, allCommands]);
4207
4339
  // External auto-submit. When the signal changes, submit the current input —
4208
4340
  // used by the prompt → chat morph to send the prefilled prompt once the chat
4209
4341
  // has docked into place (handleSubmit clears the input as it sends).
@@ -4259,7 +4391,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4259
4391
  if (!q)
4260
4392
  return AVAILABLE_MODELS;
4261
4393
  return AVAILABLE_MODELS.filter((m) => m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q));
4262
- }, [modelPicker]);
4394
+ // AVAILABLE_MODELS MUST be a dep: when a custom provider is edited (a model
4395
+ // renamed/added, a URL changed) the catalog refreshes in place, and without
4396
+ // this the picker keeps rendering the pre-edit snapshot — the new model
4397
+ // never appears and can't be selected. (The query is read from inputRef and
4398
+ // stays fresh because each keystroke re-sets modelPicker.)
4399
+ }, [modelPicker, AVAILABLE_MODELS]);
4263
4400
  // ── Older models section ────────────────────────────────────────────────────
4264
4401
  // Deprecated entries fold into a collapsed "Older models ⌄" section under the
4265
4402
  // current models. The section auto-expands when the user's currentModel is in
@@ -4870,7 +5007,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4870
5007
  if (item.kind === 'activity')
4871
5008
  return (_jsx(ActivityCard, { activity: item.card.activity, onActivityClick: onActivityClick }, item.card.id));
4872
5009
  if (item.kind === 'tip')
4873
- return (_jsx(TipCard, { text: item.card.text, onDismiss: () => dismissTip(item.card.id) }, item.card.id));
5010
+ return (_jsx(TipCard, { text: item.card.text, accent: item.card.accent, icon: item.card.icon, onDismiss: () => dismissTip(item.card.id) }, item.card.id));
4874
5011
  if (item.kind === 'system') {
4875
5012
  if (item.card.variant === 'settings') {
4876
5013
  // Legacy inline branch — kept so any 'settings' card persisted
@@ -4973,7 +5110,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4973
5110
  hash,
4974
5111
  }, onRevert: handleRevertCommit }, msg.id));
4975
5112
  }
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));
5113
+ return (_jsx(MessageItem, { msg: msg, sendMessage: sendMessage, handleAskUserResponse: handleAskUserResponse, isLoading: isLoading, streamingStatus: streamingStatus, onNavigatePreview: onNavigatePreview, undoneTcIds: undoneTcIds, handleUndoToggle: handleUndoToggle, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, handleFileRevert: handleFileRevert, setInputAndCursorEnd: setInputAndCursorEnd, setModelPicker: setModelPicker, userAvatar: userAvatar,
5114
+ // Avatar clicks open the signed-in user's OWN profile, so only
5115
+ // their own messages get the handler: author-less ones (the
5116
+ // local echo, legacy solo rows) or an author matching
5117
+ // currentUserId. A teammate's avatar stays non-interactive —
5118
+ // clicking Test's face must not open Luke's profile editor.
5119
+ onAvatarClick: !msg.author?.id || (currentUserId != null && msg.author.id === currentUserId)
5120
+ ? onUserAvatarClick
5121
+ : undefined, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName }, msg.id));
4977
5122
  } }, item.kind === 'message' ? item.msg.id : item.card.id))), error &&
4978
5123
  !isStaleAnonymousLimit &&
4979
5124
  (errorMeta?.limitType ? (_jsx(ResourceLimitBanner, { message: error, action: buildUpgradeCta?.({ requiresSignup: errorMeta.requiresSignup }) })) : (_jsx("div", { className: cm.cn(cm.textSize('sm'), cm.sp('p', 2), cm.sp('mb', 2), cm.bgErrorSubtle, cm.textError), style: { borderRadius: '6px' }, children: error }))), (() => {
@@ -6358,7 +6503,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6358
6503
  alignItems: 'center',
6359
6504
  marginTop: '6px',
6360
6505
  gap: '4px',
6361
- }, children: [_jsx("button", { type: "button", onClick: () => {
6506
+ }, children: [_jsx("button", { type: "button", disabled: !canEdit, onClick: () => {
6362
6507
  const newMode = mode === 'plan' ? 'execute' : 'plan';
6363
6508
  setMode(newMode);
6364
6509
  http
@@ -6392,9 +6537,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6392
6537
  ? `1px solid ${isLight ? 'rgba(217,119,6,0.55)' : 'rgba(234,179,8,0.5)'}`
6393
6538
  : 'none',
6394
6539
  borderRadius: '3px',
6395
- cursor: 'pointer',
6540
+ cursor: canEdit ? 'pointer' : 'not-allowed',
6396
6541
  color: mode === 'plan' ? (isLight ? '#d97706' : '#eab308') : 'inherit',
6397
- opacity: mode === 'plan' ? 1 : 0.4,
6542
+ opacity: !canEdit ? 0.4 : mode === 'plan' ? 1 : 0.4,
6398
6543
  padding: 0,
6399
6544
  transition: 'opacity 100ms, color 100ms',
6400
6545
  }, onMouseEnter: (e) => {
@@ -6403,7 +6548,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6403
6548
  }, onMouseLeave: (e) => {
6404
6549
  if (mode !== 'plan')
6405
6550
  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: () => {
6551
+ }, children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" }) }) }), fastModeAvailable && (_jsx("button", { type: "button", "data-mol-id": "chat-fast-mode-toggle", disabled: !canEdit, onClick: () => {
6407
6552
  const next = !fastMode;
6408
6553
  setFastMode(next);
6409
6554
  http
@@ -6709,7 +6854,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6709
6854
  * @param props - Component props (see {@link MessageItemProps}).
6710
6855
  * @returns The rendered chat panel element.
6711
6856
  */
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, }) {
6857
+ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessageSent, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, initialInputValue, hideConversationMenu, renderConversationHeader = true, conversationId: controlledConversationId, chatKey: controlledChatKey, onConversationId: controlledOnConversationId, openShareSignal: controlledShareSignal, openReportSignal: controlledReportSignal, openSettingsSignal: controlledSettingsSignal, onManageCustomModels, gitStatusTick, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, isPro, isAnonymous, canEdit = true, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, className, }) {
6713
6858
  const cm = getClassMap();
6714
6859
  const isNarrow = useNarrowViewport();
6715
6860
  const isCoarse = useCoarsePointer();
@@ -6918,7 +7063,7 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
6918
7063
  textOverflow: 'ellipsis',
6919
7064
  whiteSpace: 'nowrap',
6920
7065
  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)] }));
7066
+ }, children: conv.preview ?? 'New conversation' }), _jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { opacity: 0.55 }, children: relativeTime(conv.updatedAt) })] }, conv.id)))] }))] })), _jsx(ChatInner, { projectId: projectId, endpoint: chatEndpoint, initialMessage: initialMessage, onInitialMessageSent: onInitialMessageSent, isPro: isPro, isAnonymous: isAnonymous, canEdit: canEdit, buildUpgradeCta: buildUpgradeCta, buildHelpUpgradeSection: buildHelpUpgradeSection, activeFile: activeFile, openTabs: openTabs, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: onFileRevert, onFileChange: onFileChange, onFileDeleted: onFileDeleted, onCommit: onCommit, onConversationId: reportConversationId, onActivityClick: onActivityClick, onRenderError: onRenderError, onProfileClick: onProfileClick, currentUserId: currentUserId, onReadyToBuild: onReadyToBuild, awaitingSandboxBoot: awaitingSandboxBoot, onClientAction: onClientAction, onTurnComplete: onTurnComplete, onLoadingChange: onLoadingChange, onNavigatePreview: onNavigatePreview, onRegisterPushHandler: onRegisterPushHandler, autoSubmitSignal: autoSubmitSignal, openSettingsSignal: effectiveSettingsSignal, onManageCustomModels: onManageCustomModels, openReportSignal: effectiveReportSignal, openShareSignal: effectiveShareSignal, initialInputValue: initialInputValue, pendingMessage: pendingMessage, pendingMessageKey: pendingMessageKey, pendingMessageSuppressUser: pendingMessageSuppressUser, pendingMessageUserInitiated: pendingMessageUserInitiated, userEditedFile: userEditedFile, userEditedFileKey: userEditedFileKey, gitStatusTick: gitStatusTick, discovery: hideConversationMenu, userAvatar: userAvatar, agentName: agentName, productName: productName, version: version, extraCommands: extraCommands, feedbackUrl: feedbackUrl }, chatKey)] }));
6922
7067
  }
6923
7068
  ChatPanel.displayName = 'ChatPanel';
6924
7069
  //# sourceMappingURL=ChatPanel.js.map