@molecule/app-ide-react 3.0.3 → 3.1.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.
@@ -51,7 +51,7 @@ import { ShareModal } from './ShareModal.js';
51
51
  import { SkillsCard } from './SkillsCard.js';
52
52
  import { StreamingIndicator } from './StreamingIndicator.js';
53
53
  import { getTestsBarVisible, parseTestsBarArg, setTestsBarVisible, shouldShowTestsBar, } from './tests-bar-utilities.js';
54
- import { applyTestRunEvent, buildTestFixMessage, EMPTY_RUN_STATE, failRun, markSkipUnavailable, parseTestCommand, requestSkip, } from './tests-card-utilities.js';
54
+ import { applyTestRunEvent, buildTestFixMessage, EMPTY_RUN_STATE, failRun, markSkipUnavailable, parseTestCommand, requestSkip, TestRunProjection, } from './tests-card-utilities.js';
55
55
  import { TestsCard } from './TestsCard.js';
56
56
  import { TestStatusBar } from './TestStatusBar.js';
57
57
  import { TipCard } from './TipCard.js';
@@ -650,7 +650,7 @@ function CollapsibleUserMessage({ content, isLight, }) {
650
650
  * @param props - Component props (see {@link MessageItemProps}).
651
651
  * @returns The rendered verification badge element.
652
652
  */
653
- function VerificationBadge({ status, output, categories, }) {
653
+ function VerificationBadge({ status, output, categories, renderCheckUnavailable, }) {
654
654
  const cm = getClassMap();
655
655
  const [expanded, setExpanded] = useState(false);
656
656
  if (status === 'ok') {
@@ -660,7 +660,9 @@ function VerificationBadge({ status, output, categories, }) {
660
660
  gap: '6px',
661
661
  padding: '3px 0',
662
662
  fontSize: '13px',
663
- }, className: cm.textMuted, children: [_jsx("svg", { width: "10", height: "10", viewBox: "0 0 10 10", style: { flexShrink: 0, alignSelf: 'center', position: 'relative', top: '-1px' }, children: _jsx("path", { d: "M2 5.5 L4.2 7.8 L8 3", fill: "none", stroke: "#3fb950", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }), _jsx("span", { children: t('ide.chat.verificationPassed', undefined, { defaultValue: 'Checks passed' }) })] }));
663
+ }, className: cm.textMuted, children: [_jsx("svg", { width: "10", height: "10", viewBox: "0 0 10 10", style: { flexShrink: 0, alignSelf: 'center', position: 'relative', top: '-1px' }, children: _jsx("path", { d: "M2 5.5 L4.2 7.8 L8 3", fill: "none", stroke: "#3fb950", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) }), _jsxs("span", { children: [t('ide.chat.verificationPassed', undefined, { defaultValue: 'Checks passed' }), renderCheckUnavailable && (_jsxs("span", { "data-mol-id": "render-check-unavailable", className: cm.textWarning, children: [' · ', t('ide.chat.renderCheckUnavailable', undefined, {
664
+ defaultValue: 'Render check unavailable',
665
+ })] }))] })] }));
664
666
  }
665
667
  // Count errors from the output text
666
668
  const cats = categories ?? [];
@@ -1212,7 +1214,7 @@ const MessageItem = memo(function MessageItem(props) {
1212
1214
  }
1213
1215
  if (blockType === 'verification') {
1214
1216
  const vBlock = block;
1215
- return (_jsx(VerificationBadge, { status: vBlock.status, output: vBlock.output, workspaces: vBlock.workspaces, categories: vBlock.categories }, `verification-${bi}`));
1217
+ return (_jsx(VerificationBadge, { status: vBlock.status, output: vBlock.output, workspaces: vBlock.workspaces, categories: vBlock.categories, renderCheckUnavailable: vBlock.renderCheckUnavailable }, `verification-${bi}`));
1216
1218
  }
1217
1219
  if (blockType === 'resource_limit') {
1218
1220
  const rlBlock = block;
@@ -1321,12 +1323,24 @@ const MessageItem = memo(function MessageItem(props) {
1321
1323
  }, children: String.fromCharCode(65 + i) }), _jsx("span", { children: opt.label })] }, i)))] }));
1322
1324
  })()] })) }));
1323
1325
  });
1326
+ /** Only COMPLETED own ids are bounded; active own ids are pinned separately. */
1327
+ const COMPLETED_OWN_TEST_RUN_ID_LIMIT = 32;
1328
+ /** Remember a completed own run for its delayed feed copy. */
1329
+ function rememberCompletedOwnTestRunId(ids, runId) {
1330
+ ids.delete(runId);
1331
+ ids.add(runId);
1332
+ if (ids.size <= COMPLETED_OWN_TEST_RUN_ID_LIMIT)
1333
+ return;
1334
+ const oldest = ids.values().next();
1335
+ if (!oldest.done)
1336
+ ids.delete(oldest.value);
1337
+ }
1324
1338
  /**
1325
1339
  * Inner chat component that owns useChat state, message rendering, and input handling.
1326
1340
  * @param props - Component props (see {@link MessageItemProps}).
1327
1341
  * @returns The rendered chat inner component.
1328
1342
  */
