@molecule/app-ide-react 1.8.0 → 1.9.1

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';
@@ -1218,7 +1218,7 @@ const MessageItem = memo(function MessageItem(props) {
1218
1218
  * @param props - Component props (see {@link MessageItemProps}).
1219
1219
  * @returns The rendered chat inner component.
1220
1220
  */
1221
- function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, canEdit, canShare, buildUpgradeCta, buildHelpUpgradeSection, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onConversationId, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, openSettingsSignal, onManageCustomModels, modelSelectionSignal, openReportSignal, openShareSignal, initialInputValue, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, gitStatusTick: externalGitStatusTick, discovery, userAvatar, agentName = DEFAULT_AGENT_NAME, productName = DEFAULT_PRODUCT_NAME, version, extraCommands,
1221
+ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, canEdit, canShare, buildUpgradeCta, buildHelpUpgradeSection, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onConversationId, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, onRegisterHistoryReconcile, 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,
1222
1222
  // feedbackUrl: prop kept for back-compat (callers still pass it), but no longer
1223
1223
  // consumed here — its only use was the command-menu footer link removed in P3-21.
1224
1224
  }) {
@@ -1265,19 +1265,39 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1265
1265
  const soundsConfigRef = useRef({ ...DEFAULT_SOUNDS_CONFIG });
1266
1266
  // ── Context usage tracking (ring indicator) ─────────────────────────────
1267
1267
  const [contextUsage, setContextUsage] = useState(null);
1268
- // Restore context usage from the history endpoint on mount
1268
+ // Restore context usage from the history endpoint on mount. Retries a few
1269
+ // times on failure: this used to be one-shot, so a single transient error at
1270
+ // mount (an API deploy restart, a rate-limit blip) silently cost the context
1271
+ // ring for the entire session — the state only ever refills from the next
1272
+ // turn's `done` event (observed live 2026-08-29: ring gone, server data fine,
1273
+ // reload fixed it).
1269
1274
  useEffect(() => {
1270
1275
  if (!hasConversation)
1271
1276
  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
- });
1277
+ let cancelled = false;
1278
+ let attempt = 0;
1279
+ const load = () => {
1280
+ http
1281
+ .get(endpoint)
1282
+ .then((res) => {
1283
+ if (cancelled)
1284
+ return;
1285
+ if (res.data.contextUsage)
1286
+ setContextUsage(res.data.contextUsage);
1287
+ })
1288
+ .catch((_error) => {
1289
+ // Transient failure — retry with backoff (bounded); after the budget,
1290
+ // the next turn's done event repopulates the ring.
1291
+ if (cancelled || attempt >= 3)
1292
+ return;
1293
+ attempt++;
1294
+ setTimeout(load, attempt * 4000);
1295
+ });
1296
+ };
1297
+ load();
1298
+ return () => {
1299
+ cancelled = true;
1300
+ };
1281
1301
  }, [endpoint, hasConversation, http]);
1282
1302
  // ── Auto-fix countdown state ──────────────────────────────────────────────
1283
1303
  // After the AI finishes, if verification found errors, show a countdown
@@ -1491,7 +1511,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1491
1511
  }
1492
1512
  onFileChange?.(path, content);
1493
1513
  }, [onFileChange, onFileOpen, autoFixCountdown]);
