@molecule/app-ide-react 1.9.0 → 1.10.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.
@@ -19,7 +19,7 @@ import { CHAT_CARD_ICON_SIZE, chatCardBorder, chatCardStyle } from './chat-card-
19
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
- import { effortOptionsForModel, nativeEffortName, parseEffortCommand, resolveEffortArg, } from './chat-effort-utilities.js';
22
+ import { defaultEffortForModel, effortOptionsForModel, nativeEffortName, parseEffortCommand, resolveEffortArg, } from './chat-effort-utilities.js';
23
23
  import { buildHelpText } from './chat-help-utilities.js';
24
24
  import { effectiveModeModelId, freeTierLockReason, freeTierUsableMode, isModeModelLocked, modeSettingKey, parseModelModeCommand, resolveModeModel, } from './chat-model-mode-utilities.js';
25
25
  import { modelHasPeakPricing, modelPeakMultiplier, modelPeakWindowLabels, modelUsageRate, sortModels, } from './chat-models-utilities.js';
@@ -935,6 +935,36 @@ export function CommitCardItem({ card, onRevert, }) {
935
935
  return _jsx("div", { children: path }, path);
936
936
  }) }) }))] }) }));
937
937
  }
938
+ /**
939
+ * The clickable author-name in a message header (C5) — a reset button that
940
+ * renders exactly like the bold name span it replaces, with an underline on
941
+ * hover/focus as the affordance, and opens the author's profile like their
942
+ * avatar does. Its own component so the hover state never re-renders the
943
+ * (memoized, heavy) message row.
944
+ *
945
+ * @param props - Click handler + the name text.
946
+ * @param props.onClick - Opens the author's profile.
947
+ * @param props.children - The author's display name.
948
+ * @returns The name button.
949
+ */
950
+ function AuthorNameButton({ onClick, children, }) {
951
+ const [hover, setHover] = useState(false);
952
+ return (_jsx("button", { type: "button", onClick: onClick, onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), onFocus: () => setHover(true), onBlur: () => setHover(false), "data-mol-id": "chat-user-name-button",
953
+ // Full button reset so it is byte-identical to the former bold span at
954
+ // rest — the underline affordance is the only visual change on hover.
955
+ style: {
956
+ padding: 0,
957
+ margin: 0,
958
+ border: 'none',
959
+ background: 'transparent',
960
+ font: 'inherit',
961
+ color: 'inherit',
962
+ fontWeight: 600,
963
+ cursor: 'pointer',
964
+ textDecoration: hover ? 'underline' : 'none',
965
+ }, children: children }));
966
+ }
967
+ AuthorNameButton.displayName = 'AuthorNameButton';
938
968
  /**
939
969
  * Renders a single message (user or assistant) in the chat timeline.
940
970
  * Wrapped in React.memo so unchanged messages skip re-rendering when
@@ -943,7 +973,7 @@ export function CommitCardItem({ card, onRevert, }) {
943
973
  * @returns The rendered message item.
944
974
  */