1329
- function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, canEdit, canSendTeamMessages = true, canShare, shareLinksAllowed, shareLinksRefreshSignal, shareModalContent, 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, listTests, runTests, canRunTests, testsAvailable, skipToolCall,
1343
+ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent, isPro, isAnonymous, canEdit, canSendTeamMessages = true, canShare, shareLinksAllowed, shareLinksRefreshSignal, shareModalContent, 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, listTests, runTests, watchTestRuns, canRunTests, testsAvailable, skipToolCall,
1330
1344
  // feedbackUrl: prop kept for back-compat (callers still pass it), but no longer
1331
1345
  // consumed here — its only use was the command-menu footer link removed in P3-21.
1332
1346
  }) {
@@ -2753,13 +2767,48 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2753
2767
  const [testsList, setTestsList] = useState([]);
2754
2768
  const [testsStatus, setTestsStatus] = useState('loading');
2755
2769
  const [testsRun, setTestsRun] = useState(EMPTY_RUN_STATE);
2756
- const testsRunHandleRef = useRef(null);
2770
+ // Each callback closes over its own folded run. Only this invocation owns
2771
+ // controls; stopped/completed invocations can still report trailing verdicts.
2772
+ const ownTestRunRef = useRef(null);
2757
2773
  // Live "is a run in flight?" for the dispatchers, so `startTestsRun` keeps a
2758
2774
  // stable identity — the command callbacks that hold it carry hand-written
2759
2775
  // dependency lists, and a value that changes every run transition is one they
2760
2776
  // would eventually capture stale.
2761
2777
  const testsRunningRef = useRef(false);
2762
2778
  testsRunningRef.current = testsRun.running;
2779
+ // One incremental projection: per-file verdicts plus bounded live/latest run state. Server ids
2780
+ // dedupe the direct stream and feed echo; legacy id-less custom hosts use the bounded id guard.
2781
+ const testRunProjectionRef = useRef(null);
2782
+ if (!testRunProjectionRef.current)
2783
+ testRunProjectionRef.current = new TestRunProjection();
2784
+ const activeOwnTestRunIdsRef = useRef(new Set());
2785
+ const completedOwnTestRunIdsRef = useRef(new Set());
2786
+ const projectTestsRun = useCallback(() => {
2787
+ const invocation = ownTestRunRef.current;
2788
+ if (invocation && invocation.state.runId == null) {
2789
+ setTestsRun(invocation.state);
2790
+ return;
2791
+ }
2792
+ const activeOwnRunId = invocation?.state.runId ?? null;
2793
+ const next = testRunProjectionRef.current.show(activeOwnRunId);
2794
+ setTestsRun(invocation && next.runId === invocation.state.runId
2795
+ ? {
2796
+ ...next,
2797
+ skipPending: invocation.state.skipPending,
2798
+ skipUnavailable: invocation.state.skipUnavailable,
2799
+ }
2800
+ : next);
2801
+ }, []);
2802
+ const recordOwnTestRunEvent = useCallback((event) => {
2803
+ const legacy = !event.eventId;
2804
+ if (legacy) {
2805
+ if (ownTestRunRef.current?.state.runId === event.runId)
2806
+ activeOwnTestRunIdsRef.current.add(event.runId);
2807
+ else
2808
+ rememberCompletedOwnTestRunId(completedOwnTestRunIdsRef.current, event.runId);
2809
+ }
2810
+ testRunProjectionRef.current.apply(event, ownTestRunRef.current?.state.runId ?? null);
2811
+ }, []);
2763
2812
  // Whether a TURN is in flight — a fix is a real message, so it waits its turn.
2764
2813
  const testsBusyRef = useRef(false);
2765
2814
  // Live for the async callbacks, so a list resolving after an unmount, or a
@@ -2771,7 +2820,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2771
2820
  testsMountedRef.current = true;
2772
2821
  return () => {
2773
2822
  testsMountedRef.current = false;
2774
- testsRunHandleRef.current?.cancel();
2823
+ const own = ownTestRunRef.current;
2824
+ ownTestRunRef.current = null;
2825
+ own?.handle?.cancel();
2775
2826
  };
2776
2827
  }, []);