1494
- const { messages, isLoading, isRemoteStreaming, noteRemoteStreamEvent, error, errorMeta, mode, fastMode, streamingStatus, setMode, setFastMode, sendMessage, abort, clearHistory, editQueuedMessage, deleteQueuedMessage, clearQueuedForFile, applyRemoteEvent, retryCountdown, cancelRetry, } = useChat({
1514
+ const { messages, isLoading, isRemoteStreaming, noteRemoteStreamEvent, error, errorMeta, mode, fastMode, streamingStatus, setMode, setFastMode, sendMessage, abort, clearHistory, editQueuedMessage, deleteQueuedMessage, clearQueuedForFile, applyRemoteEvent, reconcileHistory, retryCountdown, cancelRetry, } = useChat({
1495
1515
  endpoint,
1496
1516
  projectId,
1497
1517
  agentName,
@@ -1919,6 +1939,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
1919
1939
  setIsListening(false);
1920
1940
  setCommandMenu(null);
1921
1941
  setModelPicker(null);
1942
+ setEffortPicker(null);
1922
1943
  setSoundsPicker(null);
1923
1944
  setFilePicker(null);
1924
1945
  setPanelOverlay(null);
@@ -2115,6 +2136,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2115
2136
  // Popups are one-at-a-time — close any sibling before opening the picker
2116
2137
  setCommandMenu(null);
2117
2138
  setModelPicker(null);
2139
+ setEffortPicker(null);
2118
2140
  setSoundsPicker(null);
2119
2141
  setFilePicker(null);
2120
2142
  setPanelOverlay(null);
@@ -2155,6 +2177,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2155
2177
  const [commandMenu, setCommandMenu] = useState(null);
2156
2178
  // ── Model picker (shown when typing /model <filter>) ──────────────────────
2157
2179
  const [modelPicker, setModelPicker] = useState(null);
2180
+ const [effortPicker, setEffortPicker] = useState(null);
2158
2181
  // ── System cards (persistent inline notifications in chat history) ────────
2159
2182
  const [systemCards, setSystemCards] = useState([]);
2160
2183
  // Bug-report modal — `{ title }` (seed) when open, null when closed. Opened by
@@ -2258,6 +2281,15 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2258
2281
  onRegisterPushHandler?.(applyPushedStreamEvent);
2259
2282
  return () => onRegisterPushHandler?.(null);
2260
2283
  }, [onRegisterPushHandler, applyPushedStreamEvent]);
2284
+ // Register the history reconcile with the parent so it can converge this
2285
+ // panel on the persisted transcript whenever its push channel (re)connects —
2286
+ // a broadcast sent while that socket was down (a teammate's team note against
2287
+ // a backgrounded tab or a slept laptop) is otherwise lost until a page
2288
+ // lifecycle event happens to fire.
2289
+ useEffect(() => {
2290
+ onRegisterHistoryReconcile?.(reconcileHistory);
2291
+ return () => onRegisterHistoryReconcile?.(null);
2292
+ }, [onRegisterHistoryReconcile, reconcileHistory]);
2261
2293
  // Inject the user-message accent-stripe styles once (the gradient `::before` + its
2262
2294
  // keyframe can't live inline; gated on the row's data-mol-id, so no other row is
2263
2295
  // touched). Guarded by id so it injects a single time across the app.
@@ -2510,6 +2542,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2510
2542
  // Close any sibling popup so overlays never stack (matches how the model
2511
2543
  // picker / sounds picker each own this region exclusively).
2512
2544
  setModelPicker(null);
2545
+ setEffortPicker(null);
2513
2546
  setSoundsPicker(null);
2514
2547
  setCommandMenu(null);
2515
2548
  setPanelOverlayQuery(query);
@@ -2685,10 +2718,13 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2685
2718
  setAutoCommitLoaded(true);
2686
2719
  });
2687
2720
  }, [http, projectId]);
2688
- // Re-read ONLY the persisted model fields when the host signals a model change
2689
- // (the provider modal's "Use" button sets chatModel server-side). Targeted so
2690
- // a model pick never re-hydrates the rest of the settings (auto-commit,
2691
- // skills, effort). Skips the initial 0 value mount already read them.
2721
+ // Re-read the persisted agent-behavior settings when the host signals a
2722
+ // settings change: the provider modal's "Use" button, or a resource_change
2723
+ // broadcast saying ANOTHER member changed the project (model/effort/regions/
2724
+ // max-loops/auto-fix/auto-approve) so this browser's pickers and toggles
2725
+ // match the change instead of rendering stale values under the shared setting
2726
+ // card. Deliberately does NOT touch auto-commit or skills (they carry local
2727
+ // in-session state the mount effect owns). Skips the initial 0 value.
2692
2728
  useEffect(() => {
2693
2729
  if (!modelSelectionSignal)
2694
2730
  return;
@@ -2704,10 +2740,44 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2704
2740
  setPlanModel(s.planModel);
2705
2741
  if (typeof s?.executeModel === 'string')
2706
2742
  setExecuteModel(s.executeModel);
2743
+ if (typeof s?.commitModel === 'string')
2744
+ setCommitModel(s.commitModel);
2745
+ if (typeof s?.compactModel === 'string')
2746
+ setCompactModel(s.compactModel);
2747
+ if (typeof s?.effortLevel === 'string' && s.effortLevel)
2748
+ setEffortLevel(s.effortLevel);
2749
+ if (s?.effortByMode &&
2750
+ typeof s.effortByMode === 'object' &&
2751
+ !Array.isArray(s.effortByMode)) {
2752
+ const next = {};
2753
+ for (const m of ['plan', 'execute']) {
2754
+ const raw = s.effortByMode[m];
2755
+ if (typeof raw === 'string' && raw)
2756
+ next[m] = raw;
2757
+ }
2758
+ setEffortByMode(next);
2759
+ }
2760
+ if (s?.modelRegions &&
2761
+ typeof s.modelRegions === 'object' &&
2762
+ !Array.isArray(s.modelRegions)) {
2763
+ const nextRegions = {};
2764
+ for (const [id, raw] of Object.entries(s.modelRegions)) {
2765
+ if (typeof raw === 'string' && raw in MODEL_REGION_META)
2766
+ nextRegions[id] = raw;
2767
+ }
2768
+ setModelRegions(nextRegions);
2769
+ }
2770
+ if (typeof s?.maxToolLoops === 'number')
2771
+ setCurrentMaxLoops(s.maxToolLoops);
2772
+ if (typeof s?.autoFix === 'boolean')
2773
+ setAutoFixEnabled(s.autoFix);
2774
+ if (typeof s?.autoApproveCommands === 'boolean') {
2775
+ setAutoApproveCommandsEnabled(s.autoApproveCommands);
2776
+ }
2707
2777
  })