945
975
  const MessageItem = memo(function MessageItem(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;
976
+ const { msg, sendMessage, handleAskUserResponse, isLoading, streamingStatus, onNavigatePreview, undoneTcIds, handleUndoToggle, onFileOpen, onFileDoubleClick, onFileDiff, handleFileRevert, setInputAndCursorEnd, setModelPicker, chatMode, userAvatar, onProfileClick, currentUserId, discovery, buildUpgradeCta, agentName, canEdit, } = props;
947
977
  const cm = getClassMap();
948
978
  const themeMode = useThemeMode();
949
979
  const isLight = themeMode === 'light';
@@ -959,6 +989,15 @@ const MessageItem = memo(function MessageItem(props) {
959
989
  // A real, user-typed message (the only one styled with the blue border + the
960
990
  // user's own avatar).
961
991
  const isUser = msg.role === 'user' && !isAutomatic;
992
+ // Who this message belongs to — the identity a profile click hands the host.
993
+ // An AUTHORED message is that author's (a teammate's click opens THEIR
994
+ // profile, never the viewer's); an author-less one (the local optimistic
995
+ // echo, legacy solo rows) is the signed-in user's own, stamped with
996
+ // currentUserId + the viewer's avatar so the host still knows who it is.
997
+ const authorIdentity = msg.author
998
+ ? { id: msg.author.id, name: msg.author.name, avatar: msg.author.avatar ?? null }
999
+ : { id: currentUserId, avatar: userAvatar ?? null };
1000
+ const openAuthorProfile = onProfileClick ? () => onProfileClick(authorIdentity) : undefined;
962
1001
  // Spacing follows the one timeline convention (see TIMELINE_ITEM_GAP above): a
963
1002
  // single bottom margin, no top margin, no negatives — so a message can never
964
1003
  // pull itself up over the previous item's spacing. Discovery is roomier but
@@ -980,11 +1019,16 @@ const MessageItem = memo(function MessageItem(props) {
980
1019
  // A real user message keeps its classic look: a gray surface with the
981
1020
  // molecule brand's ANIMATED blue gradient stripe (3px) on the left edge,
982
1021
  // drawn by the `::before` injected via USER_ACCENT_STYLE + gated on the
983
- // data-mol-id below, and the user's full-size avatar. An auto-sent message
984
- // is instead a green (success) tinted card — same chrome as the info cards
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.
1022
+ // data-mol-id below, PLUS a solid blue outline ring (host-tunable via
1023
+ // `--mol-chat-accent-border`) so a sent-to-Synthase message reads bordered
1024
+ // the same way a team note does, and the user's full-size avatar. An
1025
+ // auto-sent message is instead a green (success) tinted card same chrome
1026
+ // as the info cards so it's unmistakably agent-sent, not user-typed (C2).
1027
+ // A team note keeps the user-message look but swaps the blue chrome (its
1028
+ // data-mol-id differs, so USER_ACCENT_STYLE never applies) for the gold
1029
+ // team-only border — with its own solid 3px left band (an inset box-shadow,
1030
+ // which starts at the border's inner edge exactly like the stripe's
1031
+ // `inset: 0` ::before, so the two rows' left accents match in geometry).
988
1032
  ...(isAutomatic
989
1033
  ? chatCardStyle(AUTO_SENT_ACCENT)
990
1034
  : {
@@ -993,7 +1037,12 @@ const MessageItem = memo(function MessageItem(props) {
993
1037
  paddingTop: '10px',
994
1038
  paddingBottom: '10px',
995
1039
  paddingRight: '10px',
996
- ...(isTeamNote ? { border: `1px solid ${NOTICE_TONE.gold.accent}` } : {}),
1040
+ border: isTeamNote
1041
+ ? `1px solid ${NOTICE_TONE.gold.accent}`
1042
+ : '1px solid var(--mol-chat-accent-border, var(--mol-color-primary, #3060c0))',
1043
+ ...(isTeamNote
1044
+ ? { boxShadow: `inset 3px 0 0 0 ${NOTICE_TONE.gold.accent}` }
1045
+ : {}),
997
1046
  }),
998
1047
  }, "data-mol-id": isTeamNote
999
1048
  ? 'chat-team-message'
@@ -1004,20 +1053,24 @@ const MessageItem = memo(function MessageItem(props) {
1004
1053
  // when they have none — NEVER the viewing user's picture (which made
1005
1054
  // a teammate's avatar-less message wear the viewer's own face). Only
1006
1055
  // an author-less message (the local optimistic echo, legacy rows) is
1007
- // the signed-in user's own and uses their avatar.
1056
+ // the signed-in user's own and uses their avatar. Clicking opens the
1057
+ // AUTHOR's profile (own and teammate alike — the host decides which
1058
+ // surface from the identity's id).
1008
1059
  , {
1009
1060
  // An AUTHORED message shows that author's avatar — or their initial
1010
1061
  // when they have none — NEVER the viewing user's picture (which made
1011
1062
  // a teammate's avatar-less message wear the viewer's own face). Only
1012
1063
  // 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: {
1064
+ // the signed-in user's own and uses their avatar. Clicking opens the
1065
+ // AUTHOR's profile (own and teammate alike the host decides which
1066
+ // surface from the identity's id).
1067
+ userAvatar: authorIdentity.avatar, name: msg.author?.name ?? undefined, size: 36, onClick: openAuthorProfile })), _jsxs("div", { style: { flex: 1, minWidth: 0, marginTop: 1 }, children: [isAutomatic ? null : (_jsxs("div", { style: {
1015
1068
  display: 'flex',
1016
1069
  alignItems: 'baseline',
1017
1070
  gap: 8,
1018
1071
  lineHeight: 1.3,
1019
1072
  marginBottom: 1,
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' }, {
1073
+ }, children: [openAuthorProfile ? (_jsx(AuthorNameButton, { onClick: openAuthorProfile, children: msg.author?.name ?? t('ide.chat.you', undefined, { defaultValue: 'You' }) })) : (_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
1074
  defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
1022
1075
  }), "aria-label": t('ide.chat.teamOnly.badge', { agentName: agentName ?? 'the assistant' }, {
1023
1076
  defaultValue: 'Team only — visible to your team; {{agentName}} will ignore it',
@@ -1241,15 +1294,6 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1241
1294
  // visible; 420px bounds it on small tablets.
1242
1295
  const popupMaxHeight = isNarrow ? 'min(50dvh, 420px)' : '70vh';
1243
1296
  const http = useHttpClient();
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.
1250
- const onUserAvatarClick = useMemo(() => onProfileClick
1251
- ? () => onProfileClick({ avatar: userAvatar })
1252
- : undefined, [onProfileClick, userAvatar]);
1253
1297
  // If there's already a conversation (conversationId in the URL), always load
1254
1298
  // history — even when initialMessage is set. This prevents a refresh from
1255
1299
  // re-sending the initial prompt instead of restoring the existing conversation.
@@ -1265,19 +1309,39 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1265
1309
  const soundsConfigRef = useRef({ ...DEFAULT_SOUNDS_CONFIG });
1266
1310
  // ── Context usage tracking (ring indicator) ─────────────────────────────
1267
1311
  const [contextUsage, setContextUsage] = useState(null);
1268
- // Restore context usage from the history endpoint on mount
1312
+ // Restore context usage from the history endpoint on mount. Retries a few
1313
+ // times on failure: this used to be one-shot, so a single transient error at
1314
+ // mount (an API deploy restart, a rate-limit blip) silently cost the context
1315
+ // ring for the entire session — the state only ever refills from the next
1316
+ // turn's `done` event (observed live 2026-08-29: ring gone, server data fine,
1317
+ // reload fixed it).
1269
1318
  useEffect(() => {
1270
1319
  if (!hasConversation)
1271
1320
  return;
1272
- http
1273
- .get(endpoint)
1274
- .then((res) => {
1275
- if (res.data.contextUsage)
1276
- setContextUsage(res.data.contextUsage);
1277
- })
1278
- .catch(() => {
1279
- /* ignore */
1280
- });
1321
+ let cancelled = false;
1322
+ let attempt = 0;
1323
+ const load = () => {
1324
+ http
1325
+ .get(endpoint)
1326
+ .then((res) => {
1327
+ if (cancelled)
1328
+ return;
1329
+ if (res.data.contextUsage)
1330
+ setContextUsage(res.data.contextUsage);
1331
+ })
1332
+ .catch((_error) => {
1333
+ // Transient failure — retry with backoff (bounded); after the budget,
1334
+ // the next turn's done event repopulates the ring.
1335
+ if (cancelled || attempt >= 3)
1336
+ return;
1337
+ attempt++;
1338
+ setTimeout(load, attempt * 4000);
1339
+ });
1340
+ };
1341
+ load();
1342
+ return () => {
1343
+ cancelled = true;
1344
+ };
1281
1345
  }, [endpoint, hasConversation, http]);
1282
1346
  // ── Auto-fix countdown state ──────────────────────────────────────────────
1283
1347
  // After the AI finishes, if verification found errors, show a countdown
@@ -1438,6 +1502,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1438
1502
  const sendMessageRef = useRef(() => { });
1439
1503
  useEffect(() => {
1440
1504
  if (autoFixCountdown && autoFixCountdown.secondsLeft === 0 && !autoFixCountdown.paused) {
1505
+ // Defense-in-depth: arming is sender-only (a viewer never owns a stream),
1506
+ // but an autonomous agent-turn dispatch must never fire from a read-only
1507
+ // client regardless of how the countdown came to exist.
1508
+ if (canEdit === false) {
1509
+ setAutoFixCountdown(null);
1510
+ return;
1511
+ }
1441
1512
  const msg = `Fix these issues:\n\n${autoFixCountdown.output}`;
1442
1513
  setAutoFixCountdown(null);
1443
1514
  // Auto-sent on the user's behalf — flag it so the chat renders it in the
@@ -1445,7 +1516,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1445
1516
  // user typed it (C2).
1446
1517
  sendMessageRef.current(msg, undefined, { automatic: true });
1447
1518
  }
1448
- }, [autoFixCountdown]);
1519
+ }, [autoFixCountdown, canEdit]);
1449
1520
  // Auto-pause countdown when user starts typing
1450
1521
  const handleAutoFixPauseOnInput = useCallback(() => {
1451
1522
  setAutoFixCountdown((prev) => (prev && !prev.paused ? { ...prev, paused: true } : prev));
@@ -1463,6 +1534,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1463
1534
  // overwrite it), and `…PersistedRef` tracks the value we believe is on the
1464
1535
  // server so we only PATCH genuine changes (never the value we just hydrated).
1465
1536
  const [autoCommitLoaded, setAutoCommitLoaded] = useState(false);
1537
+ // Live canEdit for async callbacks (the settings hydrate) that must not
1538
+ // capture a stale role from mount time.
1539
+ const canEditRef = useRef(canEdit);
1540
+ canEditRef.current = canEdit;
1466
1541
  const autoCommitPersistedRef = useRef(0);
1467
1542
  const autoCommitPatchTimerRef = useRef(null);
1468
1543
  // Set after debouncedFetchPendingFiles is defined below (it depends on state
@@ -1919,6 +1994,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1919
1994
  setIsListening(false);
1920
1995
  setCommandMenu(null);
1921
1996
  setModelPicker(null);
1997
+ setEffortPicker(null);
1922
1998
  setSoundsPicker(null);
1923
1999
  setFilePicker(null);
1924
2000
  setPanelOverlay(null);
@@ -2115,6 +2191,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2115
2191
  // Popups are one-at-a-time — close any sibling before opening the picker
2116
2192
  setCommandMenu(null);
2117
2193
  setModelPicker(null);
2194
+ setEffortPicker(null);
2118
2195
  setSoundsPicker(null);
2119
2196
  setFilePicker(null);
2120
2197
  setPanelOverlay(null);
@@ -2155,6 +2232,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2155
2232
  const [commandMenu, setCommandMenu] = useState(null);
2156
2233
  // ── Model picker (shown when typing /model <filter>) ──────────────────────
2157
2234
  const [modelPicker, setModelPicker] = useState(null);
2235
+ const [effortPicker, setEffortPicker] = useState(null);
2158
2236
  // ── System cards (persistent inline notifications in chat history) ────────
2159
2237
  const [systemCards, setSystemCards] = useState([]);
2160
2238
  // Bug-report modal — `{ title }` (seed) when open, null when closed. Opened by
@@ -2519,6 +2597,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2519
2597
  // Close any sibling popup so overlays never stack (matches how the model
2520
2598
  // picker / sounds picker each own this region exclusively).
2521
2599
  setModelPicker(null);
2600
+ setEffortPicker(null);
2522
2601
  setSoundsPicker(null);
2523
2602
  setCommandMenu(null);
2524
2603
  setPanelOverlayQuery(query);
@@ -2677,11 +2756,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2677
2756
  // Restore the persisted auto-commit cadence in the paused state (it
2678
2757
  // re-arms on the next file change). Auto-commit is ON by default: a
2679
2758
  // project that never set `autoCommitSeconds` resolves to the default
2680
- // cadence; an explicit 0 (the user turned it off) stays off.
2759
+ // cadence; an explicit 0 (the user turned it off) stays off. Never for
2760
+ // a read-only VIEWER: /commit is an editor action, so arming the
2761
+ // countdown on a viewer's client only produces a doomed dispatch (and
2762
+ // the denial card it minted) when it lapses.
2681
2763
  const savedAutoCommit = resolveAutoCommitSeconds(s?.autoCommitSeconds);
2682
2764
  autoCommitPersistedRef.current = savedAutoCommit;
2683
- if (savedAutoCommit > 0)
2765
+ if (savedAutoCommit > 0 && canEditRef.current !== false) {
2684
2766
  dispatchAutoCommit({ type: 'hydrate', seconds: savedAutoCommit });
2767
+ }
2685
2768
  setAutoCommitLoaded(true);
2686
2769
  })
2687
2770
  .catch(() => {
@@ -2764,6 +2847,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2764
2847
  useEffect(() => {
2765
2848
  if (!autoCommitLoaded)
2766
2849
  return;
2850
+ // A viewer never mirrors auto-commit state back to the project — the PATCH
2851
+ // is editor-gated server-side and their reducer stays disarmed anyway.
2852
+ if (canEdit === false)
2853
+ return;
2767
2854
  const seconds = autoCommit.intervalSeconds;
2768
2855
  if (autoCommitPersistedRef.current === seconds)
2769
2856
  return;
@@ -2782,7 +2869,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2782
2869
  if (autoCommitPatchTimerRef.current)
2783
2870
  clearTimeout(autoCommitPatchTimerRef.current);
2784
2871
  };
2785
- }, [autoCommit.intervalSeconds, autoCommitLoaded, http, projectId]);
2872
+ }, [autoCommit.intervalSeconds, autoCommitLoaded, http, projectId, canEdit]);
2786
2873
  // ── Removed-model recovery ──────────────────────────────────────────────────
2787
2874
  // If the saved chatModel is no longer in the catalog (a provider retired it,
2788
2875
  // or we pruned the entry), notify once and fall back to the free-tier model.
@@ -3004,13 +3091,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3004
3091
  useEffect(() => {
3005
3092
  if (initialMessage && !hasConversation && sentInitialRef.current !== initialMessage) {
3006
3093
  sentInitialRef.current = initialMessage;
3007
- // Same rule as handleSubmit: a host side-channel command (e.g. /teamsay)
3008
- // renders no optimistic echo the server's `message` event is the message.
3094
+ // A read-only VIEWER never auto-sends the initial prompt: the host sources
3095
+ // it from PROJECT-keyed localStorage, which survives a role demotion and a
3096
+ // shared browser — a stale entry would auto-POST an agent turn the server
3097
+ // 403s, surfacing a spurious red error on mount. Consume the ref (above)
3098
+ // so it is never retried either.
3099
+ if (canEdit === false)
3100
+ return;
3009
3101
  const sideChannel = matchesSideChannelCommand(extraCommands?.length ? [...COMMANDS, ...extraCommands] : COMMANDS, initialMessage);
3010
3102
  sendMessage(initialMessage, undefined, sideChannel ? { suppressUserMessage: true } : undefined);
3011
3103
  onInitialMessageSent?.();
3012
3104
  }
3013
- }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent, extraCommands]);
3105
+ }, [initialMessage, hasConversation, sendMessage, onInitialMessageSent, extraCommands, canEdit]);
3014
3106
  // ── Auto-send pending message (e.g. "Fix with AI", preview errors) ──────
3015
3107
  // Defers sending while the AI is streaming to avoid queueing up auto-fix
3016
3108
  // messages during active work. Messages are sent once streaming ends.
@@ -3591,6 +3683,23 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3591
3683
  return;
3592
3684
  }
3593
3685
  setModelPicker(null);
3686
+ // Show the effort picker while typing "/effort ..." — same live behavior
3687
+ // as /model. Text after the command (minus the --plan/--execute flag)
3688
+ // filters the level list; "?" keeps the textual status path instead. A
3689
+ // dropdown-chosen mode survives further keystrokes unless a flag names one.
3690
+ if (/^\/effort\s/i.test(val)) {
3691
+ const typed = parseEffortCommand(val);
3692
+ if (typed && (typed.kind === 'menu' || (typed.kind === 'set' && typed.arg !== '?'))) {
3693
+ const typedMode = typed.mode;
3694
+ setEffortPicker((p) => ({
3695
+ selectedIdx: -1,
3696
+ mode: typedMode ?? p?.mode ?? liveModelMode,
3697
+ }));
3698
+ setCommandMenu(null);
3699
+ return;
3700
+ }
3701
+ }
3702
+ setEffortPicker(null);
3594
3703
  if (val.startsWith('/') && !val.includes(' ')) {
3595
3704
  setCommandMenu({ selectedIdx: -1 });
3596
3705
  }
@@ -3686,6 +3795,29 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3686
3795
  logger.warn('Failed to update model processing region', { error });
3687
3796
  }
3688
3797
  }, [http, projectId, modelRegions]);
3798
+ // Persist a mode's reasoning effort (used by both the /effort <level> typed
3799
+ // path and the effort picker). Shallow-merge on the server means we send the
3800
+ // WHOLE effortByMode map, not just the changed entry.
3801
+ const applyEffortLevel = useCallback(async (targetMode, level, targetModel) => {
3802
+ setEffortPicker(null);
3803
+ setInputValue('');
3804
+ try {
3805
+ const nextByMode = { ...effortByMode, [targetMode]: level };
3806
+ await http.patch(settingsPatchUrl(), { settings: { effortByMode: nextByMode } });
3807
+ setEffortByMode(nextByMode);
3808
+ addSystemCard(t('ide.chat.effort.setMode', {
3809
+ mode: targetMode,
3810
+ level: nativeEffortName(targetModel, level) ?? level,
3811
+ model: targetModel?.label ?? '?',
3812
+ }, { defaultValue: 'Reasoning effort for {{mode}} set to {{level}} ({{model}}).' }));
3813
+ }
3814
+ catch (error) {
3815
+ logger.warn('Failed to update reasoning effort level', { error });
3816
+ addSystemCard(t('ide.chat.effort.error', undefined, {
3817
+ defaultValue: 'Failed to update reasoning effort.',
3818
+ }));
3819
+ }
3820
+ }, [http, effortByMode, settingsPatchUrl, addSystemCard]);
3689
3821
  // The shared command registry UNION any host-provided commands (e.g.
3690
3822
  // molecule.dev's /deploy, /push, /invite, /teamsay), so the menu, grouping,
3691
3823
  // and dispatch all see one list and host commands never go missing. Declared
@@ -3694,11 +3826,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3694
3826
  const extraCommandIds = useMemo(() => new Set((extraCommands ?? []).map((c) => c.id)), [extraCommands]);
3695
3827
  const executeCommand = useCallback(async (id) => {
3696
3828
  setCommandMenu(null);
3697
- // Any command closes every sibling popup (panel overlay, model/sounds/mic
3698
- // pickers) so popups are strictly one-at-a-time — the branches below
3699
- // re-open their own.
3829
+ // Any command closes every sibling popup (panel overlay, model/effort/
3830
+ // sounds/mic pickers) so popups are strictly one-at-a-time — the branches
3831
+ // below re-open their own.
3700
3832
  setPanelOverlay(null);
3701
3833
  setModelPicker(null);
3834
+ setEffortPicker(null);
3702
3835
  setSoundsPicker(null);
3703
3836
  setMicPicker(null);
3704
3837
  // Host-provided commands (e.g. molecule.dev's /deploy, /push, /invite,
@@ -3743,8 +3876,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3743
3876
  setInputAndCursorEnd('/maxloops ');
3744
3877
  }
3745
3878
  else if (id === 'effort') {
3746
- // Prefill so the user types a level; /effort ? (or bare) shows status.
3747
- setInputAndCursorEnd('/effort ');
3879
+ // Open the selectable level picker (mirrors /model) scoped to the live
3880
+ // conversation mode; its mode dropdown re-scopes in place.
3881
+ setInputValue('');
3882
+ setEffortPicker({ selectedIdx: -1, mode: liveModelMode });
3748
3883
  }
3749
3884
  else if (id === 'autocommit') {
3750
3885
  // Prefill so the user types the cadence (seconds); 0 cancels.
@@ -4084,11 +4219,17 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4084
4219
  // the exact render the countdown hits zero): never commit mid-turn — the
4085
4220
  // effect re-runs when the hold clears and fires then.
4086
4221
  useEffect(() => {
4087
- if (!isAutoCommitDue(autoCommit) || autoCommitHeld)
4222
+ // A read-only VIEWER never fires auto-commit: /commit is an editor action,
4223
+ // and the client-side dispatch here surfaced the "view-only access, so this
4224
+ // command is unavailable" denial card out of nowhere on viewers whenever a
4225
+ // synced countdown lapsed (observed 2026-08-31, right after a watched turn
4226
+ // settled and released the hold). The hydrate below is also viewer-gated,
4227
+ // so this is the belt for a mid-session demotion.
4228
+ if (canEdit === false || !isAutoCommitDue(autoCommit) || autoCommitHeld)
4088
4229
  return;
4089
4230
  void executeCommand('commit');
4090
4231
  dispatchAutoCommit({ type: 'fired' });
4091
- }, [autoCommit, autoCommitHeld, executeCommand]);
4232
+ }, [autoCommit, autoCommitHeld, executeCommand, canEdit]);
4092
4233
  // ── Submit ─────────────────────────────────────────────────────────────────
4093
4234
  const handleSubmit = useCallback(async () => {
4094
4235
  // Stop voice recognition on submit
@@ -4255,6 +4396,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4255
4396
  setPanelOverlay(null);
4256
4397
  setCommandMenu(null);
4257
4398
  setModelPicker(null);
4399
+ setEffortPicker(null);
4258
4400
  setSoundsPicker(null);
4259
4401
  setMicPicker({ autoStart: false });
4260
4402
  return;
@@ -4304,6 +4446,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4304
4446
  const effortCmd = parseEffortCommand(trimmed);
4305
4447
  if (effortCmd) {
4306
4448
  setInputValue('');
4449
+ // Bare /effort (or a bare --plan/--execute flag) opens the selectable
4450
+ // level picker — same interaction as bare /model. The picker lists the
4451
+ // target mode's model's own levels; its mode dropdown re-scopes in place.
4452
+ if (effortCmd.kind === 'menu') {
4453
+ setPanelOverlay(null);
4454
+ setCommandMenu(null);
4455
+ setModelPicker(null);
4456
+ setSoundsPicker(null);
4457
+ setMicPicker(null);
4458
+ setEffortPicker({ selectedIdx: -1, mode: effortCmd.mode ?? liveModelMode });
4459
+ return;
4460
+ }
4307
4461
  // Resolve the model a given mode will actually use — mirrors the /model
4308
4462
  // picker + slash-suffix resolveModeModel logic (P2-10: each mode's
4309
4463
  // options come from ITS model's reasoning capabilities).
@@ -4361,22 +4515,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4361
4515
  }, { defaultValue: "{{level}} isn't available for {{model}}. Available: {{levels}}" }));
4362
4516
  return;
4363
4517
  }
4364
- try {
4365
- const nextByMode = { ...effortByMode, [targetMode]: level };
4366
- await http.patch(settingsPatchUrl(), { settings: { effortByMode: nextByMode } });
4367
- setEffortByMode(nextByMode);
4368
- addSystemCard(t('ide.chat.effort.setMode', {
4369
- mode: targetMode,
4370
- level: nativeEffortName(targetModel, level) ?? effortCmd.arg,
4371
- model: targetModelLabel,
4372
- }, { defaultValue: 'Reasoning effort for {{mode}} set to {{level}} ({{model}}).' }));
4373
- }
4374
- catch (error) {
4375
- logger.warn('Failed to update reasoning effort level', { error });
4376
- addSystemCard(t('ide.chat.effort.error', undefined, {
4377
- defaultValue: 'Failed to update reasoning effort.',
4378
- }));
4379
- }
4518
+ await applyEffortLevel(targetMode, level, targetModel);
4380
4519
  }
4381
4520
  return;
4382
4521
  }
@@ -4472,7 +4611,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4472
4611
  setInputAndCursorEnd('/teamsay ');
4473
4612
  else
4474
4613
  setInputValue('');
4475
- sendMessage(trimmed, undefined, { suppressUserMessage: true });
4614
+ // sideChannel: a team note is a human-to-human message, not a turn — it
4615
+ // goes out immediately even while a turn streams (it used to queue
4616
+ // silently behind the sender's own active turn, and a viewer's note
4617
+ // used to tear down their remote-turn tracking).
4618
+ sendMessage(trimmed, undefined, { suppressUserMessage: true, sideChannel: true });
4476
4619
  return;
4477
4620
  }
4478
4621
  // Rewrite /explain with attachments into a proper prompt (attachments processed below)
@@ -4530,6 +4673,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4530
4673
  runSavedScript,
4531
4674
  allCommands,
4532
4675
  selectModel,
4676
+ applyEffortLevel,
4533
4677
  liveModelMode,
4534
4678
  ]);