2777
2828
  const refreshTests = useCallback(async () => {
@@ -2807,52 +2858,121 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2807
2858
  return;
2808
2859
  if (canRunTests === false || canEdit === false)
2809
2860
  return;
2810
- if (testsAvailable === false || testsRunningRef.current)
2861
+ if (testsAvailable === false || testsRunningRef.current || ownTestRunRef.current)
2811
2862
  return;
2812
2863
  if (selection.ids?.length === 0)
2813
2864
  return;
2814
2865
  // Seed a running state immediately: the host's `start` event may be a
2815
2866
  // round trip away, and a Run button that appears to do nothing is the
2816
2867
  // failure this feature exists to remove.
2817
- setTestsRun((prev) => ({
2818
- ...prev,
2868
+ const initial = {
2869
+ ...EMPTY_RUN_STATE,
2819
2870
  running: true,
2820
- outcome: null,
2821
- error: null,
2822
- output: [],
2823
- currentId: null,
2824
- queued: selection.ids ?? [],
2871
+ queued: [...(selection.ids ?? [])],
2825
2872
  startedAt: Date.now(),
2826
- durationMs: null,
2827
- }));
2873
+ external: false,
2874
+ };
2875
+ const invocation = {
2876
+ state: initial,
2877
+ handle: null,
2878
+ ended: null,
2879
+ };
2880
+ ownTestRunRef.current = invocation;
2881
+ setTestsRun((prev) => {
2882
+ const results = { ...prev.results };
2883
+ for (const id of initial.queued)
2884
+ delete results[id];
2885
+ return { ...initial, results };
2886
+ });
2828
2887
  try {
2829
2888
  const handle = runTests(selection, (event) => {
2830
2889
  if (!testsMountedRef.current)
2831
2890
  return;
2832
- setTestsRun((prev) => applyTestRunEvent(prev, event));
2891
+ const current = ownTestRunRef.current === invocation;
2892
+ const ended = invocation.ended;
2893
+ invocation.state = applyTestRunEvent(invocation.state, event);
2894
+ const runId = invocation.state.runId;
2895
+ // Completion owns cleanup even when no start ever supplied an id. Otherwise one failed
2896
+ // start leaves this invocation occupied and every later Run click returns early.
2833
2897
  if (event.type === 'done') {
2834
- testsRunHandleRef.current = null;
2835
- // A run cannot create a test, but it CAN finish after the agent wrote
2836
- // one — re-list once it settles so the card is current.
2898
+ invocation.ended = event;
2899
+ if (current)
2900
+ ownTestRunRef.current = null;
2837
2901
  void refreshTests();
2838
2902
  }
2903
+ if (runId == null) {
2904
+ if (current)
2905
+ setTestsRun(invocation.state);
2906
+ return;
2907
+ }
2908
+ if (event.type === 'start') {
2909
+ recordOwnTestRunEvent(event);
2910
+ if (ended) {
2911
+ // A start delayed past cancellation is history, never a live own run.
2912
+ invocation.state = applyTestRunEvent(invocation.state, ended);
2913
+ recordOwnTestRunEvent({ ...ended, runId });
2914
+ }
2915
+ }
2916
+ else {
2917
+ recordOwnTestRunEvent({ ...event, runId });
2918
+ }
2919
+ if (event.type === 'done' && !event.eventId) {
2920
+ activeOwnTestRunIdsRef.current.delete(runId);
2921
+ rememberCompletedOwnTestRunId(completedOwnTestRunIdsRef.current, runId);
2922
+ }
2923
+ projectTestsRun();
2839
2924
  });
2840
- testsRunHandleRef.current = handle;
2925
+ // The host is allowed to finish synchronously, before returning its
2926
+ // handle. It no longer owns controls after handing the card onward.
2927
+ if (ownTestRunRef.current !== invocation)
2928
+ return;
2929
+ invocation.handle = handle;
2841
2930
  // Whether a Skip control belongs on screen is decided HERE, by whether
2842
2931
  // this host's handle serves one — not by the card guessing, and not by
2843
2932
  // a button that would do nothing when clicked.
2844
2933
  const skips = typeof handle.skipCurrent === 'function';
2934
+ invocation.state = {
2935
+ ...invocation.state,
2936
+ skipPending: false,
2937
+ skipUnavailable: !skips,
2938
+ };
2845
2939
  setTestsRun((prev) => ({ ...prev, skipPending: false, skipUnavailable: !skips }));
2846
2940
  }
2847
2941
  catch (error) {
2848
2942
  logger.warn('Failed to start a test run', { error });
2849
- setTestsRun((prev) => failRun(prev, error instanceof Error
2943
+ if (ownTestRunRef.current !== invocation)
2944
+ return;
2945
+ ownTestRunRef.current = null;
2946
+ const message = error instanceof Error
2850
2947
  ? error.message
2851
2948
  : t('ide.tests.runError', undefined, {
2852
2949
  defaultValue: 'The test run could not start.',
2853
- })));
2950
+ });
2951
+ const runId = invocation.state.runId;
2952
+ if (runId != null) {
2953
+ const done = { type: 'done', outcome: 'error', error: message };
2954
+ invocation.ended = done;
2955
+ invocation.state = applyTestRunEvent(invocation.state, done);
2956
+ activeOwnTestRunIdsRef.current.delete(runId);
2957
+ recordOwnTestRunEvent({ ...done, runId });
2958
+ projectTestsRun();
2959
+ }
2960
+ else if (testRunProjectionRef.current.show().running) {
2961
+ projectTestsRun();
2962
+ }
2963
+ else {
2964
+ setTestsRun((prev) => failRun(prev, message));
2965
+ }
2854
2966
  }
