@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.
- package/README.md +42 -20
- package/dist/command-metadata.js +2 -2
- package/dist/command-metadata.js.map +1 -1
- package/dist/components/ChatPanel.d.ts +11 -5
- package/dist/components/ChatPanel.d.ts.map +1 -1
- package/dist/components/ChatPanel.js +408 -75
- package/dist/components/ChatPanel.js.map +1 -1
- package/dist/components/PreviewPanel.d.ts +1 -1
- package/dist/components/PreviewPanel.d.ts.map +1 -1
- package/dist/components/PreviewPanel.js +311 -33
- package/dist/components/PreviewPanel.js.map +1 -1
- package/dist/components/chat-effort-utilities.d.ts +11 -3
- package/dist/components/chat-effort-utilities.d.ts.map +1 -1
- package/dist/components/chat-effort-utilities.js +5 -3
- package/dist/components/chat-effort-utilities.js.map +1 -1
- package/dist/components/chat-models-utilities.d.ts +7 -3
- package/dist/components/chat-models-utilities.d.ts.map +1 -1
- package/dist/components/chat-models-utilities.js +65 -9
- package/dist/components/chat-models-utilities.js.map +1 -1
- package/dist/types.d.ts +40 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
|
@@ -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,
|
|
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,
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
//
|
|
3008
|
-
//
|
|
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/
|
|
3698
|
-
// pickers) so popups are strictly one-at-a-time — the branches
|
|
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
|
-
//
|
|
3747
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5410
|
-
//
|
|
5411
|
-
//
|
|
5412
|
-
//
|
|
5413
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
}) }))] })),
|
|
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 &&
|
|
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)
|