2708
2778
  .catch(() => {
2709
- // Non-fatal: the model was persisted server-side; the picker indicator
2710
- // just won't refresh until the next full settings read.
2779
+ // Non-fatal: the change was persisted server-side; this view just won't
2780
+ // refresh until the next signal or full settings read.
2711
2781
  });
2712
2782
  }, [modelSelectionSignal, http, projectId]);
2713
2783
  // Persist the auto-commit cadence to project.settings (debounced) so it
@@ -3545,6 +3615,23 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3545
3615
  return;
3546
3616
  }
3547
3617
  setModelPicker(null);
3618
+ // Show the effort picker while typing "/effort ..." — same live behavior
3619
+ // as /model. Text after the command (minus the --plan/--execute flag)
3620
+ // filters the level list; "?" keeps the textual status path instead. A
3621
+ // dropdown-chosen mode survives further keystrokes unless a flag names one.
3622
+ if (/^\/effort\s/i.test(val)) {
3623
+ const typed = parseEffortCommand(val);
3624
+ if (typed && (typed.kind === 'menu' || (typed.kind === 'set' && typed.arg !== '?'))) {
3625
+ const typedMode = typed.mode;
3626
+ setEffortPicker((p) => ({
3627
+ selectedIdx: -1,
3628
+ mode: typedMode ?? p?.mode ?? liveModelMode,
3629
+ }));
3630
+ setCommandMenu(null);
3631
+ return;
3632
+ }
3633
+ }
3634
+ setEffortPicker(null);
3548
3635
  if (val.startsWith('/') && !val.includes(' ')) {
3549
3636
  setCommandMenu({ selectedIdx: -1 });
3550
3637
  }
@@ -3640,6 +3727,29 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3640
3727
  logger.warn('Failed to update model processing region', { error });
3641
3728
  }
3642
3729
  }, [http, projectId, modelRegions]);
3730
+ // Persist a mode's reasoning effort (used by both the /effort <level> typed
3731
+ // path and the effort picker). Shallow-merge on the server means we send the
3732
+ // WHOLE effortByMode map, not just the changed entry.
3733
+ const applyEffortLevel = useCallback(async (targetMode, level, targetModel) => {
3734
+ setEffortPicker(null);
3735
+ setInputValue('');
3736
+ try {
3737
+ const nextByMode = { ...effortByMode, [targetMode]: level };
3738
+ await http.patch(settingsPatchUrl(), { settings: { effortByMode: nextByMode } });
3739
+ setEffortByMode(nextByMode);
3740
+ addSystemCard(t('ide.chat.effort.setMode', {
3741
+ mode: targetMode,
3742
+ level: nativeEffortName(targetModel, level) ?? level,
3743
+ model: targetModel?.label ?? '?',
3744
+ }, { defaultValue: 'Reasoning effort for {{mode}} set to {{level}} ({{model}}).' }));
3745
+ }
3746
+ catch (error) {
3747
+ logger.warn('Failed to update reasoning effort level', { error });
3748
+ addSystemCard(t('ide.chat.effort.error', undefined, {
3749
+ defaultValue: 'Failed to update reasoning effort.',
3750
+ }));
3751
+ }
3752
+ }, [http, effortByMode, settingsPatchUrl, addSystemCard]);
3643
3753
  // The shared command registry UNION any host-provided commands (e.g.
3644
3754
  // molecule.dev's /deploy, /push, /invite, /teamsay), so the menu, grouping,
3645
3755
  // and dispatch all see one list and host commands never go missing. Declared
@@ -3648,11 +3758,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3648
3758
  const extraCommandIds = useMemo(() => new Set((extraCommands ?? []).map((c) => c.id)), [extraCommands]);