2855
- }, [runTests, canRunTests, canEdit, testsAvailable, refreshTests]);
2967
+ }, [
2968
+ runTests,
2969
+ canRunTests,
2970
+ canEdit,
2971
+ testsAvailable,
2972
+ refreshTests,
2973
+ projectTestsRun,
2974
+ recordOwnTestRunEvent,
2975
+ ]);
2856
2976
  // Hand the failing tests to the agent as ONE ordinary user message — the same
2857
2977
  // move as the editor’s "Fix with AI" and the auto-fix loop: compose the
2858
2978
  // problem, send it through the normal path, let the turn answer. The overlay
@@ -2869,18 +2989,70 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2869
2989
  sendMessageRef.current(message);
2870
2990
  }, [canEdit]);
2871
2991
  const cancelTestsRun = useCallback(() => {
2872
- testsRunHandleRef.current?.cancel();
2873
- testsRunHandleRef.current = null;
2874
- setTestsRun((prev) => ({
2875
- ...prev,
2876
- running: false,
2877
- currentId: null,
2878
- cases: {},
2879
- skipPending: false,
2880
- skipUnavailable: true,
2881
- outcome: 'cancelled',
2882
- }));
2883
- }, []);
2992
+ // Only this panel's own run can be stopped from here; the controls are not
2993
+ // offered on a run it is only watching.
2994
+ const invocation = ownTestRunRef.current;
2995
+ if (!invocation)
2996
+ return;
2997
+ // Relinquish ownership before cancel(), which may synchronously call back.
2998
+ ownTestRunRef.current = null;
2999
+ const done = { type: 'done', outcome: 'cancelled' };
3000
+ invocation.ended = done;
3001
+ invocation.state = applyTestRunEvent(invocation.state, done);
3002
+ const runId = invocation.state.runId;
3003
+ if (runId != null) {
3004
+ activeOwnTestRunIdsRef.current.delete(runId);
3005
+ recordOwnTestRunEvent({ ...done, runId });
3006
+ projectTestsRun();
3007
+ }
3008
+ else {
3009
+ setTestsRun({ ...invocation.state, skipUnavailable: true });
3010
+ }
3011
+ invocation.handle?.cancel();
3012
+ }, [projectTestsRun, recordOwnTestRunEvent]);
3013
+ // Every run in the project, however it was started, reaches the same bar and
3014
+ // browser: the host replays the runs it still holds, then streams new ones.
3015
+ // The ref keeps the subscription from restarting (and replaying) each time
3016
+ // `refreshTests` changes identity.
3017
+ const refreshTestsRef = useRef(refreshTests);
3018
+ refreshTestsRef.current = refreshTests;
3019
+ useEffect(() => {
3020
+ if (!watchTestRuns)
3021
+ return;
3022
+ let relist = null;
3023
+ let unsubscribe = null;
3024
+ try {
3025
+ unsubscribe = watchTestRuns((event) => {
3026
+ if (!testsMountedRef.current)
3027
+ return;
3028
+ // Only id-less legacy hosts need run-id suppression. Ordered copies dedupe by event id.
3029
+ if (!event.eventId &&
3030
+ (activeOwnTestRunIdsRef.current.has(event.runId) ||
3031
+ completedOwnTestRunIdsRef.current.has(event.runId))) {
3032
+ return;
3033
+ }
3034
+ testRunProjectionRef.current.apply(event, ownTestRunRef.current?.state.runId ?? null);
3035
+ projectTestsRun();
3036
+ if (event.type === 'done') {
3037
+ if (relist)
3038
+ clearTimeout(relist);
3039
+ relist = setTimeout(() => {
3040
+ relist = null;
3041
+ void refreshTestsRef.current();
3042
+ }, 500);
3043
+ }
3044
+ });
3045
+ projectTestsRun();
3046
+ }
3047
+ catch (error) {
3048
+ logger.warn('Failed to watch the project test runs', { error });
3049
+ }
3050
+ return () => {
3051
+ if (relist)
3052
+ clearTimeout(relist);
3053
+ unsubscribe?.();
3054
+ };
3055
+ }, [watchTestRuns, projectTestsRun]);
2884
3056
  // Skip the executor's in-flight tool call WITHOUT ending the turn — the
2885
3057
  // model is told the command did not run and carries on. The card owns its own
2886
3058
  // pressed state, so this just forwards the host's answer: `false` means the