4535
4679
  // External auto-submit. When the signal changes, submit the current input —
@@ -4746,6 +4890,43 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4746
4890
  ? (resolveModeModel({ planModel, executeModel, commitModel, compactModel, chatModel: savedChatModel }, modelPicker.mode) ?? serverModelDefaults?.[modelPicker.mode])
4747
4891
  : savedChatModel || undefined
4748
4892
  : undefined;
4893
+ // ── Effort picker derived state ─────────────────────────────────────────────
4894
+ // The model whose levels the open /effort picker lists — the model the
4895
+ // selected mode will actually run — plus its selectable options and the level
4896
+ // the "current" pill marks (the persisted per-mode value resolved per-model,
4897
+ // so an unset mode correctly pills the model's own default). Cheap to
4898
+ // recompute per render, mirroring pickerModeOptions above.
4899
+ const effortPickerModel = effortPicker
4900
+ ? AVAILABLE_MODELS.find((m) => m.id === effectiveModelForMode(effortPicker.mode))
4901
+ : undefined;
4902
+ const effortPickerOptions = effortOptionsForModel(effortPickerModel);
4903
+ // Typed filter, mirroring filteredModels: the text after "/effort" (minus a
4904
+ // mode flag) narrows the rows, so "/effort xh" + Enter selects xhigh. Read
4905
+ // from inputRef — it stays fresh because each keystroke re-sets effortPicker.
4906
+ const effortPickerVisibleOptions = (() => {
4907
+ if (!effortPicker)
4908
+ return [];
4909
+ const typed = parseEffortCommand(inputRef.current);
4910
+ const q = typed?.kind === 'set' ? typed.arg.toLowerCase() : '';
4911
+ if (!q)
4912
+ return effortPickerOptions;
4913
+ return effortPickerOptions.filter((o) => o.value.toLowerCase().includes(q));
4914
+ })();
4915
+ const effortPickerCurrent = effortPicker
4916
+ ? nativeEffortName(effortPickerModel, effortByMode[effortPicker.mode] ?? (effortLevel || undefined))
4917
+ : null;
4918
+ // Mode dropdown rows ("Plan · <model>") — effort exists only for the two
4919
+ // conversation modes, each scoped to ITS model's native levels.
4920
+ const effortPickerModeOptions = ['plan', 'execute'].map((m) => ({
4921
+ value: m,
4922
+ label: t('ide.chat.modelModeOption', {
4923
+ mode: m === 'plan'
4924
+ ? t('ide.chat.settings.modePlan', undefined, { defaultValue: 'Plan' })
4925
+ : t('ide.chat.settings.modeExecute', undefined, { defaultValue: 'Execute' }),
4926
+ model: AVAILABLE_MODELS.find((x) => x.id === effectiveModelForMode(m))?.label ??
4927
+ effectiveModelForMode(m),
4928
+ }, { defaultValue: '{{mode}} · {{model}}' }),
4929
+ }));
4749
4930
  const filteredEntries = useMemo(() => {
4750
4931
  if (!filePicker)
4751
4932
  return [];
@@ -4807,6 +4988,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4807
4988
  setMicPicker(null);
4808
4989
  return;
4809
4990
  }
4991
+ if (effortPicker) {
4992
+ setEffortPicker(null);
4993
+ return;
4994
+ }
4810
4995
  if (modelPicker) {
4811
4996
  setModelPicker(null);
4812
4997
  return;
@@ -4851,6 +5036,43 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4851
5036
  return;
4852
5037
  }
4853
5038
  }