3649
3759
  const executeCommand = useCallback(async (id) => {
3650
3760
  setCommandMenu(null);
3651
- // Any command closes every sibling popup (panel overlay, model/sounds/mic
3652
- // pickers) so popups are strictly one-at-a-time — the branches below
3653
- // re-open their own.
3761
+ // Any command closes every sibling popup (panel overlay, model/effort/
3762
+ // sounds/mic pickers) so popups are strictly one-at-a-time — the branches
3763
+ // below re-open their own.
3654
3764
  setPanelOverlay(null);
3655
3765
  setModelPicker(null);
3766
+ setEffortPicker(null);
3656
3767
  setSoundsPicker(null);
3657
3768
  setMicPicker(null);
3658
3769
  // Host-provided commands (e.g. molecule.dev's /deploy, /push, /invite,
@@ -3697,8 +3808,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3697
3808
  setInputAndCursorEnd('/maxloops ');
3698
3809
  }
3699
3810
  else if (id === 'effort') {
3700
- // Prefill so the user types a level; /effort ? (or bare) shows status.
3701
- setInputAndCursorEnd('/effort ');
3811
+ // Open the selectable level picker (mirrors /model) scoped to the live
3812
+ // conversation mode; its mode dropdown re-scopes in place.
3813
+ setInputValue('');
3814
+ setEffortPicker({ selectedIdx: -1, mode: liveModelMode });
3702
3815
  }
3703
3816
  else if (id === 'autocommit') {
3704
3817
  // Prefill so the user types the cadence (seconds); 0 cancels.
@@ -4209,6 +4322,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4209
4322
  setPanelOverlay(null);
4210
4323
  setCommandMenu(null);
4211
4324
  setModelPicker(null);
4325
+ setEffortPicker(null);
4212
4326
  setSoundsPicker(null);
4213
4327
  setMicPicker({ autoStart: false });
4214
4328
  return;
@@ -4258,6 +4372,18 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4258
4372
  const effortCmd = parseEffortCommand(trimmed);
4259
4373
  if (effortCmd) {
4260
4374
  setInputValue('');
4375
+ // Bare /effort (or a bare --plan/--execute flag) opens the selectable
4376
+ // level picker — same interaction as bare /model. The picker lists the
4377
+ // target mode's model's own levels; its mode dropdown re-scopes in place.
4378
+ if (effortCmd.kind === 'menu') {
4379
+ setPanelOverlay(null);
4380
+ setCommandMenu(null);
4381
+ setModelPicker(null);
4382
+ setSoundsPicker(null);
4383
+ setMicPicker(null);
4384
+ setEffortPicker({ selectedIdx: -1, mode: effortCmd.mode ?? liveModelMode });
4385
+ return;
4386
+ }
4261
4387
  // Resolve the model a given mode will actually use — mirrors the /model
4262
4388
  // picker + slash-suffix resolveModeModel logic (P2-10: each mode's
4263
4389
  // options come from ITS model's reasoning capabilities).
@@ -4315,22 +4441,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4315
4441
  }, { defaultValue: "{{level}} isn't available for {{model}}. Available: {{levels}}" }));
4316
4442
  return;
4317
4443
  }
4318
- try {
4319
- const nextByMode = { ...effortByMode, [targetMode]: level };
4320
- await http.patch(settingsPatchUrl(), { settings: { effortByMode: nextByMode } });
4321
- setEffortByMode(nextByMode);
4322
- addSystemCard(t('ide.chat.effort.setMode', {
4323
- mode: targetMode,
4324
- level: nativeEffortName(targetModel, level) ?? effortCmd.arg,
4325
- model: targetModelLabel,
4326
- }, { defaultValue: 'Reasoning effort for {{mode}} set to {{level}} ({{model}}).' }));
4327
- }
4328
- catch (error) {
4329
- logger.warn('Failed to update reasoning effort level', { error });
4330
- addSystemCard(t('ide.chat.effort.error', undefined, {
4331
- defaultValue: 'Failed to update reasoning effort.',
4332
- }));
4333
- }
4444
+ await applyEffortLevel(targetMode, level, targetModel);
4334
4445
  }
4335
4446
  return;
4336
4447
  }
@@ -4406,6 +4517,20 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4406
4517
  // event (persisted + broadcast to every member), which IS the visible message — so the
4407
4518
  // transcript never shows the literal "/command" text, and never shows it twice.
4408
4519
  if (matchesSideChannelCommand(allCommands, trimmed)) {
4520
+ // The sent note must not survive as a draft: the keystroke-debounced
4521
+ // persistDraft already holds the full "/teamsay …" text, and the viewer
4522
+ // re-prefill below is non-empty so setInputValue never clears it — a
4523
+ // reload then reopened the already-SENT message in the composer. Cancel
4524
+ // any pending draft write and drop the stored draft; the mount prefill
4525
+ // recreates the bare prefix.
4526
+ if (draftTimerRef.current)
4527
+ clearTimeout(draftTimerRef.current);
4528
+ try {
4529
+ sessionStorage.removeItem(draftKey);
4530
+ }
4531
+ catch (_error) {
4532
+ /* sessionStorage unavailable — draft persistence is best-effort */
4533
+ }
4409
4534
  // A viewer's composer is the team-chat box — re-fill the /teamsay prefix
4410
4535
  // after each sent team message so the next one is one keystroke away.
4411
4536
  if (canEdit === false)
@@ -4470,6 +4595,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4470
4595
  runSavedScript,
4471
4596
  allCommands,
4472
4597
  selectModel,
4598
+ applyEffortLevel,
4473
4599
  liveModelMode,
4474
4600
  ]);