@@ -2905,23 +3077,33 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
2905
3077
  const skipCurrentTest = useCallback(() => {
2906
3078
  if (canEdit === false || canRunTests === false)
2907
3079
  return;
2908
- const skip = testsRunHandleRef.current?.skipCurrent;
3080
+ const invocation = ownTestRunRef.current;
3081
+ if (!invocation)
3082
+ return;
3083
+ const skip = invocation.handle?.skipCurrent;
2909
3084
  if (!skip) {
3085
+ invocation.state = markSkipUnavailable(invocation.state);
2910
3086
  setTestsRun((prev) => markSkipUnavailable(prev));
2911
3087
  return;
2912
3088
  }
3089
+ invocation.state = requestSkip(invocation.state);
2913
3090
  setTestsRun((prev) => requestSkip(prev));
2914
3091
  const unavailable = () => {
2915
- if (testsMountedRef.current)
3092
+ if (testsMountedRef.current && ownTestRunRef.current === invocation) {
3093
+ invocation.state = markSkipUnavailable(invocation.state);
2916
3094
  setTestsRun((prev) => markSkipUnavailable(prev));
3095
+ }
2917
3096
  };
2918
3097
  try {
2919
- const outcome = skip.call(testsRunHandleRef.current);
3098
+ const outcome = skip.call(invocation.handle);
2920
3099
  if (outcome) {
2921
3100
  void outcome.then((accepted) => {
2922
3101
  if (accepted === false)
2923
3102
  unavailable();
2924
- }, unavailable);
3103
+ }, (error) => {
3104
+ logger.warn('Failed to skip the running test', { error });
3105
+ unavailable();
3106
+ });
2925
3107
  }
2926
3108
  }
2927
3109
  catch (error) {
@@ -3329,11 +3511,31 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3329
3511
  // ── Git status ─────────────────────────────────────────────────────────────
3330
3512
  const [gitStatusTick, setGitStatusTick] = useState(0);
3331
3513
  const refreshGitStatus = useCallback(() => setGitStatusTick((n) => n + 1), []);
3514
+ const gitScopeRef = useRef(0);
3515
+ useLayoutEffect(() => {
3516
+ gitScopeRef.current++;
3517
+ setPendingFiles(null);
3518
+ setCommitState(null);
3519
+ setCommitCards([]);
3520
+ setCommitBarExpanded(false);
3521
+ return () => {
3522
+ gitScopeRef.current++;
3523
+ };
3524
+ }, [projectId]);
3525
+ const gitStatusRequestRef = useRef(0);
3332
3526
  const fetchPendingFiles = useCallback(() => {
3333
- http
3527
+ const scope = gitScopeRef.current;
3528
+ const request = ++gitStatusRequestRef.current;
3529
+ return http
3334
3530
  .get(`/projects/${projectId}/git-status`)
3335
- .then((res) => setPendingFiles(res.data.files.length > 0 ? res.data.files : null))
3336
- .catch(() => setPendingFiles(null));
3531
+ .then((res) => {
3532
+ if (scope === gitScopeRef.current && request === gitStatusRequestRef.current)
3533
+ setPendingFiles(res.data.files.length > 0 ? res.data.files : null);
3534
+ })
3535
+ .catch(() => {
3536
+ if (scope === gitScopeRef.current && request === gitStatusRequestRef.current)
3537
+ setPendingFiles(null);
3538
+ });
3337
3539
  }, [http, projectId]);
3338
3540
  // Debounced version — coalesces rapid ticks (e.g. save → format, or an AI
3339
3541
  // write burst mid-turn) into a single fetch after the last write settles.
@@ -3362,6 +3564,25 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3362
3564
  return;
3363
3565
  debouncedFetchPendingFiles();
3364
3566
  }, [externalGitStatusTick, debouncedFetchPendingFiles]);
3567
+ // Startup repairs, another editor, or another client can commit without a
3568
+ // local file event. Reconcile on return and while the workspace is visible.
3569
+ useEffect(() => {
3570
+ const refresh = () => {
3571
+ if (document.visibilityState !== 'hidden')
3572
+ void fetchPendingFiles();
3573
+ };
3574
+ window.addEventListener('focus', refresh);
3575
+ document.addEventListener('visibilitychange', refresh);
3576
+ const interval = setInterval(refresh, 45_000);
3577
+ return () => {
3578
+ window.removeEventListener('focus', refresh);
3579
+ document.removeEventListener('visibilitychange', refresh);
3580
+ clearInterval(interval);
3581
+ if (gitFetchTimerRef.current)
3582
+ clearTimeout(gitFetchTimerRef.current);
3583
+ gitStatusRequestRef.current++;
3584
+ };
3585
+ }, [fetchPendingFiles]);
3365
3586
  // A user-side file mutation (edit/rename/delete) also restarts the auto-commit
3366
3587
  // countdown — the parent's externalGitStatusTick is the canonical "files moved"
3367
3588
  // signal. Skipped on the initial 0 so opening a project doesn't arm anything.
@@ -3604,6 +3825,9 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3604
3825
  }, [userEditedFile, userEditedFileKey, clearQueuedForFile]);