5039
+ // Effort picker: one row per (typed-filter-matching) level of the selected
5040
+ // mode's model. A fixed-reasoning model or an unmatched filter lists
5041
+ // nothing — arrows/Enter fall through (Escape closes; Enter submits the
5042
+ // typed text, so an unknown level still gets the "isn't available" card).
5043
+ if (effortPicker && effortPickerVisibleOptions.length > 0) {
5044
+ if (e.key === 'ArrowDown') {
5045
+ e.preventDefault();
5046
+ setEffortPicker((p) => p
5047
+ ? { ...p, selectedIdx: wrapIdx(p.selectedIdx, 1, effortPickerVisibleOptions.length) }
5048
+ : null);
5049
+ return;
5050
+ }
5051
+ if (e.key === 'ArrowUp') {
5052
+ e.preventDefault();
5053
+ setEffortPicker((p) => p
5054
+ ? { ...p, selectedIdx: wrapIdx(p.selectedIdx, -1, effortPickerVisibleOptions.length) }
5055
+ : null);
5056
+ return;
5057
+ }
5058
+ if (e.key === 'Enter' || e.key === 'Tab') {
5059
+ e.preventDefault();
5060
+ // Highlighted row wins; otherwise an exactly-typed level beats the
5061
+ // first substring match ("/effort high" must never apply xhigh's
5062
+ // neighbor), then default to the first visible row (mirrors /model).
5063
+ let option = effortPickerVisibleOptions[effortPicker.selectedIdx];
5064
+ if (!option) {
5065
+ const typed = parseEffortCommand(inputRef.current);
5066
+ const q = typed?.kind === 'set' ? typed.arg.toLowerCase() : '';
5067
+ option =
5068
+ (q ? effortPickerVisibleOptions.find((o) => o.value.toLowerCase() === q) : undefined) ??
5069
+ effortPickerVisibleOptions[0];
5070
+ }
5071
+ if (option)
5072
+ void applyEffortLevel(effortPicker.mode, option.value, effortPickerModel);
5073
+ return;
5074
+ }
5075
+ }
4854
5076
  // The optional "manage your own models" row sits after the model rows and