4475
4601
  // External auto-submit. When the signal changes, submit the current input —
@@ -4686,6 +4812,43 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4686
4812
  ? (resolveModeModel({ planModel, executeModel, commitModel, compactModel, chatModel: savedChatModel }, modelPicker.mode) ?? serverModelDefaults?.[modelPicker.mode])
4687
4813
  : savedChatModel || undefined
4688
4814
  : undefined;
4815
+ // ── Effort picker derived state ─────────────────────────────────────────────
4816
+ // The model whose levels the open /effort picker lists — the model the
4817
+ // selected mode will actually run — plus its selectable options and the level
4818
+ // the "current" pill marks (the persisted per-mode value resolved per-model,
4819
+ // so an unset mode correctly pills the model's own default). Cheap to
4820
+ // recompute per render, mirroring pickerModeOptions above.
4821
+ const effortPickerModel = effortPicker
4822
+ ? AVAILABLE_MODELS.find((m) => m.id === effectiveModelForMode(effortPicker.mode))
4823
+ : undefined;
4824
+ const effortPickerOptions = effortOptionsForModel(effortPickerModel);
4825
+ // Typed filter, mirroring filteredModels: the text after "/effort" (minus a
4826
+ // mode flag) narrows the rows, so "/effort xh" + Enter selects xhigh. Read
4827
+ // from inputRef — it stays fresh because each keystroke re-sets effortPicker.
4828
+ const effortPickerVisibleOptions = (() => {
4829
+ if (!effortPicker)
4830
+ return [];
4831
+ const typed = parseEffortCommand(inputRef.current);
4832
+ const q = typed?.kind === 'set' ? typed.arg.toLowerCase() : '';
4833
+ if (!q)
4834
+ return effortPickerOptions;
4835
+ return effortPickerOptions.filter((o) => o.value.toLowerCase().includes(q));
4836
+ })();
4837
+ const effortPickerCurrent = effortPicker
4838
+ ? nativeEffortName(effortPickerModel, effortByMode[effortPicker.mode] ?? (effortLevel || undefined))
4839
+ : null;
4840
+ // Mode dropdown rows ("Plan · <model>") — effort exists only for the two
4841
+ // conversation modes, each scoped to ITS model's native levels.
4842
+ const effortPickerModeOptions = ['plan', 'execute'].map((m) => ({
4843
+ value: m,
4844
+ label: t('ide.chat.modelModeOption', {
4845
+ mode: m === 'plan'
4846
+ ? t('ide.chat.settings.modePlan', undefined, { defaultValue: 'Plan' })
4847
+ : t('ide.chat.settings.modeExecute', undefined, { defaultValue: 'Execute' }),
4848
+ model: AVAILABLE_MODELS.find((x) => x.id === effectiveModelForMode(m))?.label ??
4849
+ effectiveModelForMode(m),
4850
+ }, { defaultValue: '{{mode}} · {{model}}' }),
4851
+ }));
4689
4852
  const filteredEntries = useMemo(() => {
4690
4853
  if (!filePicker)
4691
4854
  return [];
@@ -4747,6 +4910,10 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4747
4910
  setMicPicker(null);
4748
4911
  return;
4749
4912
  }
4913
+ if (effortPicker) {
4914
+ setEffortPicker(null);
4915
+ return;
4916
+ }
4750
4917
  if (modelPicker) {
4751
4918
  setModelPicker(null);
4752
4919
  return;
@@ -4791,6 +4958,43 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4791
4958
  return;
4792
4959
  }
4793
4960
  }