3605
3826
  // ── Commit ─────────────────────────────────────────────────────────────────
3606
3827
  const handleCommit = useCallback(async () => {
3828
+ const scope = gitScopeRef.current;
3829
+ // An older status response must not restore a pre-commit count.
3830
+ gitStatusRequestRef.current++;
3607
3831
  setCommitState({ status: 'committing' });
3608
3832
  const cardId = `commit-${Date.now()}`;
3609
3833
  setCommitCards((prev) => [
@@ -3611,8 +3835,11 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3611
3835
  { id: cardId, message: '', files: [], timestamp: Date.now(), status: 'running' },
3612
3836
  ]);
3613
3837
  try {
3614
- const result = await http.post(`/projects/${projectId}/commit`, {});
3838
+ const result = await http.post(`/projects/${projectId}/commit`);
3839
+ if (scope !== gitScopeRef.current)
3840
+ return;
3615
3841
  if (result.data.committed) {
3842
+ gitStatusRequestRef.current++;
3616
3843
  const msg = result.data.message ?? t('ide.chat.committed');
3617
3844
  setCommitState({ status: 'committed', message: msg });
3618
3845
  setPendingFiles(null);
@@ -3627,14 +3854,28 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3627
3854
  }
3628
3855
  : c));
3629
3856
  onCommit?.();
3630
- setTimeout(() => setCommitState(null), 3000);
3857
+ setTimeout(() => {
3858
+ if (scope === gitScopeRef.current)
3859
+ setCommitState(null);
3860
+ }, 3000);
3631
3861
  }
3632
3862
  else {
3633
3863
  setCommitCards((prev) => prev.filter((c) => c.id !== cardId));
3634
3864
  setCommitState(null);
3865
+ addSystemCard(result.data.message === 'No changes to commit'
3866
+ ? t('ide.chat.commitNoChanges', undefined, { defaultValue: 'No changes to commit.' })
3867
+ : result.data.message === 'A build is in progress'
3868
+ ? t('ide.chat.commitBuildInProgress', undefined, {
3869
+ defaultValue: 'Wait for the build to finish before creating a Git commit.',
3870
+ })
3871
+ : t('ide.chat.commitError', undefined, { defaultValue: 'Failed to commit changes.' }));
3872
+ void fetchPendingFiles();
3873
+ onCommit?.();
3635
3874
  }
3636
3875
  }
3637
3876
  catch (error) {
3877
+ if (scope !== gitScopeRef.current)
3878
+ return;
3638
3879
  logger.warn('Commit request failed', { error });
3639
3880
  setCommitCards((prev) => prev.map((c) => c.id === cardId
3640
3881
  ? {
@@ -3644,9 +3885,12 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
3644
3885
  }
3645
3886
  : c));
3646
3887
  setCommitState({ status: 'error' });
3647
- setTimeout(() => setCommitState(null), 3000);
3888
+ setTimeout(() => {
3889
+ if (scope === gitScopeRef.current)
3890
+ setCommitState(null);
3891
+ }, 3000);
3648
3892
  }
3649
- }, [http, projectId, onCommit]);
3893
+ }, [http, projectId, onCommit, addSystemCard, fetchPendingFiles, t]);
3650
3894
  // ── Revert commit ────────────────────────────────────────────────────────
3651
3895
  /** Reverts a commit by hash. Returns the new revert commit's hash on success. */