4855
5077
  // participates in arrow/Enter navigation as the last index.
4856
5078
  const modelRowCount = visibleModels.length + (onManageCustomModels ? 1 : 0);
@@ -5406,14 +5628,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5406
5628
  }, onRevert: canEdit === false ? undefined : handleRevertCommit }, msg.id));
5407
5629
  }
5408
5630
  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,
5409
- // Avatar clicks open the signed-in user's OWN profile, so only
5410
- // their own messages get the handler: author-less ones (the
5411
- // local echo, legacy solo rows) or an author matching
5412
- // currentUserId. A teammate's avatar stays non-interactive —
5413
- // clicking Test's face must not open Luke's profile editor.
5414
- onAvatarClick: !msg.author?.id || (currentUserId != null && msg.author.id === currentUserId)
5415
- ? onUserAvatarClick
5416
- : undefined, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName, canEdit: canEdit }, msg.id));
5631
+ // Avatar/name clicks open the clicked AUTHOR's profile
5632
+ // MessageItem builds the identity from the message's own
5633
+ // author (teammates included), so the host shows their
5634
+ // view-only profile and the viewer's editable own.
5635
+ onProfileClick: onProfileClick, currentUserId: currentUserId, discovery: discovery, buildUpgradeCta: buildUpgradeCta, agentName: agentName, canEdit: canEdit }, msg.id));
5417
5636
  } }, item.kind === 'message' ? item.msg.id : item.card.id))), error &&