4961
+ // Effort picker: one row per (typed-filter-matching) level of the selected
4962
+ // mode's model. A fixed-reasoning model or an unmatched filter lists
4963
+ // nothing — arrows/Enter fall through (Escape closes; Enter submits the
4964
+ // typed text, so an unknown level still gets the "isn't available" card).
4965
+ if (effortPicker && effortPickerVisibleOptions.length > 0) {
4966
+ if (e.key === 'ArrowDown') {
4967
+ e.preventDefault();
4968
+ setEffortPicker((p) => p
4969
+ ? { ...p, selectedIdx: wrapIdx(p.selectedIdx, 1, effortPickerVisibleOptions.length) }
4970
+ : null);
4971
+ return;
4972
+ }
4973
+ if (e.key === 'ArrowUp') {
4974
+ e.preventDefault();
4975
+ setEffortPicker((p) => p
4976
+ ? { ...p, selectedIdx: wrapIdx(p.selectedIdx, -1, effortPickerVisibleOptions.length) }
4977
+ : null);
4978
+ return;
4979
+ }
4980
+ if (e.key === 'Enter' || e.key === 'Tab') {
4981
+ e.preventDefault();
4982
+ // Highlighted row wins; otherwise an exactly-typed level beats the
4983
+ // first substring match ("/effort high" must never apply xhigh's
4984
+ // neighbor), then default to the first visible row (mirrors /model).
4985
+ let option = effortPickerVisibleOptions[effortPicker.selectedIdx];
4986
+ if (!option) {
4987
+ const typed = parseEffortCommand(inputRef.current);
4988
+ const q = typed?.kind === 'set' ? typed.arg.toLowerCase() : '';
4989
+ option =
4990
+ (q ? effortPickerVisibleOptions.find((o) => o.value.toLowerCase() === q) : undefined) ??
4991
+ effortPickerVisibleOptions[0];
4992
+ }
4993
+ if (option)
4994
+ void applyEffortLevel(effortPicker.mode, option.value, effortPickerModel);
4995
+ return;
4996
+ }
4997
+ }
4794
4998
  // The optional "manage your own models" row sits after the model rows and
4795
4999
  // participates in arrow/Enter navigation as the last index.
4796
5000
  const modelRowCount = visibleModels.length + (onManageCustomModels ? 1 : 0);
@@ -6200,7 +6404,108 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6200
6404
  : 'color-mix(in srgb, var(--mol-color-primary, #6366f1) 12%, transparent)',
6201
6405
  }, children: t('ide.chat.manageCustomModels', undefined, {
6202
6406
  defaultValue: 'Add or manage your own models…',
6203
- }) }))] })), soundsPicker && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
6407
+ }) }))] })), effortPicker && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
6408
+ position: 'absolute',
6409
+ bottom: '100%',
6410
+ left: 0,
6411
+ right: 0,
6412
+ marginBottom: 0,
6413
+ borderRadius: '6px 6px 0 0',
6414
+ zIndex: 100,
6415
+ boxShadow: '0 -4px 16px rgba(0,0,0,0.25)',
6416
+ display: 'flex',
6417
+ flexDirection: 'column',
6418
+ maxHeight: popupMaxHeight,
6419
+ }, children: [_jsxs("div", { className: cm.cn(cm.textSize('xs'), cm.textMuted), style: {
6420
+ padding: '6px 12px',
6421
+ borderBottom: '1px solid rgba(128,128,128,0.12)',
6422
+ display: 'flex',
6423
+ alignItems: 'center',
6424
+ gap: 10,
6425
+ flexShrink: 0,
6426
+ flexWrap: 'wrap',
6427
+ }, children: [_jsx("span", { style: { flexShrink: 0 }, children: t('ide.chat.settings.effort.label', undefined, {
6428
+ defaultValue: 'Reasoning effort',
6429
+ }) }), _jsxs("div", { style: {
6430
+ display: 'flex',
6431
+ alignItems: 'center',
6432
+ gap: 6,
6433
+ flex: '1 1 190px',
6434
+ minWidth: 0,
6435
+ }, 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) => {
6436
+ const v = e.target.value;
6437
+ // Reset the highlighted row — the level list changes with
6438
+ // the mode's model.
6439
+ setEffortPicker((p) => (p ? { mode: v, selectedIdx: -1 } : p));
6440
+ }, className: cm.cn(cm.surfaceSecondary, cm.borderAll, cm.textSize('xs')), style: {
6441
+ borderRadius: 4,
6442
+ // Extra right padding clears the native dropdown arrow so
6443
+ // the selected label never runs underneath it.
6444
+ padding: '2px 18px 2px 6px',
6445
+ color: 'inherit',
6446
+ cursor: 'pointer',
6447
+ height: 24,
6448
+ boxSizing: 'border-box',
6449
+ flex: 1,
6450
+ minWidth: 0,
6451
+ overflow: 'hidden',
6452
+ whiteSpace: 'nowrap',
6453
+ textOverflow: 'ellipsis',
6454
+ }, 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: {
6455
+ background: 'none',
6456
+ border: 'none',
6457
+ cursor: 'pointer',
6458
+ color: 'inherit',
6459
+ padding: '0 2px',
6460
+ fontSize: '14px',
6461
+ lineHeight: 1,
6462
+ opacity: 0.6,
6463
+ // Touch floor for this secondary header ✕ (36px, like the
6464
+ // notice-card actions); fine pointers keep the slim header.
6465
+ ...(isCoarse
6466
+ ? {
6467
+ display: 'inline-flex',
6468
+ alignItems: 'center',
6469
+ justifyContent: 'center',
6470
+ minWidth: 36,
6471
+ minHeight: 36,
6472
+ }
6473
+ : {}),
6474
+ }, 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 ?? '?' }, {
6475
+ defaultValue: 'Reasoning effort is fixed on {{model}} ({{mode}} mode) — nothing to set.',
6476
+ }) })) : (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) => {
6477
+ ;
6478
+ e.currentTarget.style.background = 'rgba(128,128,128,0.15)';
6479
+ }, onMouseLeave: (e) => {
6480
+ ;
6481
+ e.currentTarget.style.background =
6482
+ idx === effortPicker.selectedIdx ? 'rgba(128,128,128,0.1)' : 'transparent';
6483
+ }, className: cm.w('full'), style: {
6484
+ display: 'flex',
6485
+ alignItems: 'center',
6486
+ gap: '6px',
6487
+ width: '100%',
6488
+ minHeight: '40px',
6489
+ padding: '8px 12px',
6490
+ border: 'none',
6491
+ borderTop: idx === 0 ? 'none' : '1px solid rgba(128,128,128,0.12)',
6492
+ cursor: 'pointer',
6493
+ color: 'inherit',
6494
+ textAlign: 'left',
6495
+ fontSize: '13px',
6496
+ background: idx === effortPicker.selectedIdx ? 'rgba(128,128,128,0.1)' : 'transparent',
6497
+ }, 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: {
6498
+ // Right-aligned + primary-tinted, matching the model
6499
+ // picker's "current" pill (hex is only the var()
6500
+ // fallback; the theme token wins).
6501
+ marginLeft: 'auto',
6502
+ fontSize: '10px',
6503
+ color: 'var(--mol-color-primary, #6366f1)',
6504
+ background: 'color-mix(in srgb, var(--mol-color-primary, #6366f1) 16%, transparent)',
6505
+ border: '1px solid color-mix(in srgb, var(--mol-color-primary, #6366f1) 42%, transparent)',
6506
+ padding: '1px 7px',
6507
+ borderRadius: '999px',
6508
+ }, children: t('ide.chat.currentBadge', undefined, { defaultValue: 'current' }) }))] }, option.value)))) })] })), soundsPicker && (_jsxs("div", { className: cm.cn(cm.surface, cm.borderAll), style: {
6204
6509
  position: 'absolute',
6205
6510
  bottom: '100%',
6206
6511
  left: 0,
@@ -6441,7 +6746,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6441
6746
  ? t('ide.chat.activeFile', undefined, { defaultValue: 'active' })
6442
6747
  : t('ide.chat.openTab', undefined, { defaultValue: 'open' }) }))] }, entry.name));