3652
3896
  const handleRevertCommit = useCallback(async (hash) => {
@@ -4503,32 +4747,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4503
4747
  }
4504
4748
  else if (id === 'commit') {
4505
4749
  setInputValue('');
4506
- try {
4507
- const status = await http.get(`/projects/${projectId}/git-status`);
4508
- if (!status.data.files.length) {
4509
- addSystemCard(t('ide.chat.commitNoChanges', undefined, { defaultValue: 'No changes to commit.' }));
4510
- return;
4511
- }
4512
- const res = await http.post(`/projects/${projectId}/commit`);
4513
- if (res.data.committed) {
4514
- setCommitCards((prev) => [
4515
- ...prev,
4516
- {
4517
- id: crypto.randomUUID(),
4518
- message: res.data.message ?? '',
4519
- files: res.data.files ?? [],
4520
- timestamp: Date.now(),
4521
- status: 'done',
4522
- hash: res.data.hash,
4523
- },
4524
- ]);
4525
- refreshGitStatus();
4526
- }
4527
- }
4528
- catch (error) {
4529
- logger.warn('Failed to commit changes via /commit command', { error });
4530
- addSystemCard(t('ide.chat.commitError', undefined, { defaultValue: 'Failed to commit changes.' }));
4531
- }
4750
+ await handleCommit();
4532
4751
  }
4533
4752
  else if (id === 'test') {
4534
4753
  setInputValue('');
@@ -4653,6 +4872,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
4653
4872
  messages,
4654
4873
  sendMessage,
4655
4874
  refreshGitStatus,
4875
+ handleCommit,
4656
4876
  openPanelOverlay,
4657
4877
  extraCommandIds,
4658
4878
  canEdit,
@@ -6174,7 +6394,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
6174
6394
  return (_jsx(ScriptsCard, { projectId: projectId, initialQuery: item.card.query ?? '', isLight: isLight, agentName: agentName }, item.card.id));
6175
6395
  }
6176
6396
  if (item.card.variant === 'tests') {
6177
- return (_jsx(TestsCard, { tests: testsList, status: testsStatus, run: testsRun, initialQuery: item.card.query ?? '', canRun: (canRunTests ?? canEdit !== false) && canEdit !== false, onRun: startTestsRun, onCancel: cancelTestsRun, onSkipCurrent: skipCurrentTest, onFix: fixTests, fixDisabledReason: canEdit === false
6397
+ return (_jsx(TestsCard, { tests: testsList, status: testsStatus, run: testsRun, agentName: agentName, initialQuery: item.card.query ?? '', canRun: (canRunTests ?? canEdit !== false) && canEdit !== false, onRun: startTestsRun, onCancel: cancelTestsRun, onSkipCurrent: skipCurrentTest, onFix: fixTests, fixDisabledReason: canEdit === false
6178
6398
  ? t('ide.tests.viewerCannotRun', undefined, {
6179
6399
  defaultValue: 'Only editors can run this project’s tests.',
6180
6400
  })
@@ -7431,7 +7651,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
7431
7651
  minHeight: 36,
7432
7652
  }
7433
7653
  : {}),
7434
- }, children: '✕' })] }), _jsxs("div", { style: { overflowY: 'auto', flex: 1 }, children: [panelOverlay === 'settings' && (_jsx(SettingsCard, { settings: computeSettingsList(), onRunCommand: (commandId) => void executeCommand(commandId), onPrefillInput: (input) => setInputAndCursorEnd(`${input} `), isLight: isLight, agentName: agentName, embedded: true })), panelOverlay === 'skills' && (_jsx(SkillsCard, { projectId: projectId, initialQuery: panelOverlayQuery, onLoad: loadSkill, onCreate: createSkill, loadedSkillPaths: loadedSkillPaths, defaultSkillPaths: defaultSkillPaths, onToggleDefault: toggleDefaultSkill, onResetDefault: resetDefaultSkills, defaultsExplicit: defaultSkillsExplicitRef.current, isLight: isLight, embedded: true })), panelOverlay === 'scripts' && (_jsx(ScriptsCard, { projectId: projectId, initialQuery: panelOverlayQuery, isLight: isLight, agentName: agentName, embedded: true })), panelOverlay === 'tests' && (_jsx(TestsCard, { tests: testsList, status: testsStatus, run: testsRun, initialQuery: panelOverlayQuery, canRun: (canRunTests ?? canEdit !== false) && canEdit !== false, onRun: startTestsRun, onCancel: cancelTestsRun, onSkipCurrent: skipCurrentTest, onFix: fixTests, fixDisabledReason: canEdit === false
7654
+ }, children: '✕' })] }), _jsxs("div", { style: { overflowY: 'auto', flex: 1 }, children: [panelOverlay === 'settings' && (_jsx(SettingsCard, { settings: computeSettingsList(), onRunCommand: (commandId) => void executeCommand(commandId), onPrefillInput: (input) => setInputAndCursorEnd(`${input} `), isLight: isLight, agentName: agentName, embedded: true })), panelOverlay === 'skills' && (_jsx(SkillsCard, { projectId: projectId, initialQuery: panelOverlayQuery, onLoad: loadSkill, onCreate: createSkill, loadedSkillPaths: loadedSkillPaths, defaultSkillPaths: defaultSkillPaths, onToggleDefault: toggleDefaultSkill, onResetDefault: resetDefaultSkills, defaultsExplicit: defaultSkillsExplicitRef.current, isLight: isLight, embedded: true })), panelOverlay === 'scripts' && (_jsx(ScriptsCard, { projectId: projectId, initialQuery: panelOverlayQuery, isLight: isLight, agentName: agentName, embedded: true })), panelOverlay === 'tests' && (_jsx(TestsCard, { tests: testsList, status: testsStatus, run: testsRun, agentName: agentName, initialQuery: panelOverlayQuery, canRun: (canRunTests ?? canEdit !== false) && canEdit !== false, onRun: startTestsRun, onCancel: cancelTestsRun, onSkipCurrent: skipCurrentTest, onFix: fixTests, fixDisabledReason: canEdit === false
7435
7655
  ? t('ide.tests.viewerCannotRun', undefined, {
7436
7656
  defaultValue: 'Only editors can run this project’s tests.',
7437
7657
  })
@@ -7627,7 +7847,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
7627
7847
  }, onMouseLeave: (e) => {
7628
7848
  e.currentTarget.style.opacity = '0.7';
7629
7849
  e.currentTarget.style.background = 'none';
7630
- }, children: _jsx("svg", { width: "13", height: "13", viewBox: "0 0 12 12", style: { display: 'block' }, children: _jsx("path", { d: "M 2.5,6.5 L 6,3 L 9.5,6.5 M 6,3.5 L 6,10", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) }))] }, qm.id))) })] })), workVisible && (_jsxs(WorkCard, { children: [_jsx(PlanCard, { todos: plan, live: planLive, awaitingUser: planAwaitingUser }), _jsx(BackgroundTasksStrip, { tasks: backgroundTasks }), showTests && (_jsx(TestStatusBar, { run: testsRun, tests: testsList, onOpen: () => openTestsBrowser(''), onStop: testsRun.running ? cancelTestsRun : undefined })), showFiles && pendingFiles != null && (_jsx(DockStrip, { molId: "chat-commit-bar", toggleMolId: "chat-commit-toggle", expanded: commitBarExpanded, onToggle: () => setCommitBarExpanded((v) => !v), hideLabel: true, label: t('ide.chat.showChangedFiles', undefined, {
7850
+ }, children: _jsx("svg", { width: "13", height: "13", viewBox: "0 0 12 12", style: { display: 'block' }, children: _jsx("path", { d: "M 2.5,6.5 L 6,3 L 9.5,6.5 M 6,3.5 L 6,10", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) }))] }, qm.id))) })] })), workVisible && (_jsxs(WorkCard, { children: [_jsx(PlanCard, { todos: plan, live: planLive, awaitingUser: planAwaitingUser }), _jsx(BackgroundTasksStrip, { tasks: backgroundTasks }), showTests && (_jsx(TestStatusBar, { run: testsRun, agentName: agentName, tests: testsList, onOpen: () => openTestsBrowser(''), onStop: testsRun.running && !testsRun.external ? cancelTestsRun : undefined })), showFiles && pendingFiles != null && (_jsx(DockStrip, { molId: "chat-commit-bar", toggleMolId: "chat-commit-toggle", expanded: commitBarExpanded, onToggle: () => setCommitBarExpanded((v) => !v), hideLabel: true, label: t('ide.chat.showChangedFiles', undefined, {
7631
7851
  defaultValue: 'Show changed files',
7632
7852
  }), actions: _jsx(_Fragment, { children: isAutoCommitEnabled(autoCommit) &&
7633
7853
  commitState?.status !== 'committing' &&
@@ -8077,7 +8297,7 @@ function ChatInner({ projectId, endpoint, initialMessage, onInitialMessageSent,
8077
8297
  * @param props - Component props (see {@link MessageItemProps}).
8078
8298
  * @returns The rendered chat panel element.
8079
8299
  */
8080
- 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, canSendTeamMessages = true, canShare, shareLinksAllowed, shareLinksRefreshSignal, shareModalContent, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, listTests, runTests, canRunTests, testsAvailable, skipToolCall, className, }) {
8300
+ 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, canSendTeamMessages = true, canShare, shareLinksAllowed, shareLinksRefreshSignal, shareModalContent, buildUpgradeCta, buildHelpUpgradeSection, userAvatar, agentName, productName, version, extraCommands, feedbackUrl, listTests, runTests, watchTestRuns, canRunTests, testsAvailable, skipToolCall, className, }) {
8081
8301
  const cm = getClassMap();
8082
8302
  // Share management may be gated ABOVE canEdit by the host (see
8083
8303
  // ChatPanelProps.canShare) — gates the built-in header share button here and
@@ -8292,7 +8512,7 @@ export function ChatPanel({ projectId, endpoint, initialMessage, onInitialMessag
8292
8512
  textOverflow: 'ellipsis',
8293
8513
  whiteSpace: 'nowrap',
8294
8514
  width: '100%',
8295
- }, 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, canSendTeamMessages: canSendTeamMessages, canShare: shareAllowed, shareLinksAllowed: shareLinksAllowed, shareLinksRefreshSignal: shareLinksRefreshSignal, shareModalContent: shareModalContent, 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, listTests: listTests, runTests: runTests, canRunTests: canRunTests, testsAvailable: testsAvailable, skipToolCall: skipToolCall }, chatKey)] }));
8515
+ }, 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, canSendTeamMessages: canSendTeamMessages, canShare: shareAllowed, shareLinksAllowed: shareLinksAllowed, shareLinksRefreshSignal: shareLinksRefreshSignal, shareModalContent: shareModalContent, 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, listTests: listTests, runTests: runTests, watchTestRuns: watchTestRuns, canRunTests: canRunTests, testsAvailable: testsAvailable, skipToolCall: skipToolCall }, chatKey)] }));
8296
8516
  }
8297
8517
  ChatPanel.displayName = 'ChatPanel';
8298
8518
  //# sourceMappingURL=ChatPanel.js.map