5418
5637
  !isStaleAnonymousLimit &&
5419
5638
  (errorMeta?.limitType ? (_jsx(ResourceLimitBanner, { message: error, action: buildUpgradeCta?.({ requiresSignup: errorMeta.requiresSignup }) })) : isViewerAccessDenied ? (
@@ -5434,7 +5653,14 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5434
5653
  // continuation) shows the same live activity indicator as an own send —
5435
5654
  // watchers see Synthase working, not a frozen transcript.
5436
5655
  const showActivity = isLoading || awaitingSandboxBoot || isRemoteStreaming;
5437
- const streamingMsg = isLoading
5656
+ // A remote turn streams real messages into the same store, so it gets the
5657
+ // SAME activity treatment as an own send. Only the no-turn-at-all case
5658
+ // (awaitingSandboxBoot alone) is a sandbox-boot wait — labeling every
5659
+ // !isLoading render with the boot copy showed viewers "Waiting for the
5660
+ // development environment to finish starting…" over a running sandbox
5661
+ // for the whole remote turn (observed 2026-08-31).
5662
+ const streamingLike = isLoading || isRemoteStreaming;
5663
+ const streamingMsg = streamingLike
5438
5664
  ? [...visibleMessages].reverse().find((m) => m.isStreaming)
5439
5665
  : undefined;
5440
5666
  // Turn start = the last genuine user message, so the elapsed timer counts up across
@@ -5451,7 +5677,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5451
5677
  // matches the elapsed timer's span: it climbs monotonically and only plateaus during
5452
5678
  // tool-execution gaps, rather than vanishing/restarting at each new assistant message.
5453
5679
  const turnTokens = estimateTurnTokens(messages);
5454
- const label = isLoading
5680
+ const label = streamingLike
5455
5681
  ? (streamingStatus ?? (streamingMsg ? streamingActivityLabel(streamingMsg) : undefined))
5456
5682
  : t('ide.chat.awaitingSandbox', undefined, {
5457
5683
  defaultValue: 'Waiting for the development environment to finish starting…',
@@ -5461,7 +5687,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
5461
5687
  opacity: showActivity ? 1 : 0,
5462
5688
  pointerEvents: showActivity ? 'auto' : 'none',
5463
5689
  transition: 'opacity 0.18s ease-out',
5464
- }, children: showActivity && (_jsx(StreamingIndicator, { label: label, tokens: isLoading ? turnTokens : undefined, startedAt: isLoading ? turnStartedAt : undefined })) }));
5690
+ }, children: showActivity && (_jsx(StreamingIndicator, { label: label, tokens: streamingLike ? turnTokens : undefined, startedAt: streamingLike ? turnStartedAt : undefined })) }));
5465
5691
  })(), _jsx("div", { ref: messagesEndRef })] }), autoFixCountdown && (_jsxs("div", { className: cm.cn(cm.shrink0, cm.borderT), style: {
5466
5692
  display: 'flex',
5467
5693
  alignItems: 'center',
@@ -6260,7 +6486,108 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6260
6486
  : 'color-mix(in srgb, var(--mol-color-primary, #6366f1) 12%, transparent)',
6261
6487
  }, children: t('ide.chat.manageCustomModels', undefined, {
6262
6488
  defaultValue: 'Add or manage your own models…',
6263
- }) }))] })), soundsPicker && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
6489
+ }) }))] })), effortPicker && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
6490
+ position: 'absolute',
6491
+ bottom: '100%',
6492
+ left: 0,
6493
+ right: 0,
6494
+ marginBottom: 0,
6495
+ borderRadius: '6px 6px 0 0',
6496
+ zIndex: 100,
6497
+ boxShadow: '0 -4px 16px rgba(0,0,0,0.25)',
6498
+ display: 'flex',
6499
+ flexDirection: 'column',
6500
+ maxHeight: popupMaxHeight,
6501
+ }, children: [_jsxs("div", { className: cm.cn(cm.textSize('xs'), cm.textMuted), style: {
6502
+ padding: '6px 12px',
6503
+ borderBottom: '1px solid rgba(128,128,128,0.12)',
6504
+ display: 'flex',
6505
+ alignItems: 'center',
6506
+ gap: 10,
6507
+ flexShrink: 0,
6508
+ flexWrap: 'wrap',
6509
+ }, children: [_jsx("span", { style: { flexShrink: 0 }, children: t('ide.chat.settings.effort.label', undefined, {
6510
+ defaultValue: 'Reasoning effort',
6511
+ }) }), _jsxs("div", { style: {
6512
+ display: 'flex',
6513
+ alignItems: 'center',
6514
+ gap: 6,
6515
+ flex: '1 1 190px',
6516
+ minWidth: 0,
6517
+ }, children: [_jsx("span", { style: { flexShrink: 0 }, children: t('ide.chat.modelModeLabel', undefined, { defaultValue: 'Mode' }) }), _jsx("select", { "data-mol-id": "chat-effort-mode-select", "aria-label": t('ide.chat.modelModeLabel', undefined, { defaultValue: 'Mode' }), value: effortPicker.mode, onChange: (e) => {
6518
+ const v = e.target.value;
6519
+ // Reset the highlighted row — the level list changes with
6520
+ // the mode's model.
6521
+ setEffortPicker((p) => (p ? { mode: v, selectedIdx: -1 } : p));
6522
+ }, className: cm.cn(cm.surfaceSecondary, cm.borderAll, cm.textSize('xs')), style: {
6523
+ borderRadius: 4,
6524
+ // Extra right padding clears the native dropdown arrow so
6525
+ // the selected label never runs underneath it.
6526
+ padding: '2px 18px 2px 6px',
6527
+ color: 'inherit',
6528
+ cursor: 'pointer',
6529
+ height: 24,
6530
+ boxSizing: 'border-box',
6531
+ flex: 1,
6532
+ minWidth: 0,
6533
+ overflow: 'hidden',
6534
+ whiteSpace: 'nowrap',
6535
+ textOverflow: 'ellipsis',
6536
+ }, children: effortPickerModeOptions.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value))) })] }), _jsx("button", { type: "button", "data-mol-id": "chat-effort-picker-close", onClick: () => setEffortPicker(null), style: {
6537
+ background: 'none',
6538
+ border: 'none',
6539
+ cursor: 'pointer',
6540
+ color: 'inherit',
6541
+ padding: '0 2px',
6542
+ fontSize: '14px',
6543
+ lineHeight: 1,
6544
+ opacity: 0.6,
6545
+ // Touch floor for this secondary header ✕ (36px, like the
6546
+ // notice-card actions); fine pointers keep the slim header.
6547
+ ...(isCoarse
6548
+ ? {
6549
+ display: 'inline-flex',
6550
+ alignItems: 'center',
6551
+ justifyContent: 'center',
6552
+ minWidth: 36,
6553
+ minHeight: 36,
6554
+ }
6555
+ : {}),
6556
+ }, children: '✕' })] }), _jsx("div", { style: { overflowY: 'auto', flex: 1 }, children: effortPickerOptions.length === 0 ? (_jsx("div", { className: cm.cn(cm.textSize('sm'), cm.textMuted), style: { padding: 12 }, children: t('ide.chat.effort.fixedForModel', { mode: effortPicker.mode, model: effortPickerModel?.label ?? '?' }, {
6557
+ defaultValue: 'Reasoning effort is fixed on {{model}} ({{mode}} mode) — nothing to set.',
6558
+ }) })) : (effortPickerVisibleOptions.map((option, idx) => (_jsxs("button", { type: "button", "data-mol-id": `chat-effort-level-${option.value}`, onClick: () => void applyEffortLevel(effortPicker.mode, option.value, effortPickerModel), onMouseEnter: (e) => {
6559
+ ;
6560
+ e.currentTarget.style.background = 'rgba(128,128,128,0.15)';
6561
+ }, onMouseLeave: (e) => {
6562
+ ;
6563
+ e.currentTarget.style.background =
6564
+ idx === effortPicker.selectedIdx ? 'rgba(128,128,128,0.1)' : 'transparent';
6565
+ }, className: cm.w('full'), style: {
6566
+ display: 'flex',
6567
+ alignItems: 'center',
6568
+ gap: '6px',
6569
+ width: '100%',
6570
+ minHeight: '40px',
6571
+ padding: '8px 12px',
6572
+ border: 'none',
6573
+ borderTop: idx === 0 ? 'none' : '1px solid rgba(128,128,128,0.12)',
6574
+ cursor: 'pointer',
6575
+ color: 'inherit',
6576
+ textAlign: 'left',
6577
+ fontSize: '13px',
6578
+ background: idx === effortPicker.selectedIdx ? 'rgba(128,128,128,0.1)' : 'transparent',
6579
+ }, children: [_jsx("span", { className: cm.fontWeight('medium'), children: option.value }), defaultEffortForModel(effortPickerModel) === option.value && (_jsx("span", { className: cm.textMuted, style: { fontSize: '10px' }, children: t('ide.chat.modelMode.default', undefined, { defaultValue: 'Default' }) })), effortPickerCurrent === option.value && (_jsx("span", { "data-mol-id": `effort-current-${option.value}`, className: cm.fontWeight('medium'), style: {
6580
+ // Right-aligned + primary-tinted, matching the model
6581
+ // picker's "current" pill (hex is only the var()
6582
+ // fallback; the theme token wins).
6583
+ marginLeft: 'auto',
6584
+ fontSize: '10px',
6585
+ color: 'var(--mol-color-primary, #6366f1)',
6586
+ background: 'color-mix(in srgb, var(--mol-color-primary, #6366f1) 16%, transparent)',
6587
+ border: '1px solid color-mix(in srgb, var(--mol-color-primary, #6366f1) 42%, transparent)',
6588
+ padding: '1px 7px',
6589
+ borderRadius: '999px',
6590
+ }, children: t('ide.chat.currentBadge', undefined, { defaultValue: 'current' }) }))] }, option.value)))) })] })), soundsPicker && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
6264
6591
  position: 'absolute',