6443
6748
  }) }));
6444
- })(), queuedMessages.length > 0 && !commandMenu && !modelPicker && !panelOverlay && (_jsxs("div", { style: {
6749
+ })(), queuedMessages.length > 0 &&
6750
+ !commandMenu &&
6751
+ !modelPicker &&
6752
+ !effortPicker &&
6753
+ !panelOverlay && (_jsxs("div", { style: {
6445
6754
  borderTop: '1px solid rgba(128,128,128,0.15)',
6446
6755
  padding: '6px 8px 6px 10px',
6447
6756
  }, 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: {
@@ -6577,6 +6886,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6577
6886
  pendingFiles.length > 0 &&
6578
6887
  !commandMenu &&
6579
6888
  !modelPicker &&
6889
+ !effortPicker &&
6580
6890
  !panelOverlay && (_jsxs("div", { style: {
6581
6891
  borderTop: '1px solid rgba(128,128,128,0.15)',
6582
6892
  // Equal top/right/bottom (8px) so the commit button — flush to the
@@ -6942,6 +7252,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6942
7252
  // Popups are one-at-a-time — close siblings first.
6943
7253
  setMicPicker(null);
6944
7254
  setModelPicker(null);
7255
+ setEffortPicker(null);
6945
7256
  setSoundsPicker(null);
6946
7257
  const cur = inputRef.current;
6947
7258
  if (!cur)
@@ -7124,7 +7435,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
7124
7435
  * @param props - Component props (see {@link MessageItemProps}).
7125
7436
  * @returns The rendered chat panel element.
7126
7437
  */
7127
- export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessageSent, activeFile, openTabs, onFileOpen, onFileDoubleClick, onFileDiff, onFileRevert, onFileChange, onFileDeleted, onCommit, onActivityClick, onRenderError, onProfileClick, currentUserId, onReadyToBuild, awaitingSandboxBoot, onClientAction, onTurnComplete, onLoadingChange, onNavigatePreview, onRegisterPushHandler, autoSubmitSignal, initialInputValue, hideConversationMenu, renderConversationHeader = true, conversationId: controlledConversationId, chatKey: controlledChatKey, onConversationId: controlledOnConversationId, openShareSignal: controlledShareSignal, openReportSignal: controlledReportSignal, openSettingsSignal: controlledSettingsSignal, onManageCustomModels, gitStatusTick, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, isPro, isAnonymous, canEdit = true, canShare, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, className, }) {
7438
+ 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, onRegisterHistoryReconcile, autoSubmitSignal, initialInputValue, hideConversationMenu, renderConversationHeader = true, conversationId: controlledConversationId, chatKey: controlledChatKey, onConversationId: controlledOnConversationId, openShareSignal: controlledShareSignal, openReportSignal: controlledReportSignal, openSettingsSignal: controlledSettingsSignal, onManageCustomModels, gitStatusTick, pendingMessage, pendingMessageKey, pendingMessageSuppressUser, pendingMessageUserInitiated, userEditedFile, userEditedFileKey, isPro, isAnonymous, canEdit = true, canShare, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, className, }) {
7128
7439
  const cm = getClassMap();
7129
7440
  // Share management may be gated ABOVE canEdit by the host (see
7130
7441
  // ChatPanelProps.canShare) — gates the built-in header share button here and
@@ -7337,7 +7648,7 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
7337
7648
  textOverflow: 'ellipsis',
7338
7649
  whiteSpace: 'nowrap',
7339
7650
  width: '100%',
7340
- }, children: conv.preview ?? 'New conversation' }), _jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { opacity: 0.55 }, children: relativeTime(conv.updatedAt) })] }, conv.id)))] }))] })), _jsx(ChatInner, { projectId: projectId, endpoint: chatEndpoint, initialMessage: initialMessage, onInitialMessageSent: onInitialMessageSent, isPro: isPro, isAnonymous: isAnonymous, canEdit: canEdit, canShare: shareAllowed, buildUpgradeCta: buildUpgradeCta, buildHelpUpgradeSection: buildHelpUpgradeSection, activeFile: activeFile, openTabs: openTabs, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: onFileRevert, onFileChange: onFileChange, onFileDeleted: onFileDeleted, onCommit: onCommit, onConversationId: reportConversationId, onActivityClick: onActivityClick, onRenderError: onRenderError, onProfileClick: onProfileClick, currentUserId: currentUserId, onReadyToBuild: onReadyToBuild, awaitingSandboxBoot: awaitingSandboxBoot, onClientAction: onClientAction, onTurnComplete: onTurnComplete, onLoadingChange: onLoadingChange, onNavigatePreview: onNavigatePreview, onRegisterPushHandler: onRegisterPushHandler, autoSubmitSignal: autoSubmitSignal, openSettingsSignal: effectiveSettingsSignal, onManageCustomModels: onManageCustomModels, openReportSignal: effectiveReportSignal, openShareSignal: effectiveShareSignal, initialInputValue: initialInputValue, pendingMessage: pendingMessage, pendingMessageKey: pendingMessageKey, pendingMessageSuppressUser: pendingMessageSuppressUser, pendingMessageUserInitiated: pendingMessageUserInitiated, userEditedFile: userEditedFile, userEditedFileKey: userEditedFileKey, gitStatusTick: gitStatusTick, discovery: hideConversationMenu, userAvatar: userAvatar, agentName: agentName, productName: productName, version: version, extraCommands: extraCommands, feedbackUrl: feedbackUrl }, chatKey)] }));
7651
+ }, children: conv.preview ?? 'New conversation' }), _jsx("span", { className: cm.cn(cm.textMuted, cm.textSize('xs')), style: { opacity: 0.55 }, children: relativeTime(conv.updatedAt) })] }, conv.id)))] }))] })), _jsx(ChatInner, { projectId: projectId, endpoint: chatEndpoint, initialMessage: initialMessage, onInitialMessageSent: onInitialMessageSent, isPro: isPro, isAnonymous: isAnonymous, canEdit: canEdit, canShare: shareAllowed, buildUpgradeCta: buildUpgradeCta, buildHelpUpgradeSection: buildHelpUpgradeSection, activeFile: activeFile, openTabs: openTabs, onFileOpen: onFileOpen, onFileDoubleClick: onFileDoubleClick, onFileDiff: onFileDiff, onFileRevert: onFileRevert, onFileChange: onFileChange, onFileDeleted: onFileDeleted, onCommit: onCommit, onConversationId: reportConversationId, onActivityClick: onActivityClick, onRenderError: onRenderError, onProfileClick: onProfileClick, currentUserId: currentUserId, onReadyToBuild: onReadyToBuild, awaitingSandboxBoot: awaitingSandboxBoot, onClientAction: onClientAction, onTurnComplete: onTurnComplete, onLoadingChange: onLoadingChange, onNavigatePreview: onNavigatePreview, onRegisterPushHandler: onRegisterPushHandler, onRegisterHistoryReconcile: onRegisterHistoryReconcile, 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)] }));
7341
7652
  }
7342
7653
  ChatPanel.displayName = 'ChatPanel';
7343
7654
  //# sourceMappingURL=ChatPanel.js.map