@molecule/app-ide-react 1.4.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.
Files changed (36) hide show
  1. package/README.md +100 -1
  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 +37 -1
  5. package/dist/command-metadata.js.map +1 -1
  6. package/dist/components/ChatPanel.d.ts +9 -1
  7. package/dist/components/ChatPanel.d.ts.map +1 -1
  8. package/dist/components/ChatPanel.js +199 -27
  9. package/dist/components/ChatPanel.js.map +1 -1
  10. package/dist/components/ShareLinkManager.d.ts +63 -0
  11. package/dist/components/ShareLinkManager.d.ts.map +1 -0
  12. package/dist/components/ShareLinkManager.js +162 -0
  13. package/dist/components/ShareLinkManager.js.map +1 -0
  14. package/dist/components/ShareModal.d.ts +7 -11
  15. package/dist/components/ShareModal.d.ts.map +1 -1
  16. package/dist/components/ShareModal.js +5 -99
  17. package/dist/components/ShareModal.js.map +1 -1
  18. package/dist/components/TipCard.d.ts +7 -2
  19. package/dist/components/TipCard.d.ts.map +1 -1
  20. package/dist/components/TipCard.js +8 -6
  21. package/dist/components/TipCard.js.map +1 -1
  22. package/dist/components/UserAvatar.d.ts +10 -1
  23. package/dist/components/UserAvatar.d.ts.map +1 -1
  24. package/dist/components/UserAvatar.js +9 -6
  25. package/dist/components/UserAvatar.js.map +1 -1
  26. package/dist/components/chat-share-utilities.d.ts +6 -0
  27. package/dist/components/chat-share-utilities.d.ts.map +1 -1
  28. package/dist/components/chat-share-utilities.js.map +1 -1
  29. package/dist/components/index.d.ts +1 -0
  30. package/dist/components/index.d.ts.map +1 -1
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/index.js.map +1 -1
  33. package/dist/types.d.ts +30 -1
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. 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';
@@ -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, 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
@@ -2521,6 +2595,31 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2521
2595
  setAutoCommitLoaded(true);
2522
2596
  });
2523
2597
  }, [http, projectId]);
2598
+ // Re-read ONLY the persisted model fields when the host signals a model change
2599
+ // (the provider modal's "Use" button sets chatModel server-side). Targeted so
2600
+ // a model pick never re-hydrates the rest of the settings (auto-commit,
2601
+ // skills, effort). Skips the initial 0 value — mount already read them.
2602
+ useEffect(() => {
2603
+ if (!modelSelectionSignal)
2604
+ return;
2605
+ http
2606
+ .get(`/projects/${projectId}`)
2607
+ .then((res) => {
2608
+ const s = res.data.settings;
2609
+ if (typeof s?.chatModel === 'string') {
2610
+ setCurrentModel(s.chatModel);
2611
+ setSavedChatModel(s.chatModel);
2612
+ }
2613
+ if (typeof s?.planModel === 'string')
2614
+ setPlanModel(s.planModel);
2615
+ if (typeof s?.executeModel === 'string')
2616
+ setExecuteModel(s.executeModel);
2617
+ })
2618
+ .catch(() => {
2619
+ // Non-fatal: the model was persisted server-side; the picker indicator
2620
+ // just won't refresh until the next full settings read.
2621
+ });
2622
+ }, [modelSelectionSignal, http, projectId]);
2524
2623
  // Persist the auto-commit cadence to project.settings (debounced) so it
2525
2624
  // survives a reload/reconnect like every other setting. The reducer is the
2526
2625
  // live source of truth; this mirrors intervalSeconds (0 = off) back to the
@@ -2566,7 +2665,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2566
2665
  return;
2567
2666
  removedModelNotifiedRef.current = currentModel;
2568
2667
  const removedId = currentModel;
2569
- 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;
2570
2677
  addSystemCard(fallback
2571
2678
  ? t('ide.chat.modelRemoved', { removed: removedId, fallback }, {
2572
2679
  defaultValue: 'Your selected model "{{removed}}" is no longer available. Switched to "{{fallback}}". Type /model to pick another.',
@@ -2755,10 +2862,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2755
2862
  useEffect(() => {
2756
2863
  if (initialMessage && !hasConversation && sentInitialRef.current !== initialMessage) {
2757
2864
  sentInitialRef.current = initialMessage;
2758
- 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);
2759
2869
  onInitialMessageSent?.();
2760
2870
  }
2761
- }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent]);
2871
+ }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent, extraCommands]);
2762
2872
  // ── Auto-send pending message (e.g. "Fix with AI", preview errors) ──────
2763
2873
  // Defers sending while the AI is streaming to avoid queueing up auto-fix
2764
2874
  // messages during active work. Messages are sent once streaming ends.
@@ -3434,6 +3544,20 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3434
3544
  setInputAndCursorEnd(`/${id} `);
3435
3545
  return;
3436
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
+ }
3437
3561
  if (id === 'clear') {
3438
3562
  setInputValue('');
3439
3563
  await clearHistory();
@@ -3779,6 +3903,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3779
3903
  refreshGitStatus,
3780
3904
  openPanelOverlay,
3781
3905
  extraCommandIds,
3906
+ canEdit,
3907
+ allCommands,
3908
+ t,
3782
3909
  ]);
3783
3910
  // When the auto-commit countdown reaches zero, fire the existing /commit path
3784
3911
  // (no new backend) and pause until the next file change re-arms it. /commit
@@ -3814,6 +3941,26 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3814
3941
  const trimmed = inputRef.current.trim();
3815
3942
  if (!trimmed && attachedFiles.length === 0)
3816
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
+ }
3817
3964
  // Handle /autofix toggle locally