6265
6592
  bottom: '100%',
6266
6593
  left: 0,
@@ -6501,7 +6828,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6501
6828
  ? t('ide.chat.activeFile', undefined, { defaultValue: 'active' })
6502
6829
  : t('ide.chat.openTab', undefined, { defaultValue: 'open' }) }))] }, entry.name));
6503
6830
  }) }));
6504
- })(), queuedMessages.length > 0 && !commandMenu && !modelPicker && !panelOverlay && (_jsxs("div", { style: {
6831
+ })(), queuedMessages.length > 0 &&
6832
+ !commandMenu &&
6833
+ !modelPicker &&
6834
+ !effortPicker &&
6835
+ !panelOverlay && (_jsxs("div", { style: {
6505
6836
  borderTop: '1px solid rgba(128,128,128,0.15)',
6506
6837
  padding: '6px 8px 6px 10px',
6507
6838
  }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4, marginBottom: 3 }, children: [_jsx(Icon, { name: "clock", size: 11, "aria-hidden": "true", style: { opacity: 0.5, flexShrink: 0 } }), _jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { fontStyle: 'italic' }, children: t('ide.chat.queuedCount', { count: queuedMessages.length }, { defaultValue: '{{count}} queued' }) })] }), _jsx("div", { style: {
@@ -6637,6 +6968,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6637
6968
  pendingFiles.length > 0 &&
6638
6969
  !commandMenu &&
6639
6970
  !modelPicker &&
6971
+ !effortPicker &&
6640
6972
  !panelOverlay && (_jsxs("div", { style: {
6641
6973
  borderTop: '1px solid rgba(128,128,128,0.15)',
6642
6974
  // Equal top/right/bottom (8px) so the commit button — flush to the
@@ -7002,6 +7334,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
7002
7334
  // Popups are one-at-a-time — close siblings first.
7003
7335
  setMicPicker(null);
7004
7336
  setModelPicker(null);
7337
+ setEffortPicker(null);
7005
7338
  setSoundsPicker(null);
7006
7339
  const cur = inputRef.current;
7007
7340
  if (!cur)