3818
3965
  if (/^\/autofix$/i.test(trimmed)) {
3819
3966
  void executeCommand('autofix');
@@ -4132,6 +4279,16 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4132
4279
  sendMessage(prompt);
4133
4280
  return;
4134
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
+ }
4135
4292
  // Rewrite /explain with attachments into a proper prompt (attachments processed below)
4136
4293
  let message = trimmed;
4137
4294
  if (explainMatch) {
@@ -4178,7 +4335,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4178
4335
  setAttachedFiles([]);
4179
4336
  setAttachmentError(null);
4180
4337
  sendMessage(message, chatAttachments.length > 0 ? chatAttachments : undefined);
4181
- }, [attachedFiles, http, projectId, sendMessage, setInputValue, runSavedScript]);
4338
+ }, [attachedFiles, http, projectId, sendMessage, setInputValue, runSavedScript, allCommands]);
4182
4339
  // External auto-submit. When the signal changes, submit the current input —
4183
4340
  // used by the prompt → chat morph to send the prefilled prompt once the chat
4184
4341
  // has docked into place (handleSubmit clears the input as it sends).
@@ -4234,7 +4391,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4234
4391
  if (!q)
4235
4392
  return AVAILABLE_MODELS;
4236
4393
  return AVAILABLE_MODELS.filter((m) => m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q));
4237
- }, [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]);
4238
4400
  // ── Older models section ────────────────────────────────────────────────────
4239
4401
  // Deprecated entries fold into a collapsed "Older models ⌄" section under the
4240
4402
  // current models. The section auto-expands when the user's currentModel is in
@@ -4597,9 +4759,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4597
4759
  case 'model': {
4598
4760
  const label = cardEvent.label || cardEvent.model;
4599
4761
  // Append the model's effective processing-region code — catalog
4600
- // models only (a custom/BYO endpoint's region is unknowable here).
4762
+ // models only. A custom/BYO endpoint has no meaningful region: its
4763
+ // catalog `regions` defaults to ['us'] as a placeholder, not a real
4764
+ // re-host choice, so never surface a region for it.
4601
4765
  const def = AVAILABLE_MODELS.find((m) => m.id === cardEvent.model);
4602
- const regionCode = def ? effectiveModelRegion(def).toUpperCase() : null;
4766
+ const regionCode = def && def.provider !== 'custom' ? effectiveModelRegion(def).toUpperCase() : null;
4603
4767
  return {
4604
4768
  id,
4605
4769
  text: regionCode
@@ -4843,7 +5007,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4843
5007
  if (item.kind === 'activity')
4844
5008
  return (_jsx(ActivityCard, { activity: item.card.activity, onActivityClick: onActivityClick }, item.card.id));
4845
5009
  if (item.kind === 'tip')
4846
- 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));
4847
5011
  if (item.kind === 'system') {
4848
5012
  if (item.card.variant === 'settings') {
4849
5013
  // Legacy inline branch — kept so any 'settings' card persisted
@@ -4946,7 +5110,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4946
5110
  hash,
4947
5111
  }, onRevert: handleRevertCommit }, msg.id));
4948
5112
  }
4949
- 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));
4950
5122
  } }, item.kind === 'message' ? item.msg.id : item.card.id))), error &&
4951
5123
  !isStaleAnonymousLimit &&
4952
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 }))), (() => {
@@ -6331,7 +6503,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6331
6503
  alignItems: 'center',
6332
6504
  marginTop: '6px',
6333
6505
  gap: '4px',
6334
- }, children: [_jsx("button", { type: "button", onClick: () => {
6506
+ }, children: [_jsx("button", { type: "button", disabled: !canEdit, onClick: () => {
6335
6507
  const newMode = mode === 'plan' ? 'execute' : 'plan';
6336
6508
  setMode(newMode);
6337
6509
  http
@@ -6365,9 +6537,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6365
6537
  ? `1px solid ${isLight ? 'rgba(217,119,6,0.55)' : 'rgba(234,179,8,0.5)'}`
6366
6538
  : 'none',
6367
6539
  borderRadius: '3px',
6368
- cursor: 'pointer',
6540
+ cursor: canEdit ? 'pointer' : 'not-allowed',
6369
6541
  color: mode === 'plan' ? (isLight ? '#d97706' : '#eab308') : 'inherit',
6370
- opacity: mode === 'plan' ? 1 : 0.4,
6542
+ opacity: !canEdit ? 0.4 : mode === 'plan' ? 1 : 0.4,
6371
6543
  padding: 0,
6372
6544
  transition: 'opacity 100ms, color 100ms',
6373
6545
  }, onMouseEnter: (e) => {
@@ -6376,7 +6548,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6376
6548
  }, onMouseLeave: (e) => {
6377
6549
  if (mode !== 'plan')
6378
6550
  e.currentTarget.style.opacity = '0.4';
6379
- }, 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: () => {
6380
6552
  const next = !fastMode;
6381
6553
  setFastMode(next);
6382
6554
  http
@@ -6682,7 +6854,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6682
6854
  * @param props - Component props (see {@link MessageItemProps}).
6683
6855
  * @returns The rendered chat panel element.
6684
6856
  */
6685
- 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, }) {
6686
6858
  const cm = getClassMap();
6687
6859
  const isNarrow = useNarrowViewport();
6688
6860
  const isCoarse = useCoarsePointer();
@@ -6891,7 +7063,7 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
6891
7063
  textOverflow: 'ellipsis',
6892
7064
  whiteSpace: 'nowrap',
6893
7065
  width: '100%',
6894
- }, 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)] }));
6895
7067
  }
6896
7068
  ChatPanel.displayName = 'ChatPanel';
6897
7069
  //# sourceMappingURL=ChatPanel.js.map