merge: dev into main

# Conflicts:
#	scripts/ci/verify-radix-presence-patch.mjs
This commit is contained in:
777genius 2026-05-26 00:45:20 +03:00
commit 33bbf949ec
60 changed files with 2853 additions and 175 deletions

View file

@ -146,6 +146,14 @@ Public release notes must follow this standard every time:
- Verify actual asset names with `gh release view v<VERSION> --repo 777genius/agent-teams-ai --json assets` before writing links.
- Prefer versioned installer links for release-specific notes: `Agent.Teams.AI-<VERSION>-arm64.dmg`, `Agent.Teams.AI-<VERSION>-x64.dmg`, `Agent.Teams.AI.Setup.<VERSION>.exe`, `Agent.Teams.AI-<VERSION>.AppImage`, `agent-teams-ai_<VERSION>_amd64.deb`, `agent-teams-ai-<VERSION>.x86_64.rpm`, and `agent-teams-ai-<VERSION>.pacman`.
Draft releases must be treated as review artifacts:
- Do not hand off a draft release for review while it still has generated notes, stale notes from an earlier run, or a `Full Changelog`-only body.
- Before telling the user a draft is ready, always edit the draft body with the current release notes template and then re-check it with `gh release view v<VERSION> --repo 777genius/agent-teams-ai --json body,assets,isDraft,isPrerelease,targetCommitish`.
- Confirm the notes describe the exact target commit that the draft was built from, including any commits added after a previous draft attempt.
- If a draft already exists when starting or retrying a release, do not delete it automatically. Ask for explicit permission to delete, replace, or reuse it.
- Never delete a draft release just because the user said to "make a release" or "redo the release". Deleting a draft requires a separate explicit command such as "delete the draft release".
### 4. Required release closeout gate
Do not publish or call a release finished until this is true:
@ -154,7 +162,8 @@ Do not publish or call a release finished until this is true:
- The release body starts with short user-facing notes: what changed, why users care, and the most important fixes.
- The `Downloads` table from the template is present and every link points to the current `v<VERSION>` assets.
- The asset names in the notes match the assets uploaded by `release.yml`.
- `gh release view v<VERSION> --json body,assets,isDraft,isPrerelease` confirms the release is public, has notes, and has the expected installer assets.
- For a draft handoff, `gh release view v<VERSION> --json body,assets,isDraft,isPrerelease,targetCommitish` confirms the release is still a draft, targets the intended commit, has current notes, and has the expected installer assets.
- For final publication, `gh release view v<VERSION> --json body,assets,isDraft,isPrerelease,targetCommitish` confirms the release is public, has current notes, targets the intended commit, and has the expected installer assets.
If a draft was published before notes were written, immediately edit the public release body with `gh release edit`; do not leave a release with only generated notes.

View file

@ -33,6 +33,7 @@
"smoke:codex-runtime-install": "tsx scripts/smoke/codex-runtime-install.ts",
"prebuild": "node ./scripts/ci/verify-radix-presence-patch.mjs && tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
"postbuild": "node ./scripts/ci/verify-radix-renderer-bundle.mjs",
"stage-runtime": "node ./scripts/stage-runtime.mjs",
"clean:runtime": "node ./scripts/stage-runtime.mjs --clean",
"pack:mac": "node ./scripts/electron-builder/dist.mjs --mac",
@ -439,7 +440,13 @@
],
"patchedDependencies": {
"@radix-ui/react-presence@1.1.5": "patches/@radix-ui__react-presence@1.1.5.patch",
"@radix-ui/react-focus-scope@1.1.7": "patches/@radix-ui__react-focus-scope@1.1.7.patch"
"@radix-ui/react-focus-scope@1.1.7": "patches/@radix-ui__react-focus-scope@1.1.7.patch",
"@radix-ui/react-dismissable-layer@1.1.11": "patches/@radix-ui__react-dismissable-layer@1.1.11.patch",
"@radix-ui/react-popper@1.2.8": "patches/@radix-ui__react-popper@1.2.8.patch",
"@radix-ui/react-select@2.2.6": "patches/@radix-ui__react-select@2.2.6.patch",
"@radix-ui/react-tooltip@1.2.8": "patches/@radix-ui__react-tooltip@1.2.8.patch",
"@radix-ui/react-menu@2.1.16": "patches/@radix-ui__react-menu@2.1.16.patch",
"@radix-ui/react-checkbox@1.3.3": "patches/@radix-ui__react-checkbox@1.3.3.patch"
}
},
"knip": {

View file

@ -0,0 +1,178 @@
diff --git a/dist/index.js b/dist/index.js
index 6fb1a27897e543acf531704205f561f98eba0229..5180fb3fbd81c43ad539c3b2bf8047ee845f9602 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -59,6 +59,29 @@ var import_jsx_runtime = require("react/jsx-runtime");
var CHECKBOX_NAME = "Checkbox";
var [createCheckboxContext, createCheckboxScope] = (0, import_react_context.createContextScope)(CHECKBOX_NAME);
var [CheckboxProviderImpl, useCheckboxContext] = createCheckboxContext(CHECKBOX_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
function CheckboxProvider(props) {
const {
__scopeCheckbox,
@@ -82,6 +105,8 @@ function CheckboxProvider(props) {
});
const [control, setControl] = React.useState(null);
const [bubbleInput, setBubbleInput] = React.useState(null);
+ const setControlRef = useGuardedNodeSetter(setControl);
+ const setBubbleInputRef = useGuardedNodeSetter(setBubbleInput);
const hasConsumerStoppedPropagationRef = React.useRef(false);
const isFormControl = control ? !!form || !!control.closest("form") : (
// We set this to true by default so that events bubble to forms without JS (SSR)
@@ -92,7 +117,7 @@ function CheckboxProvider(props) {
disabled,
setChecked,
control,
- setControl,
+ setControl: setControlRef,
name,
form,
value,
@@ -101,7 +126,7 @@ function CheckboxProvider(props) {
defaultChecked: isIndeterminate(defaultChecked) ? false : defaultChecked,
isFormControl,
bubbleInput,
- setBubbleInput
+ setBubbleInput: setBubbleInputRef
};
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
CheckboxProviderImpl,
@@ -121,13 +146,13 @@ var CheckboxTrigger = React.forwardRef(
disabled,
checked,
required,
- setControl,
+ setControl: setControlRef,
setChecked,
hasConsumerStoppedPropagationRef,
isFormControl,
bubbleInput
} = useCheckboxContext(TRIGGER_NAME, __scopeCheckbox);
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setControl);
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setControlRef);
const initialCheckedStateRef = React.useRef(checked);
React.useEffect(() => {
const form = control?.form;
@@ -250,9 +275,9 @@ var CheckboxBubbleInput = React.forwardRef(
value,
form,
bubbleInput,
- setBubbleInput
+ setBubbleInput: setBubbleInputRef
} = useCheckboxContext(BUBBLE_INPUT_NAME, __scopeCheckbox);
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setBubbleInput);
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setBubbleInputRef);
const prevChecked = (0, import_react_use_previous.usePrevious)(checked);
const controlSize = (0, import_react_use_size.useSize)(control);
React.useEffect(() => {
diff --git a/dist/index.mjs b/dist/index.mjs
index 3f718f60b0032a25ad9386082aab9329bd6f5c7a..0688026b0d188d36dc0c4c036c9ededcc8e4f9fe 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -14,6 +14,29 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
var CHECKBOX_NAME = "Checkbox";
var [createCheckboxContext, createCheckboxScope] = createContextScope(CHECKBOX_NAME);
var [CheckboxProviderImpl, useCheckboxContext] = createCheckboxContext(CHECKBOX_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
function CheckboxProvider(props) {
const {
__scopeCheckbox,
@@ -37,6 +60,8 @@ function CheckboxProvider(props) {
});
const [control, setControl] = React.useState(null);
const [bubbleInput, setBubbleInput] = React.useState(null);
+ const setControlRef = useGuardedNodeSetter(setControl);
+ const setBubbleInputRef = useGuardedNodeSetter(setBubbleInput);
const hasConsumerStoppedPropagationRef = React.useRef(false);
const isFormControl = control ? !!form || !!control.closest("form") : (
// We set this to true by default so that events bubble to forms without JS (SSR)
@@ -47,7 +72,7 @@ function CheckboxProvider(props) {
disabled,
setChecked,
control,
- setControl,
+ setControl: setControlRef,
name,
form,
value,
@@ -56,7 +81,7 @@ function CheckboxProvider(props) {
defaultChecked: isIndeterminate(defaultChecked) ? false : defaultChecked,
isFormControl,
bubbleInput,
- setBubbleInput
+ setBubbleInput: setBubbleInputRef
};
return /* @__PURE__ */ jsx(
CheckboxProviderImpl,
@@ -76,13 +101,13 @@ var CheckboxTrigger = React.forwardRef(
disabled,
checked,
required,
- setControl,
+ setControl: setControlRef,
setChecked,
hasConsumerStoppedPropagationRef,
isFormControl,
bubbleInput
} = useCheckboxContext(TRIGGER_NAME, __scopeCheckbox);
- const composedRefs = useComposedRefs(forwardedRef, setControl);
+ const composedRefs = useComposedRefs(forwardedRef, setControlRef);
const initialCheckedStateRef = React.useRef(checked);
React.useEffect(() => {
const form = control?.form;
@@ -205,9 +230,9 @@ var CheckboxBubbleInput = React.forwardRef(
value,
form,
bubbleInput,
- setBubbleInput
+ setBubbleInput: setBubbleInputRef
} = useCheckboxContext(BUBBLE_INPUT_NAME, __scopeCheckbox);
- const composedRefs = useComposedRefs(forwardedRef, setBubbleInput);
+ const composedRefs = useComposedRefs(forwardedRef, setBubbleInputRef);
const prevChecked = usePrevious(checked);
const controlSize = useSize(control);
React.useEffect(() => {

View file

@ -0,0 +1,70 @@
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -71,9 +71,30 @@ var DismissableLayer = React.forwardRef(
} = props;
const context = React.useContext(DismissableLayerContext);
const [node, setNode] = React.useState(null);
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
const ownerDocument = node?.ownerDocument ?? globalThis?.document;
const [, force] = React.useState({});
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node2) => setNode(node2));
+ const setNodeRef = React.useCallback((node2) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node2) {
+ syncNode(node2);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, []);
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setNodeRef);
const layers = Array.from(context.layers);
const [highestLayerWithOutsidePointerEventsDisabled] = [...context.layersWithOutsidePointerEventsDisabled].slice(-1);
const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf(highestLayerWithOutsidePointerEventsDisabled);
diff --git a/dist/index.mjs b/dist/index.mjs
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -33,9 +33,30 @@ var DismissableLayer = React.forwardRef(
} = props;
const context = React.useContext(DismissableLayerContext);
const [node, setNode] = React.useState(null);
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
const ownerDocument = node?.ownerDocument ?? globalThis?.document;
const [, force] = React.useState({});
- const composedRefs = useComposedRefs(forwardedRef, (node2) => setNode(node2));
+ const setNodeRef = React.useCallback((node2) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node2) {
+ syncNode(node2);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, []);
+ const composedRefs = useComposedRefs(forwardedRef, setNodeRef);
const layers = Array.from(context.layers);
const [highestLayerWithOutsidePointerEventsDisabled] = [...context.layersWithOutsidePointerEventsDisabled].slice(-1);
const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf(highestLayerWithOutsidePointerEventsDisabled);

View file

@ -0,0 +1,150 @@
diff --git a/dist/index.js b/dist/index.js
index 4d102d0cf4c9f82dcf13a384e5af0af36a4285d3..252229df648edad3752bd8db172ad5aa52a527de 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -113,10 +113,34 @@ var usePopperScope = (0, import_react_popper.createPopperScope)();
var useRovingFocusGroupScope = (0, import_react_roving_focus.createRovingFocusGroupScope)();
var [MenuProvider, useMenuContext] = createMenuContext(MENU_NAME);
var [MenuRootProvider, useMenuRootContext] = createMenuContext(MENU_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var Menu = (props) => {
const { __scopeMenu, open = false, children, dir, onOpenChange, modal = true } = props;
const popperScope = usePopperScope(__scopeMenu);
const [content, setContent] = React.useState(null);
+ const setContentRef = useGuardedNodeSetter(setContent);
const isUsingKeyboardRef = React.useRef(false);
const handleOpenChange = (0, import_react_use_callback_ref.useCallbackRef)(onOpenChange);
const direction = (0, import_react_direction.useDirection)(dir);
@@ -141,7 +165,7 @@ var Menu = (props) => {
open,
onOpenChange: handleOpenChange,
content,
- onContentChange: setContent,
+ onContentChange: setContentRef,
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
MenuRootProvider,
{
@@ -653,6 +677,8 @@ var MenuSub = (props) => {
const popperScope = usePopperScope(__scopeMenu);
const [trigger, setTrigger] = React.useState(null);
const [content, setContent] = React.useState(null);
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
+ const setContentRef = useGuardedNodeSetter(setContent);
const handleOpenChange = (0, import_react_use_callback_ref.useCallbackRef)(onOpenChange);
React.useEffect(() => {
if (parentMenuContext.open === false) handleOpenChange(false);
@@ -665,7 +691,7 @@ var MenuSub = (props) => {
open,
onOpenChange: handleOpenChange,
content,
- onContentChange: setContent,
+ onContentChange: setContentRef,
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
MenuSubProvider,
{
@@ -673,7 +699,7 @@ var MenuSub = (props) => {
contentId: (0, import_react_id.useId)(),
triggerId: (0, import_react_id.useId)(),
trigger,
- onTriggerChange: setTrigger,
+ onTriggerChange: setTriggerRef,
children
}
)
diff --git a/dist/index.mjs b/dist/index.mjs
index 10eefb0533ee4fa16b7f0e42671969c2c4464835..680d56317917bff5bec1added766c553c5c00c52 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -46,10 +46,34 @@ var usePopperScope = createPopperScope();
var useRovingFocusGroupScope = createRovingFocusGroupScope();
var [MenuProvider, useMenuContext] = createMenuContext(MENU_NAME);
var [MenuRootProvider, useMenuRootContext] = createMenuContext(MENU_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var Menu = (props) => {
const { __scopeMenu, open = false, children, dir, onOpenChange, modal = true } = props;
const popperScope = usePopperScope(__scopeMenu);
const [content, setContent] = React.useState(null);
+ const setContentRef = useGuardedNodeSetter(setContent);
const isUsingKeyboardRef = React.useRef(false);
const handleOpenChange = useCallbackRef(onOpenChange);
const direction = useDirection(dir);
@@ -74,7 +98,7 @@ var Menu = (props) => {
open,
onOpenChange: handleOpenChange,
content,
- onContentChange: setContent,
+ onContentChange: setContentRef,
children: /* @__PURE__ */ jsx(
MenuRootProvider,
{
@@ -586,6 +610,8 @@ var MenuSub = (props) => {
const popperScope = usePopperScope(__scopeMenu);
const [trigger, setTrigger] = React.useState(null);
const [content, setContent] = React.useState(null);
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
+ const setContentRef = useGuardedNodeSetter(setContent);
const handleOpenChange = useCallbackRef(onOpenChange);
React.useEffect(() => {
if (parentMenuContext.open === false) handleOpenChange(false);
@@ -598,7 +624,7 @@ var MenuSub = (props) => {
open,
onOpenChange: handleOpenChange,
content,
- onContentChange: setContent,
+ onContentChange: setContentRef,
children: /* @__PURE__ */ jsx(
MenuSubProvider,
{
@@ -606,7 +632,7 @@ var MenuSub = (props) => {
contentId: useId(),
triggerId: useId(),
trigger,
- onTriggerChange: setTrigger,
+ onTriggerChange: setTriggerRef,
children
}
)

View file

@ -0,0 +1,88 @@
diff --git a/dist/index.js b/dist/index.js
index 5cb1fd007d32021ad950d961dd4a0868576a2c0d..795a24fe0e75aaa042cad0a376a776f3e1436269 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -61,6 +61,29 @@ var ALIGN_OPTIONS = ["start", "center", "end"];
var POPPER_NAME = "Popper";
var [createPopperContext, createPopperScope] = (0, import_react_context.createContextScope)(POPPER_NAME);
var [PopperProvider, usePopperContext] = createPopperContext(POPPER_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var Popper = (props) => {
const { __scopePopper, children } = props;
const [anchor, setAnchor] = React.useState(null);
@@ -108,7 +131,8 @@ var PopperContent = React.forwardRef(
} = props;
const context = usePopperContext(CONTENT_NAME, __scopePopper);
const [content, setContent] = React.useState(null);
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContent(node));
+ const setContentRef = useGuardedNodeSetter(setContent);
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContentRef);
const [arrow, setArrow] = React.useState(null);
const arrowSize = (0, import_react_use_size.useSize)(arrow);
const arrowWidth = arrowSize?.width ?? 0;
diff --git a/dist/index.mjs b/dist/index.mjs
index 9f84984eab84e32d20d6c052217da0e3b8374b40..0ffdb6313708ab3113c0ce40fe4519b6b605deff 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -26,6 +26,29 @@ var ALIGN_OPTIONS = ["start", "center", "end"];
var POPPER_NAME = "Popper";
var [createPopperContext, createPopperScope] = createContextScope(POPPER_NAME);
var [PopperProvider, usePopperContext] = createPopperContext(POPPER_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var Popper = (props) => {
const { __scopePopper, children } = props;
const [anchor, setAnchor] = React.useState(null);
@@ -73,7 +96,8 @@ var PopperContent = React.forwardRef(
} = props;
const context = usePopperContext(CONTENT_NAME, __scopePopper);
const [content, setContent] = React.useState(null);
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node));
+ const setContentRef = useGuardedNodeSetter(setContent);
+ const composedRefs = useComposedRefs(forwardedRef, setContentRef);
const [arrow, setArrow] = React.useState(null);
const arrowSize = useSize(arrow);
const arrowWidth = arrowSize?.width ?? 0;

View file

@ -0,0 +1,280 @@
diff --git a/dist/index.js b/dist/index.js
index dc37ac4a018a086c4244a09a67215dbaa9b4de65..fc80522666f91087ce1bce3a34844b17c74cdf6c 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -104,6 +104,29 @@ var [createSelectContext, createSelectScope] = (0, import_react_context.createCo
var usePopperScope = (0, import_react_popper.createPopperScope)();
var [SelectProvider, useSelectContext] = createSelectContext(SELECT_NAME);
var [SelectNativeOptionsProvider, useSelectNativeOptionsContext] = createSelectContext(SELECT_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var Select = (props) => {
const {
__scopeSelect,
@@ -124,6 +147,8 @@ var Select = (props) => {
const popperScope = usePopperScope(__scopeSelect);
const [trigger, setTrigger] = React.useState(null);
const [valueNode, setValueNode] = React.useState(null);
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
+ const setValueNodeRef = useGuardedNodeSetter(setValueNode);
const [valueNodeHasChildren, setValueNodeHasChildren] = React.useState(false);
const direction = (0, import_react_direction.useDirection)(dir);
const [open, setOpen] = (0, import_react_use_controllable_state.useControllableState)({
@@ -148,9 +173,9 @@ var Select = (props) => {
required,
scope: __scopeSelect,
trigger,
- onTriggerChange: setTrigger,
+ onTriggerChange: setTriggerRef,
valueNode,
- onValueNodeChange: setValueNode,
+ onValueNodeChange: setValueNodeRef,
valueNodeHasChildren,
onValueNodeHasChildrenChange: setValueNodeHasChildren,
contentId: (0, import_react_id.useId)(),
@@ -366,11 +391,15 @@ var SelectContentImpl = React.forwardRef(
const context = useSelectContext(CONTENT_NAME, __scopeSelect);
const [content, setContent] = React.useState(null);
const [viewport, setViewport] = React.useState(null);
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContent(node));
+ const setContentRef = useGuardedNodeSetter(setContent);
+ const setViewportRef = useGuardedNodeSetter(setViewport);
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContentRef);
const [selectedItem, setSelectedItem] = React.useState(null);
const [selectedItemText, setSelectedItemText] = React.useState(
null
);
+ const setSelectedItemRef = useGuardedNodeSetter(setSelectedItem);
+ const setSelectedItemTextRef = useGuardedNodeSetter(setSelectedItemText);
const getItems = useCollection(__scopeSelect);
const [isPositioned, setIsPositioned] = React.useState(false);
const firstValidItemFoundRef = React.useRef(false);
@@ -456,11 +485,11 @@ var SelectContentImpl = React.forwardRef(
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
const isSelectedItem = context.value !== void 0 && context.value === value;
if (isSelectedItem || isFirstValidItem) {
- setSelectedItem(node);
+ setSelectedItemRef(node);
if (isFirstValidItem) firstValidItemFoundRef.current = true;
}
},
- [context.value]
+ [context.value, setSelectedItemRef]
);
const handleItemLeave = React.useCallback(() => content?.focus(), [content]);
const itemTextRefCallback = React.useCallback(
@@ -468,10 +497,10 @@ var SelectContentImpl = React.forwardRef(
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
const isSelectedItem = context.value !== void 0 && context.value === value;
if (isSelectedItem || isFirstValidItem) {
- setSelectedItemText(node);
+ setSelectedItemTextRef(node);
}
},
- [context.value]
+ [context.value, setSelectedItemTextRef]
);
const SelectPosition = position === "popper" ? SelectPopperPosition : SelectItemAlignedPosition;
const popperContentProps = SelectPosition === SelectPopperPosition ? {
@@ -492,7 +521,7 @@ var SelectContentImpl = React.forwardRef(
scope: __scopeSelect,
content,
viewport,
- onViewportChange: setViewport,
+ onViewportChange: setViewportRef,
itemRefCallback,
selectedItem,
onItemLeave: handleItemLeave,
@@ -580,7 +609,9 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
const contentContext = useSelectContentContext(CONTENT_NAME, __scopeSelect);
const [contentWrapper, setContentWrapper] = React.useState(null);
const [content, setContent] = React.useState(null);
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContent(node));
+ const setContentWrapperRef = useGuardedNodeSetter(setContentWrapper);
+ const setContentRef = useGuardedNodeSetter(setContent);
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContentRef);
const getItems = useCollection(__scopeSelect);
const shouldExpandOnScrollRef = React.useRef(false);
const shouldRepositionRef = React.useRef(true);
@@ -709,7 +740,7 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
"div",
{
- ref: setContentWrapper,
+ ref: setContentWrapperRef,
style: {
display: "flex",
flexDirection: "column",
@@ -971,9 +1002,10 @@ var SelectItemText = React.forwardRef(
const itemContext = useSelectItemContext(ITEM_TEXT_NAME, __scopeSelect);
const nativeOptionsContext = useSelectNativeOptionsContext(ITEM_TEXT_NAME, __scopeSelect);
const [itemTextNode, setItemTextNode] = React.useState(null);
+ const setItemTextNodeRef = useGuardedNodeSetter(setItemTextNode);
const composedRefs = (0, import_react_compose_refs.useComposedRefs)(
forwardedRef,
- (node) => setItemTextNode(node),
+ setItemTextNodeRef,
itemContext.onItemTextChange,
(node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
);
diff --git a/dist/index.mjs b/dist/index.mjs
index f9b94f39dfddef678ef3086354f8d7413ae27e52..4a53ec0d65aa051b95e3e8ea4aff4fdd52a17406 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -37,6 +37,29 @@ var [createSelectContext, createSelectScope] = createContextScope(SELECT_NAME, [
var usePopperScope = createPopperScope();
var [SelectProvider, useSelectContext] = createSelectContext(SELECT_NAME);
var [SelectNativeOptionsProvider, useSelectNativeOptionsContext] = createSelectContext(SELECT_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var Select = (props) => {
const {
__scopeSelect,
@@ -57,6 +80,8 @@ var Select = (props) => {
const popperScope = usePopperScope(__scopeSelect);
const [trigger, setTrigger] = React.useState(null);
const [valueNode, setValueNode] = React.useState(null);
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
+ const setValueNodeRef = useGuardedNodeSetter(setValueNode);
const [valueNodeHasChildren, setValueNodeHasChildren] = React.useState(false);
const direction = useDirection(dir);
const [open, setOpen] = useControllableState({
@@ -81,9 +106,9 @@ var Select = (props) => {
required,
scope: __scopeSelect,
trigger,
- onTriggerChange: setTrigger,
+ onTriggerChange: setTriggerRef,
valueNode,
- onValueNodeChange: setValueNode,
+ onValueNodeChange: setValueNodeRef,
valueNodeHasChildren,
onValueNodeHasChildrenChange: setValueNodeHasChildren,
contentId: useId(),
@@ -299,11 +324,15 @@ var SelectContentImpl = React.forwardRef(
const context = useSelectContext(CONTENT_NAME, __scopeSelect);
const [content, setContent] = React.useState(null);
const [viewport, setViewport] = React.useState(null);
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node));
+ const setContentRef = useGuardedNodeSetter(setContent);
+ const setViewportRef = useGuardedNodeSetter(setViewport);
+ const composedRefs = useComposedRefs(forwardedRef, setContentRef);
const [selectedItem, setSelectedItem] = React.useState(null);
const [selectedItemText, setSelectedItemText] = React.useState(
null
);
+ const setSelectedItemRef = useGuardedNodeSetter(setSelectedItem);
+ const setSelectedItemTextRef = useGuardedNodeSetter(setSelectedItemText);
const getItems = useCollection(__scopeSelect);
const [isPositioned, setIsPositioned] = React.useState(false);
const firstValidItemFoundRef = React.useRef(false);
@@ -389,11 +418,11 @@ var SelectContentImpl = React.forwardRef(
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
const isSelectedItem = context.value !== void 0 && context.value === value;
if (isSelectedItem || isFirstValidItem) {
- setSelectedItem(node);
+ setSelectedItemRef(node);
if (isFirstValidItem) firstValidItemFoundRef.current = true;
}
},
- [context.value]
+ [context.value, setSelectedItemRef]
);
const handleItemLeave = React.useCallback(() => content?.focus(), [content]);
const itemTextRefCallback = React.useCallback(
@@ -401,10 +430,10 @@ var SelectContentImpl = React.forwardRef(
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
const isSelectedItem = context.value !== void 0 && context.value === value;
if (isSelectedItem || isFirstValidItem) {
- setSelectedItemText(node);
+ setSelectedItemTextRef(node);
}
},
- [context.value]
+ [context.value, setSelectedItemTextRef]
);
const SelectPosition = position === "popper" ? SelectPopperPosition : SelectItemAlignedPosition;
const popperContentProps = SelectPosition === SelectPopperPosition ? {
@@ -425,7 +454,7 @@ var SelectContentImpl = React.forwardRef(
scope: __scopeSelect,
content,
viewport,
- onViewportChange: setViewport,
+ onViewportChange: setViewportRef,
itemRefCallback,
selectedItem,
onItemLeave: handleItemLeave,
@@ -513,7 +542,9 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
const contentContext = useSelectContentContext(CONTENT_NAME, __scopeSelect);
const [contentWrapper, setContentWrapper] = React.useState(null);
const [content, setContent] = React.useState(null);
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node));
+ const setContentWrapperRef = useGuardedNodeSetter(setContentWrapper);
+ const setContentRef = useGuardedNodeSetter(setContent);
+ const composedRefs = useComposedRefs(forwardedRef, setContentRef);
const getItems = useCollection(__scopeSelect);
const shouldExpandOnScrollRef = React.useRef(false);
const shouldRepositionRef = React.useRef(true);
@@ -642,7 +673,7 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
children: /* @__PURE__ */ jsx(
"div",
{
- ref: setContentWrapper,
+ ref: setContentWrapperRef,
style: {
display: "flex",
flexDirection: "column",
@@ -904,9 +935,10 @@ var SelectItemText = React.forwardRef(
const itemContext = useSelectItemContext(ITEM_TEXT_NAME, __scopeSelect);
const nativeOptionsContext = useSelectNativeOptionsContext(ITEM_TEXT_NAME, __scopeSelect);
const [itemTextNode, setItemTextNode] = React.useState(null);
+ const setItemTextNodeRef = useGuardedNodeSetter(setItemTextNode);
const composedRefs = useComposedRefs(
forwardedRef,
- (node) => setItemTextNode(node),
+ setItemTextNodeRef,
itemContext.onItemTextChange,
(node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
);

View file

@ -0,0 +1,102 @@
diff --git a/dist/index.js b/dist/index.js
index 2d0d314a00082c458d2f551d715572cd1fa1b5c3..1954a52d7e25a2d733d57f2f32113af8a69a18bf 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -71,6 +71,29 @@ var PROVIDER_NAME = "TooltipProvider";
var DEFAULT_DELAY_DURATION = 700;
var TOOLTIP_OPEN = "tooltip.open";
var [TooltipProviderContextProvider, useTooltipProviderContext] = createTooltipContext(PROVIDER_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var TooltipProvider = (props) => {
const {
__scopeTooltip,
@@ -128,6 +151,7 @@ var Tooltip = (props) => {
const providerContext = useTooltipProviderContext(TOOLTIP_NAME, props.__scopeTooltip);
const popperScope = usePopperScope(__scopeTooltip);
const [trigger, setTrigger] = React.useState(null);
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
const contentId = (0, import_react_id.useId)();
const openTimerRef = React.useRef(0);
const disableHoverableContent = disableHoverableContentProp ?? providerContext.disableHoverableContent;
@@ -185,7 +209,7 @@ var Tooltip = (props) => {
open,
stateAttribute,
trigger,
- onTriggerChange: setTrigger,
+ onTriggerChange: setTriggerRef,
onTriggerEnter: React.useCallback(() => {
if (providerContext.isOpenDelayedRef.current) handleDelayedOpen();
else handleOpen();
diff --git a/dist/index.mjs b/dist/index.mjs
index 568389bf3ce8123fa6de6d298878b32d613e25cf..a809f654d8b2d84be0f5747384a180a0864c7d44 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -24,6 +24,29 @@ var PROVIDER_NAME = "TooltipProvider";
var DEFAULT_DELAY_DURATION = 700;
var TOOLTIP_OPEN = "tooltip.open";
var [TooltipProviderContextProvider, useTooltipProviderContext] = createTooltipContext(PROVIDER_NAME);
+function useGuardedNodeSetter(setNode) {
+ const nodeRef = React.useRef(null);
+ const nodeCleanupGenerationRef = React.useRef(0);
+ return React.useCallback((node) => {
+ const syncNode = (nextNode) => {
+ if (nodeRef.current === nextNode) return;
+ nodeRef.current = nextNode;
+ setNode(nextNode);
+ };
+ nodeCleanupGenerationRef.current += 1;
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
+ if (node) {
+ syncNode(node);
+ return;
+ }
+ queueMicrotask(() => {
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
+ return;
+ }
+ syncNode(null);
+ });
+ }, [setNode]);
+}
var TooltipProvider = (props) => {
const {
__scopeTooltip,
@@ -81,6 +104,7 @@ var Tooltip = (props) => {
const providerContext = useTooltipProviderContext(TOOLTIP_NAME, props.__scopeTooltip);
const popperScope = usePopperScope(__scopeTooltip);
const [trigger, setTrigger] = React.useState(null);
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
const contentId = useId();
const openTimerRef = React.useRef(0);
const disableHoverableContent = disableHoverableContentProp ?? providerContext.disableHoverableContent;
@@ -138,7 +162,7 @@ var Tooltip = (props) => {
open,
stateAttribute,
trigger,
- onTriggerChange: setTrigger,
+ onTriggerChange: setTriggerRef,
onTriggerEnter: React.useCallback(() => {
if (providerContext.isOpenDelayedRef.current) handleDelayedOpen();
else handleOpen();

View file

@ -48,12 +48,30 @@ overrides:
yaml: 2.9.0
patchedDependencies:
'@radix-ui/react-checkbox@1.3.3':
hash: 80a226b85c8e8a94674990c0eedf6d3709af6649df83bbed6f3ae930783e6d7d
path: patches/@radix-ui__react-checkbox@1.3.3.patch
'@radix-ui/react-dismissable-layer@1.1.11':
hash: c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3
path: patches/@radix-ui__react-dismissable-layer@1.1.11.patch
'@radix-ui/react-focus-scope@1.1.7':
hash: cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829
path: patches/@radix-ui__react-focus-scope@1.1.7.patch
'@radix-ui/react-menu@2.1.16':
hash: a59dc5aa0792599fc395b6db50cae81f2bcdb76e9d28205b12ce88906d3aeaa0
path: patches/@radix-ui__react-menu@2.1.16.patch
'@radix-ui/react-popper@1.2.8':
hash: bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404
path: patches/@radix-ui__react-popper@1.2.8.patch
'@radix-ui/react-presence@1.1.5':
hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e
path: patches/@radix-ui__react-presence@1.1.5.patch
'@radix-ui/react-select@2.2.6':
hash: eea50c1407feb65af64720d0aadd2b534ca7743474d9204e0fc1d6b93e5f31f4
path: patches/@radix-ui__react-select@2.2.6.patch
'@radix-ui/react-tooltip@1.2.8':
hash: 92cb648a695f616d3b7222b90053cb36e162bab4303abf0fe39b517e1d9dd6b8
path: patches/@radix-ui__react-tooltip@1.2.8.patch
importers:
@ -163,7 +181,7 @@ importers:
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 1.3.3(patch_hash=80a226b85c8e8a94674990c0eedf6d3709af6649df83bbed6f3ae930783e6d7d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -190,7 +208,7 @@ importers:
version: 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 2.2.6(patch_hash=eea50c1407feb65af64720d0aadd2b534ca7743474d9204e0fc1d6b93e5f31f4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
@ -199,7 +217,7 @@ importers:
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 1.2.8(patch_hash=92cb648a695f616d3b7222b90053cb36e162bab4303abf0fe39b517e1d9dd6b8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@sentry/electron':
specifier: ^7.10.0
version: 7.10.0
@ -14465,7 +14483,7 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@radix-ui/react-checkbox@1.3.3(patch_hash=80a226b85c8e8a94674990c0eedf6d3709af6649df83bbed6f3ae930783e6d7d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
@ -14519,7 +14537,7 @@ snapshots:
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(patch_hash=a59dc5aa0792599fc395b6db50cae81f2bcdb76e9d28205b12ce88906d3aeaa0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
@ -14540,7 +14558,7 @@ snapshots:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(patch_hash=c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
@ -14563,7 +14581,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@radix-ui/react-dismissable-layer@1.1.11(patch_hash=c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
@ -14582,7 +14600,7 @@ snapshots:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(patch_hash=a59dc5aa0792599fc395b6db50cae81f2bcdb76e9d28205b12ce88906d3aeaa0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
@ -14613,8 +14631,8 @@ snapshots:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(patch_hash=c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(patch_hash=bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -14641,18 +14659,18 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@radix-ui/react-menu@2.1.16(patch_hash=a59dc5aa0792599fc395b6db50cae81f2bcdb76e9d28205b12ce88906d3aeaa0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(patch_hash=c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(patch_hash=bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -14672,11 +14690,11 @@ snapshots:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(patch_hash=c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(patch_hash=bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -14690,7 +14708,7 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@radix-ui/react-popper@1.2.8(patch_hash=bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -14763,7 +14781,7 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@radix-ui/react-select@2.2.6(patch_hash=eea50c1407feb65af64720d0aadd2b534ca7743474d9204e0fc1d6b93e5f31f4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
@ -14771,11 +14789,11 @@ snapshots:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(patch_hash=c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(patch_hash=bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
@ -14822,14 +14840,14 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@radix-ui/react-tooltip@1.2.8(patch_hash=92cb648a695f616d3b7222b90053cb36e162bab4303abf0fe39b517e1d9dd6b8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(patch_hash=c9a989c59554b1942cebb761daaaeb964304d965d236cb0bd2142c4d8f8032c3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(patch_hash=bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)

View file

@ -4,26 +4,103 @@ import { dirname, join } from 'node:path';
const require = createRequire(import.meta.url);
const entrypointPath = require.resolve('@radix-ui/react-presence');
const packageRoot = dirname(dirname(entrypointPath));
const filesToCheck = ['dist/index.js', 'dist/index.mjs'];
const patchChecks = [
{
packageName: '@radix-ui/react-presence',
requiredMarkers: ['nodeCleanupGenerationRef', 'syncNode(null)'],
forbiddenSnippets: ['setNode(node2);'],
},
{
packageName: '@radix-ui/react-focus-scope',
resolverFromPackage: '@radix-ui/react-dialog',
requiredMarkers: ['containerCleanupGenerationRef', 'syncContainer(null)'],
forbiddenSnippets: ['(node) => setContainer(node)'],
},
{
packageName: '@radix-ui/react-dismissable-layer',
resolverFromPackage: '@radix-ui/react-dialog',
requiredMarkers: ['nodeCleanupGenerationRef', 'syncNode(null)'],
forbiddenSnippets: ['(node2) => setNode(node2)'],
},
{
packageName: '@radix-ui/react-select',
requiredMarkers: ['useGuardedNodeSetter', 'setContentRef', 'setItemTextNodeRef'],
forbiddenSnippets: [
'(node) => setContent(node)',
'(node) => setItemTextNode(node)',
'onTriggerChange: setTrigger,',
'onValueNodeChange: setValueNode,',
'onViewportChange: setViewport,',
'ref: setContentWrapper,',
'setSelectedItem(node);',
'setSelectedItemText(node);',
],
},
{
packageName: '@radix-ui/react-popper',
resolverFromPackage: '@radix-ui/react-select',
requiredMarkers: ['useGuardedNodeSetter', 'setContentRef'],
forbiddenSnippets: ['(node) => setContent(node)'],
},
{
packageName: '@radix-ui/react-tooltip',
requiredMarkers: ['useGuardedNodeSetter', 'setTriggerRef'],
forbiddenSnippets: ['onTriggerChange: setTrigger,'],
},
{
packageName: '@radix-ui/react-menu',
resolverFromPackage: '@radix-ui/react-dropdown-menu',
requiredMarkers: ['useGuardedNodeSetter', 'setContentRef', 'setTriggerRef'],
forbiddenSnippets: ['onContentChange: setContent,', 'onTriggerChange: setTrigger,'],
},
{
packageName: '@radix-ui/react-checkbox',
requiredMarkers: ['useGuardedNodeSetter', 'setControlRef', 'setBubbleInputRef'],
forbiddenSnippets: [
'useComposedRefs(forwardedRef, setControl)',
'useComposedRefs(forwardedRef, setBubbleInput)',
'useComposedRefs)(forwardedRef, setControl)',
'useComposedRefs)(forwardedRef, setBubbleInput)',
],
},
];
function resolvePackageRoot({ packageName, resolverFromPackage }) {
const packageRequire = resolverFromPackage
? createRequire(require.resolve(resolverFromPackage))
: require;
const entrypointPath = packageRequire.resolve(packageName);
return dirname(dirname(entrypointPath));
}
const requiredMarkers = ['nodeCleanupGenerationRef', 'syncNode(null)'];
const missing = [];
for (const relativePath of filesToCheck) {
const filePath = join(packageRoot, relativePath);
const source = readFileSync(filePath, 'utf8');
const missingMarkers = requiredMarkers.filter((marker) => !source.includes(marker));
if (missingMarkers.length > 0) {
missing.push(`${relativePath}: ${missingMarkers.join(', ')}`);
for (const check of patchChecks) {
const packageRoot = resolvePackageRoot(check);
for (const relativePath of filesToCheck) {
const filePath = join(packageRoot, relativePath);
const source = readFileSync(filePath, 'utf8');
const missingMarkers = check.requiredMarkers.filter((marker) => !source.includes(marker));
if (missingMarkers.length > 0) {
missing.push(`${check.packageName}/${relativePath}: ${missingMarkers.join(', ')}`);
}
const forbiddenSnippets = check.forbiddenSnippets ?? [];
const presentForbiddenSnippets = forbiddenSnippets.filter((snippet) => source.includes(snippet));
if (presentForbiddenSnippets.length > 0) {
missing.push(
`${check.packageName}/${relativePath}: forbidden snippets still present: ${presentForbiddenSnippets.join(', ')}`
);
}
}
}
if (missing.length > 0) {
console.error(
[
'@radix-ui/react-presence is installed without the local React 19 Presence patch.',
'Radix is installed without one or more local React 19 ref-cleanup patches.',
'Run `pnpm install --force` before building production artifacts.',
'',
...missing,

View file

@ -0,0 +1,86 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const assetsDir = join(process.cwd(), 'out', 'renderer', 'assets');
const rendererBundles = readdirSync(assetsDir)
.filter((entry) => entry.endsWith('.js'))
.sort();
if (rendererBundles.length === 0) {
console.error(
[
'No renderer JavaScript bundles found under out/renderer/assets.',
'Run `pnpm build` before packaging production artifacts.',
].join('\n')
);
process.exit(1);
}
const requiredMarkers = [
'nodeCleanupGenerationRef',
'syncNode(null)',
'useGuardedNodeSetter',
'setTriggerRef',
'setValueNodeRef',
'setContentRef',
'setViewportRef',
'setSelectedItemRef',
'setSelectedItemTextRef',
'setItemTextNodeRef',
'setControlRef',
'setBubbleInputRef',
];
const forbiddenSnippets = [
'(node) => setContent(node)',
'(node2) => setNode(node2)',
'(node) => setItemTextNode(node)',
'onContentChange: setContent,',
'onTriggerChange: setTrigger,',
'onValueNodeChange: setValueNode,',
'onViewportChange: setViewport,',
'ref: setContentWrapper,',
'setSelectedItem(node);',
'setSelectedItemText(node);',
'useComposedRefs(forwardedRef, setControl)',
'useComposedRefs(forwardedRef, setBubbleInput)',
'useComposedRefs)(forwardedRef, setControl)',
'useComposedRefs)(forwardedRef, setBubbleInput)',
];
const failures = [];
const bundleSources = new Map();
let combinedSource = '';
for (const bundleName of rendererBundles) {
const bundlePath = join(assetsDir, bundleName);
const source = readFileSync(bundlePath, 'utf8');
bundleSources.set(bundleName, source);
combinedSource += source;
}
const missingMarkers = requiredMarkers.filter((marker) => !combinedSource.includes(marker));
if (missingMarkers.length > 0) {
failures.push(`renderer bundles: missing markers: ${missingMarkers.join(', ')}`);
}
for (const [bundleName, source] of bundleSources) {
const presentForbiddenSnippets = forbiddenSnippets.filter((snippet) => source.includes(snippet));
if (presentForbiddenSnippets.length > 0) {
failures.push(
`${bundleName}: forbidden snippets still present: ${presentForbiddenSnippets.join(', ')}`
);
}
}
if (failures.length > 0) {
console.error(
[
'Renderer bundle was built without the complete Radix React 19 ref-cleanup guards.',
'',
...failures,
].join('\n')
);
process.exit(1);
}

View file

@ -1,13 +1,32 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs');
export { buildElectronBuilderInvocations };
async function runRendererBundleGuard() {
const guardPath = fileURLToPath(new URL('../ci/verify-radix-renderer-bundle.mjs', import.meta.url));
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [guardPath], {
stdio: 'inherit',
env: process.env,
});
child.on('error', reject);
child.on('exit', (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`renderer bundle guard failed with ${signal ?? `exit code ${code}`}`));
});
});
}
async function runElectronBuilder(args) {
const cliPath = require.resolve('electron-builder/cli.js');
await new Promise((resolve, reject) => {
@ -41,6 +60,8 @@ async function main(argv) {
return;
}
await runRendererBundleGuard();
for (const invocation of invocations) {
await runElectronBuilder(invocation.args);
}

View file

@ -1,14 +1,29 @@
import type { AgentAttachmentCapability, AgentAttachmentCapabilityTarget } from './types';
import type {
AgentAttachmentCapability,
AgentAttachmentCapabilityTarget,
ProviderImageMimeType,
} from './types';
const DEFAULT_IMAGE_BYTES_PER_PROVIDER = 4 * 1024 * 1024;
const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024;
const DEFAULT_FILE_BYTES_PER_PROVIDER = 4 * 1024 * 1024;
function supportedImagesOnly(displayText: string): AgentAttachmentCapability {
export const NATIVE_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
export const CLAUDE_IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
] as const;
function supportedImagesOnly(
displayText: string,
supportedImageMimeTypes: readonly ProviderImageMimeType[] = NATIVE_IMAGE_MIME_TYPES
): AgentAttachmentCapability {
return {
supportsImages: true,
supportsFiles: false,
supportedImageMimeTypes: ['image/png', 'image/jpeg'],
supportedImageMimeTypes: [...supportedImageMimeTypes],
supportedFileMimeTypes: [],
maxImages: 5,
maxFiles: 0,
@ -24,7 +39,7 @@ function supportedImagesOnly(displayText: string): AgentAttachmentCapability {
function supportedClaude(displayText: string): AgentAttachmentCapability {
return {
...supportedImagesOnly(displayText),
...supportedImagesOnly(displayText, CLAUDE_IMAGE_MIME_TYPES),
supportsFiles: true,
supportedFileMimeTypes: ['application/pdf', 'text/*'],
maxFiles: 5,

View file

@ -2,8 +2,8 @@ export const AGENT_ATTACHMENT_SCHEMA_VERSION = 1 as const;
export type AgentAttachmentKind = 'image' | 'file' | 'unsupported';
export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
export type ProviderImageMimeType = 'image/png' | 'image/jpeg';
export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
export type ProviderImageMimeType = AgentImageMimeType;
export type ProviderFileMimeType = 'application/pdf' | 'text/*';
export type AttachmentDeliveryFailureCode =

View file

@ -90,6 +90,44 @@ describe('agent attachment validation', () => {
expect(result).toEqual({ ok: true, warnings: [] });
});
it('allows Claude GIF image delivery without requiring optimization support', () => {
const capability = resolveAgentAttachmentCapability({
providerId: 'anthropic',
model: 'claude-haiku-4-5',
});
const result = validateAttachmentForCapability({
attachment: fakeImageAttachment({
id: 'att_gif',
originalName: 'clip.gif',
mimeType: 'image/gif',
}),
capability,
});
expect(result).toEqual({ ok: true, warnings: [] });
});
it('blocks GIF images for Codex native delivery', () => {
const capability = resolveAgentAttachmentCapability({
providerId: 'codex',
model: 'gpt-5.4-mini',
});
const result = validateAttachmentForCapability({
attachment: fakeImageAttachment({
id: 'att_gif',
originalName: 'clip.gif',
mimeType: 'image/gif',
}),
capability,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe('attachment_type_unsupported');
expect(result.message).toContain('image type');
}
});
it('blocks non-image files for Codex native delivery', () => {
const capability = resolveAgentAttachmentCapability({
providerId: 'codex',

View file

@ -13,10 +13,22 @@ import type {
const AGENT_IMAGE_MIME_TYPES = new Set<AgentImageMimeType>([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]);
const PROVIDER_IMAGE_MIME_TYPES = new Set<ProviderImageMimeType>(['image/png', 'image/jpeg']);
const OPTIMIZABLE_AGENT_IMAGE_MIME_TYPES = new Set<Exclude<AgentImageMimeType, 'image/gif'>>([
'image/png',
'image/jpeg',
'image/webp',
]);
const PROVIDER_IMAGE_MIME_TYPES = new Set<ProviderImageMimeType>([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]);
export function isAgentImageMimeType(mimeType: string): mimeType is AgentImageMimeType {
return AGENT_IMAGE_MIME_TYPES.has(mimeType as AgentImageMimeType);
@ -26,12 +38,27 @@ export function isProviderImageMimeType(mimeType: string): mimeType is ProviderI
return PROVIDER_IMAGE_MIME_TYPES.has(mimeType as ProviderImageMimeType);
}
function isOptimizableAgentImageMimeType(
mimeType: string
): mimeType is Exclude<AgentImageMimeType, 'image/gif'> {
return OPTIMIZABLE_AGENT_IMAGE_MIME_TYPES.has(
mimeType as Exclude<AgentImageMimeType, 'image/gif'>
);
}
function isProviderFileMimeType(mimeType: string, supported: readonly string[]): boolean {
return supported.some((candidate) =>
candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType
);
}
function isCapabilityImageMimeType(
mimeType: string,
supported: readonly ProviderImageMimeType[]
): boolean {
return supported.includes(mimeType as ProviderImageMimeType);
}
export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind {
if (isAgentImageMimeType(mimeType)) return 'image';
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
@ -48,11 +75,11 @@ export function validateImageOptimizationInput(input: {
budget?: ImageOptimizationBudget;
}): AttachmentValidationResult {
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
if (!isAgentImageMimeType(input.mimeType)) {
if (!isOptimizableAgentImageMimeType(input.mimeType)) {
return {
ok: false,
code: 'attachment_type_unsupported',
message: 'This file type is not supported for agent image delivery.',
message: 'This image type is not supported for optimization.',
warnings: [],
};
}
@ -139,7 +166,7 @@ export function validateAttachmentForCapability(input: {
};
}
if (!isProviderImageMimeType(attachment.mimeType)) {
if (!isCapabilityImageMimeType(attachment.mimeType, capability.supportedImageMimeTypes)) {
return {
ok: false,
code: 'attachment_type_unsupported',

View file

@ -31,11 +31,30 @@ describe('Claude attachment adapter', () => {
});
expect(result.kind).toBe('structured_blocks');
expect(result.blocks[0]).toEqual({ type: 'text', text: 'What color?' });
expect(result.blocks[1]).toMatchObject({
expect(result.blocks[0]).toMatchObject({
type: 'image',
source: { type: 'base64', media_type: 'image/png' },
});
expect(result.blocks[1]).toEqual({ type: 'text', text: 'What color?' });
});
it.each([
['image/jpeg', 'photo.jpg'],
['image/gif', 'animation.gif'],
['image/webp', 'screenshot.webp'],
])('serializes %s images as structured image blocks', (mimeType, filename) => {
const result = buildClaudeAttachmentDeliveryParts({
text: 'What color?',
attachments: [attachment({ filename, mimeType })],
});
expect(result.blocks).toMatchObject([
{
type: 'image',
source: { type: 'base64', media_type: mimeType },
},
{ type: 'text', text: 'What color?' },
]);
});
it('serializes UTF-8 text files as text document blocks', () => {
@ -50,11 +69,12 @@ describe('Claude attachment adapter', () => {
],
});
expect(result.blocks[1]).toEqual({
expect(result.blocks[0]).toEqual({
type: 'document',
source: { type: 'text', media_type: 'text/plain', data: 'hello' },
title: 'note.txt',
});
expect(result.blocks[1]).toEqual({ type: 'text', text: 'Read this' });
});
it('serializes text subtypes as text document blocks', () => {
@ -69,11 +89,12 @@ describe('Claude attachment adapter', () => {
],
});
expect(result.blocks[1]).toEqual({
expect(result.blocks[0]).toEqual({
type: 'document',
source: { type: 'text', media_type: 'text/plain', data: '# hello' },
title: 'notes.md',
});
expect(result.blocks[1]).toEqual({ type: 'text', text: 'Read this' });
});
it('rejects unsupported non-image files before provider send', () => {
@ -85,11 +106,11 @@ describe('Claude attachment adapter', () => {
).toThrow(/Claude attachment MIME unsupported/);
});
it('rejects unsupported image mime types before provider send', () => {
it('rejects image mime types outside Claude vision support before provider send', () => {
expect(() =>
buildClaudeAttachmentDeliveryParts({
text: 'see gif',
attachments: [attachment({ mimeType: 'image/gif' })],
text: 'see avif',
attachments: [attachment({ mimeType: 'image/avif' })],
})
).toThrow(/Claude attachment MIME unsupported/);
});

View file

@ -1,4 +1,7 @@
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
import {
AgentAttachmentError,
CLAUDE_IMAGE_MIME_TYPES,
} from '@features/agent-attachments/core/domain';
import type { AttachmentPayload } from '@shared/types';
@ -27,20 +30,25 @@ function decodeBase64Text(data: string): { ok: true; text: string } | { ok: fals
return { ok: true, text: decoded };
}
const CLAUDE_IMAGE_MIME_TYPE_SET = new Set<string>(CLAUDE_IMAGE_MIME_TYPES);
export function buildClaudeAttachmentDeliveryParts(input: {
text: string;
attachments?: AttachmentPayload[];
}): ClaudeAttachmentDeliveryParts {
const contentBlocks: ClaudeInputBlock[] = [{ type: 'text', text: input.text }];
const textBlock: ClaudeInputBlock = { type: 'text', text: input.text };
const attachments = input.attachments ?? [];
if (attachments.length === 0) {
return { kind: 'legacy_text', blocks: contentBlocks };
return { kind: 'legacy_text', blocks: [textBlock] };
}
const imageBlocks: ClaudeInputBlock[] = [];
const documentBlocks: ClaudeInputBlock[] = [];
for (const attachment of attachments) {
if (attachment.mimeType === 'application/pdf') {
contentBlocks.push({
documentBlocks.push({
type: 'document',
source: {
type: 'base64',
@ -54,7 +62,7 @@ export function buildClaudeAttachmentDeliveryParts(input: {
if (attachment.mimeType === 'text/plain' || attachment.mimeType.startsWith('text/')) {
const decoded = decodeBase64Text(attachment.data);
contentBlocks.push(
documentBlocks.push(
decoded.ok
? {
type: 'document',
@ -78,8 +86,8 @@ export function buildClaudeAttachmentDeliveryParts(input: {
continue;
}
if (attachment.mimeType === 'image/png' || attachment.mimeType === 'image/jpeg') {
contentBlocks.push({
if (CLAUDE_IMAGE_MIME_TYPE_SET.has(attachment.mimeType)) {
imageBlocks.push({
type: 'image',
source: {
// Claude expects image bytes inside the structured image block as base64.
@ -99,7 +107,7 @@ export function buildClaudeAttachmentDeliveryParts(input: {
);
}
return { kind: 'structured_blocks', blocks: contentBlocks };
return { kind: 'structured_blocks', blocks: [...imageBlocks, ...documentBlocks, textBlock] };
}
export function redactClaudeBlocksForDiagnostics(blocks: ClaudeInputBlock[]): ClaudeInputBlock[] {

View file

@ -25,5 +25,9 @@ export { FileClaudeStateProbe } from './adapters/output/ClaudeStateProbe';
export { NodePtyProcessAdapter } from './adapters/output/NodePtyProcessAdapter';
export { FileTempEmptyMcpConfigStore } from './adapters/output/TempEmptyMcpConfigStore';
export { createWorkspaceTrustCoordinator } from './composition/createWorkspaceTrustCoordinator';
export {
resolveWorkspaceTrustCanonicalGitRoot,
resolveWorkspaceTrustFilesystemGitRoot,
} from './infrastructure/WorkspaceTrustCanonicalGitRoot';
export { resolveWorkspaceTrustFeatureFlags } from './infrastructure/WorkspaceTrustFeatureFlags';
export { buildWorkspaceTrustPreflightEnv } from './infrastructure/workspaceTrustPreflightEnv';

View file

@ -0,0 +1,92 @@
import fs from 'node:fs/promises';
import path from 'node:path';
async function realpathOrNull(value: string): Promise<string | null> {
try {
return await fs.realpath(value);
} catch {
return null;
}
}
async function readTrimmedFileOrNull(filePath: string): Promise<string | null> {
try {
const value = await fs.readFile(filePath, 'utf8');
return value.trim();
} catch {
return null;
}
}
export async function resolveWorkspaceTrustFilesystemGitRoot(cwd: string): Promise<string | null> {
let current = path.resolve(cwd).normalize('NFC');
const root = path.parse(current).root;
try {
const cwdStat = await fs.stat(current);
if (!cwdStat.isDirectory()) {
return null;
}
} catch {
return null;
}
while (true) {
try {
const stat = await fs.stat(path.join(current, '.git'));
if (stat.isDirectory() || stat.isFile()) {
return current;
}
} catch {
// Keep walking until the filesystem root.
}
if (current === root) {
return null;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
export async function resolveWorkspaceTrustCanonicalGitRoot(gitRoot: string): Promise<string> {
const normalizedGitRoot = path.resolve(gitRoot).normalize('NFC');
const gitFileContent = await readTrimmedFileOrNull(path.join(normalizedGitRoot, '.git'));
if (!gitFileContent?.startsWith('gitdir:')) {
return normalizedGitRoot;
}
const worktreeGitDir = path
.resolve(normalizedGitRoot, gitFileContent.slice('gitdir:'.length).trim())
.normalize('NFC');
const commonDirRaw = await readTrimmedFileOrNull(path.join(worktreeGitDir, 'commondir'));
if (!commonDirRaw) {
return normalizedGitRoot;
}
const commonDir = path.resolve(worktreeGitDir, commonDirRaw).normalize('NFC');
// Guard against a repo borrowing another trusted repo's worktree metadata.
if (path.resolve(path.dirname(worktreeGitDir)) !== path.join(commonDir, 'worktrees')) {
return normalizedGitRoot;
}
const gitdirBacklink = await readTrimmedFileOrNull(path.join(worktreeGitDir, 'gitdir'));
if (!gitdirBacklink) {
return normalizedGitRoot;
}
const [backlink, realGitRoot] = await Promise.all([
realpathOrNull(gitdirBacklink),
realpathOrNull(normalizedGitRoot),
]);
if (!backlink || !realGitRoot || backlink !== path.join(realGitRoot, '.git')) {
return normalizedGitRoot;
}
return (path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir).normalize(
'NFC'
);
}

View file

@ -191,6 +191,10 @@ import {
markRendererUnavailable,
safeSendToRenderer,
} from './utils/safeWebContentsSend';
import {
captureStartupMemorySnapshot,
formatStartupMemorySnapshot,
} from './utils/startupTelemetry';
import { syncTelemetryFlag } from './sentry';
import { setCodexRuntimeMainWindow } from './ipc/codexRuntime';
import {
@ -236,7 +240,12 @@ import {
} from './services';
import type { FileChangeEvent } from '@main/types';
import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
import type {
AppStartupMemorySnapshot,
AppStartupStatus,
AppStartupStep,
TeamChangeEvent,
} from '@shared/types';
const logger = createLogger('App');
const appStartedAtMs = Date.now();
@ -936,12 +945,14 @@ const STARTUP_CLI_WARMUP_DELAY_MS = 90_000;
const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000;
const STARTUP_RECOVERY_CONCURRENCY = 1;
const appStartupStartedAt = Date.now();
const initialStartupMemory = captureStartupMemorySnapshot();
let appStartupSteps: AppStartupStep[] = [
{
phase: 'boot',
message: 'Starting Agent Teams AI...',
startedAt: appStartupStartedAt,
updatedAt: appStartupStartedAt,
memoryAtStart: initialStartupMemory,
},
];
let appStartupStatus: AppStartupStatus = {
@ -951,6 +962,7 @@ let appStartupStatus: AppStartupStatus = {
error: null,
startedAt: appStartupStartedAt,
updatedAt: appStartupStartedAt,
memory: initialStartupMemory,
steps: appStartupSteps,
};
@ -1001,7 +1013,11 @@ function cloneStartupSteps(): AppStartupStep[] {
return appStartupSteps.map((step) => ({ ...step }));
}
function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number): void {
function updateStartupTimeline(
update: Partial<AppStartupStatus>,
now: number,
memory: AppStartupMemorySnapshot
): void {
if (!update.phase && !update.message) {
return;
}
@ -1015,12 +1031,14 @@ function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number):
current.finishedAt = now;
current.durationMs = now - current.startedAt;
current.updatedAt = now;
current.memoryAtEnd = memory;
}
appStartupSteps.push({
phase,
message,
startedAt: now,
updatedAt: now,
memoryAtStart: memory,
});
if (appStartupSteps.length > 32) {
appStartupSteps = appStartupSteps.slice(-32);
@ -1031,7 +1049,7 @@ function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number):
}
}
function finishCurrentStartupStep(now: number): void {
function finishCurrentStartupStep(now: number, memory: AppStartupMemorySnapshot): void {
const current = appStartupSteps[appStartupSteps.length - 1];
if (!current || current.finishedAt) {
return;
@ -1039,20 +1057,30 @@ function finishCurrentStartupStep(now: number): void {
current.finishedAt = now;
current.durationMs = now - current.startedAt;
current.updatedAt = now;
current.memoryAtEnd = memory;
}
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
const now = Date.now();
updateStartupTimeline(update, now);
const memory = captureStartupMemorySnapshot();
updateStartupTimeline(update, now, memory);
if (update.ready === true || update.error) {
finishCurrentStartupStep(now);
finishCurrentStartupStep(now, memory);
}
appStartupStatus = {
...appStartupStatus,
...update,
updatedAt: now,
memory,
steps: cloneStartupSteps(),
};
if (update.phase || update.ready === true || update.error) {
logger.info(
`[startup] phase=${appStartupStatus.phase} ready=${appStartupStatus.ready} elapsedMs=${
now - appStartupStartedAt
} ${formatStartupMemorySnapshot(memory)}`
);
}
safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus);
}

View file

@ -2572,6 +2572,17 @@ function validateAttachmentSerializedPayload(input: {
};
}
function formatAttachmentDeliveryFailure(error: unknown, teamStillAlive: boolean): string {
if (!teamStillAlive) {
return 'Failed to deliver message with attachments: team process became unavailable';
}
const message = getErrorMessage(error);
if (message.startsWith('Failed to deliver message with attachments:')) {
return message;
}
return `Failed to deliver message with attachments: ${message}`;
}
function buildMessageDeliveryText(
baseText: string,
opts: {
@ -2920,11 +2931,11 @@ async function handleSendMessage(
);
stdinSent = true;
} catch (stdinError: unknown) {
// Stdin failed (process died between check and write)
// If attachments were requested, fail rather than silently dropping them
// If attachments were requested, fail rather than silently dropping them.
// Only report offline when liveness confirms the process is unavailable.
if (validatedAttachments?.length) {
throw new Error(
'Failed to deliver message with attachments: team process became unavailable'
formatAttachmentDeliveryFailure(stdinError, provisioning.isTeamAlive(tn))
);
}
const errMsg = stdinError instanceof Error ? stdinError.message : 'unknown error';

View file

@ -931,11 +931,6 @@ export class CliInstallerService {
}
const generation = this.statusGatherGeneration;
const versionProbe = await this.probeCliVersion(binaryPath);
if (!versionProbe.ok) {
return null;
}
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
binaryPath,
providerId,
@ -1024,6 +1019,7 @@ export class CliInstallerService {
* on timeout, getStatus() returns whatever fields were populated so far.
*
* Flow: binary resolve --version (sequential) Promise.all([auth, GCS]) (parallel)
* Lightweight multimodel startup status stops after binary resolution; full status hydrates health.
*/
private async gatherStatus(
ref: { current: CliInstallationStatus },
@ -1050,6 +1046,18 @@ export class CliInstallerService {
diag.binaryResolveMs = Date.now() - binaryResolveStartedAt;
if (binaryPath) {
r.binaryPath = binaryPath;
if (r.flavor === 'agent_teams_orchestrator' && providerStatusMode === 'defer') {
const recoveredHealthyStatus = this.getRecoverableHealthyStatus(binaryPath);
diag.versionProbeMs = 0;
r.installed = true;
r.installedVersion = recoveredHealthyStatus?.installedVersion ?? null;
r.launchError = null;
r.authStatusChecking = false;
this.markProvidersDeferred(r);
this.publishStatusSnapshotIfCurrent(r, generation);
return;
}
const versionProbeStartedAt = Date.now();
const versionProbe = await this.probeCliVersion(binaryPath);
diag.versionProbeMs = Date.now() - versionProbeStartedAt;
@ -1059,13 +1067,6 @@ export class CliInstallerService {
r.launchError = null;
r.authStatusChecking = true;
if (r.flavor === 'agent_teams_orchestrator' && providerStatusMode === 'defer') {
r.authStatusChecking = false;
this.markProvidersDeferred(r);
this.publishStatusSnapshotIfCurrent(r, generation);
return;
}
this.rememberHealthyStatus(r);
this.publishStatusSnapshotIfCurrent(r, generation);

View file

@ -14,11 +14,19 @@ export interface WatcherLifecycle {
isCurrent: () => boolean;
}
export interface PollSnapshotResult {
files: Map<string, string>;
cycleComplete: boolean;
deleteSafe?: boolean;
}
type PollSnapshot = Map<string, string> | PollSnapshotResult;
export interface CrossPlatformFileChangeSourceOptions {
name: string;
pollIntervalMs: number;
createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher;
collectPollSnapshot: () => Promise<Map<string, string>>;
collectPollSnapshot: () => Promise<PollSnapshot>;
emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void;
isOwnerActive: () => boolean;
isWatchLimitError: (error: unknown) => boolean;
@ -34,6 +42,7 @@ export class CrossPlatformFileChangeSource {
private pollingGenerationInProgress: number | null = null;
private pollingPrimed = false;
private pollSnapshot = new Map<string, string>();
private partialPollSnapshot = new Map<string, string>();
private closedGeneration: number | null = null;
private rejectedGeneration: number | null = null;
private generation = 0;
@ -180,6 +189,7 @@ export class CrossPlatformFileChangeSource {
this.pollingGenerationInProgress = null;
this.pollingPrimed = false;
this.pollSnapshot.clear();
this.partialPollSnapshot.clear();
const timer = this.pollingTimer;
this.pollingTimer = null;
@ -265,34 +275,51 @@ export class CrossPlatformFileChangeSource {
}
private async pollForChanges(expectedGeneration: number): Promise<void> {
const nextSnapshot = await this.options.collectPollSnapshot();
const nextSnapshot = normalizePollSnapshot(await this.options.collectPollSnapshot());
if (expectedGeneration !== this.generation || !this.options.isOwnerActive()) {
return;
}
this.mergePartialPollSnapshot(nextSnapshot.files);
if (!this.pollingPrimed) {
logger.info(`${this.options.name} polling baseline captured`);
this.pollSnapshot = nextSnapshot;
this.pollingPrimed = true;
if (nextSnapshot.cycleComplete) {
logger.info(`${this.options.name} polling baseline captured`);
this.pollSnapshot = this.partialPollSnapshot;
this.partialPollSnapshot = new Map();
this.pollingPrimed = true;
}
return;
}
for (const [relativePath, fingerprint] of nextSnapshot) {
for (const [relativePath, fingerprint] of nextSnapshot.files) {
const previous = this.pollSnapshot.get(relativePath);
if (previous === undefined) {
this.options.emitPolledChange('rename', relativePath);
} else if (previous !== fingerprint) {
this.options.emitPolledChange('change', relativePath);
}
this.pollSnapshot.set(relativePath, fingerprint);
}
for (const relativePath of this.pollSnapshot.keys()) {
if (!nextSnapshot.has(relativePath)) {
this.options.emitPolledChange('rename', relativePath);
if (nextSnapshot.cycleComplete) {
const completedSnapshot = this.partialPollSnapshot;
if (nextSnapshot.deleteSafe !== false) {
for (const relativePath of this.pollSnapshot.keys()) {
if (!completedSnapshot.has(relativePath)) {
this.options.emitPolledChange('rename', relativePath);
}
}
}
this.pollSnapshot = completedSnapshot;
this.partialPollSnapshot = new Map();
}
}
this.pollSnapshot = nextSnapshot;
private mergePartialPollSnapshot(files: Map<string, string>): void {
for (const [relativePath, fingerprint] of files) {
this.partialPollSnapshot.set(relativePath, fingerprint);
}
}
private async closeWatcher(watcher: CloseableWatcher): Promise<void> {
@ -318,3 +345,14 @@ export class CrossPlatformFileChangeSource {
);
}
}
function normalizePollSnapshot(snapshot: PollSnapshot): PollSnapshotResult {
if (snapshot instanceof Map) {
return {
files: snapshot,
cycleComplete: true,
deleteSafe: true,
};
}
return snapshot;
}

View file

@ -11,7 +11,11 @@
*/
import { type FileChangeEvent, type ParsedMessage } from '@main/types';
import { parseJsonlFileWithStats, parseJsonlStream } from '@main/utils/jsonl';
import {
countJsonlFileWithStats,
parseJsonlFileWithStats,
parseJsonlStream,
} from '@main/utils/jsonl';
import {
getProjectsBasePath,
getTasksBasePath,
@ -29,7 +33,10 @@ import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { errorDetector } from '../error/ErrorDetector';
import { ConfigManager } from './ConfigManager';
import { CrossPlatformFileChangeSource } from './CrossPlatformFileChangeSource';
import {
CrossPlatformFileChangeSource,
type PollSnapshotResult,
} from './CrossPlatformFileChangeSource';
import { type DataCache } from './DataCache';
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
import { type NotificationManager } from './NotificationManager';
@ -48,6 +55,10 @@ const WATCHER_RETRY_MS = 2000;
const TEAMS_POLL_INTERVAL_MS = 1000;
/** Poll interval for task files, which can be much larger than team metadata/inboxes */
const TASKS_POLL_INTERVAL_MS = 3000;
/** Bound each projects polling slice so fallback/SSH mode cannot rescan huge histories every tick. */
const PROJECTS_POLL_PROJECT_SLICE_BUDGET = 64;
/** Soft cap: a single large project can exceed this, but broad trees are split across ticks. */
const PROJECTS_POLL_FILE_SOFT_BUDGET = 1024;
/** Interval for periodic catch-up scan to detect missed fs.watch events */
const CATCH_UP_INTERVAL_MS = 30_000;
/** Only catch-up scan files modified within this window */
@ -102,6 +113,10 @@ export class FileWatcher extends EventEmitter {
private catchUpCursor = 0;
/** Consecutive catch-up stat timeouts per file. */
private catchUpStatFailures = new Map<string, number>();
/** Cursor for chunked project polling snapshots. */
private projectsPollCursor = 0;
/** Whether the current project polling cycle has already been split across ticks. */
private projectsPollCycleChunked = false;
/** Polling interval for projects fallback and SSH mode. */
private static readonly SSH_POLL_INTERVAL_MS = 3000;
/** Files currently being processed (concurrency guard) */
@ -298,6 +313,8 @@ export class FileWatcher extends EventEmitter {
this.lastProcessedSize.clear();
this.activeSessionFiles.clear();
this.catchUpStatFailures.clear();
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
this.processingInProgress.clear();
this.pendingReprocess.clear();
@ -679,15 +696,30 @@ export class FileWatcher extends EventEmitter {
this.changeSources.projects.startPolling();
}
private async collectProjectsPollSnapshot(): Promise<Map<string, string>> {
private async collectProjectsPollSnapshot(): Promise<PollSnapshotResult> {
const snapshot = new Map<string, string>();
const projectDirs = await this.readProviderSnapshotDir(this.projectsPath);
const projectDirs = (await this.readProviderSnapshotDir(this.projectsPath))
.filter((entry) => entry.isDirectory())
.sort((left, right) => left.name.localeCompare(right.name));
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) {
continue;
}
if (projectDirs.length === 0) {
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
return { files: snapshot, cycleComplete: true, deleteSafe: true };
}
if (this.projectsPollCursor >= projectDirs.length) {
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
}
let index = this.projectsPollCursor;
let visitedProjects = 0;
let collectedFiles = 0;
while (visitedProjects < projectDirs.length) {
const projectDir = projectDirs[index];
const sizeBefore = snapshot.size;
const projectPath = path.join(this.projectsPath, projectDir.name);
const entries = await this.readProviderSnapshotDir(projectPath);
for (const entry of entries) {
@ -722,9 +754,31 @@ export class FileWatcher extends EventEmitter {
);
}
}
collectedFiles += snapshot.size - sizeBefore;
visitedProjects += 1;
index = (index + 1) % projectDirs.length;
if (index === 0) {
const deleteSafe = !this.projectsPollCycleChunked;
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
return { files: snapshot, cycleComplete: true, deleteSafe };
}
if (
visitedProjects >= PROJECTS_POLL_PROJECT_SLICE_BUDGET ||
collectedFiles >= PROJECTS_POLL_FILE_SOFT_BUDGET
) {
this.projectsPollCursor = index;
this.projectsPollCycleChunked = true;
return { files: snapshot, cycleComplete: false };
}
}
return snapshot;
this.projectsPollCursor = 0;
this.projectsPollCycleChunked = false;
return { files: snapshot, cycleComplete: true, deleteSafe: true };
}
private async collectTodosPollSnapshot(): Promise<Map<string, string>> {
@ -928,6 +982,11 @@ export class FileWatcher extends EventEmitter {
}
const isFirstRead = lastLineCount === 0 && lastSize === 0;
if (isFirstRead && fileStats.birthtimeMs < this.instanceCreatedAt) {
await this.establishPreExistingFileBaseline(filePath, currentSize);
return;
}
const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize;
let newMessages: ParsedMessage[] = [];
let currentLineCount: number;
@ -952,22 +1011,6 @@ export class FileWatcher extends EventEmitter {
return;
}
// On first read (after app restart), establish baseline without detecting errors
// for files that existed BEFORE this FileWatcher started. This prevents flooding
// notifications with historical errors from old sessions.
// Files created AFTER startup are new sessions — detect errors normally.
if (isFirstRead) {
const isPreExistingFile = fileStats.birthtimeMs < this.instanceCreatedAt;
if (isPreExistingFile) {
this.lastProcessedLineCount.set(filePath, currentLineCount);
this.lastProcessedSize.set(filePath, processedSize);
logger.info(
`FileWatcher: Baseline established for ${filePath} (${currentLineCount} lines, ${processedSize} bytes)`
);
return;
}
}
// Detect errors in new messages
// Note: We pass the offset-adjusted line numbers to errorDetector
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
@ -1009,6 +1052,18 @@ export class FileWatcher extends EventEmitter {
}
}
private async establishPreExistingFileBaseline(
filePath: string,
currentSize: number
): Promise<void> {
const baseline = await countJsonlFileWithStats(filePath, this.fsProvider);
this.lastProcessedLineCount.set(filePath, baseline.parsedLineCount);
this.lastProcessedSize.set(filePath, baseline.consumedBytes);
logger.info(
`FileWatcher: Baseline established for ${filePath} (${baseline.parsedLineCount} lines, ${baseline.consumedBytes}/${currentSize} bytes)`
);
}
/**
* Clears the error detection tracking for a specific file.
* Call this when a file is deleted or to force re-processing.

View file

@ -217,6 +217,7 @@ export class TeamAttachmentStore {
id: entry.id,
data: buffer.toString('base64'),
mimeType,
filePath,
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;

View file

@ -36,7 +36,9 @@ import {
budgetWorkspaceTrustDiagnosticsManifest,
buildWorkspaceTrustPathCandidates,
buildWorkspaceTrustPreflightEnv,
resolveWorkspaceTrustCanonicalGitRoot,
resolveWorkspaceTrustFeatureFlags,
resolveWorkspaceTrustFilesystemGitRoot,
type WorkspaceTrustArgsOnlyPlanRequest,
type WorkspaceTrustArgsOnlyPlanResult,
type WorkspaceTrustCoordinator,
@ -3764,12 +3766,12 @@ export class TeamProvisioningService {
return [...providers];
}
private resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
private async resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
const normalizedCwd = cwd.trim();
if (!normalizedCwd) {
return Promise.resolve(null);
return null;
}
return new Promise((resolve) => {
const gitRoot = await new Promise<string | null>((resolve) => {
execFile(
'git',
['-C', normalizedCwd, 'rev-parse', '--show-toplevel'],
@ -3789,6 +3791,7 @@ export class TeamProvisioningService {
}
);
});
return gitRoot ?? resolveWorkspaceTrustFilesystemGitRoot(normalizedCwd);
}
private async collectWorkspaceTrustWorkspaces(input: {
@ -3807,9 +3810,10 @@ export class TeamProvisioningService {
let gitRoot = gitRootCache.get(cwd);
if (gitRoot === undefined) {
const resolvedGitRoot = await this.resolveWorkspaceTrustGitRoot(cwd);
gitRoot = resolvedGitRoot
const realGitRoot = resolvedGitRoot
? await fs.promises.realpath(resolvedGitRoot).catch(() => resolvedGitRoot)
: null;
gitRoot = realGitRoot ? await resolveWorkspaceTrustCanonicalGitRoot(realGitRoot) : null;
gitRootCache.set(cwd, gitRoot);
}
candidates.push(

View file

@ -58,6 +58,10 @@ export interface JsonlParseResult {
consumedBytes: number;
}
interface JsonlStreamParseOptions {
collectMessages?: boolean;
}
/**
* Parse a JSONL file line by line using streaming.
* This avoids loading the entire file into memory.
@ -88,14 +92,38 @@ export async function parseJsonlFileWithStats(
return parseJsonlStream(fsProvider.createReadStream(filePath), filePath);
}
/**
* Count parseable JSONL messages and consumed bytes without retaining message
* objects. Useful for first-read baselines where old transcript contents should
* not be surfaced and can be too large to keep in memory.
*/
export async function countJsonlFileWithStats(
filePath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<Omit<JsonlParseResult, 'messages'>> {
if (!(await fsProvider.exists(filePath))) {
return { parsedLineCount: 0, consumedBytes: 0 };
}
const result = await parseJsonlStream(fsProvider.createReadStream(filePath), filePath, {
collectMessages: false,
});
return {
parsedLineCount: result.parsedLineCount,
consumedBytes: result.consumedBytes,
};
}
/**
* Parse JSONL data from a readable stream while tracking how many bytes were
* safely consumed as complete lines.
*/
export async function parseJsonlStream(
stream: Readable,
filePath?: string
filePath?: string,
options: JsonlStreamParseOptions = {}
): Promise<JsonlParseResult> {
const collectMessages = options.collectMessages !== false;
const messages: ParsedMessage[] = [];
let pending = Buffer.alloc(0);
let parsedLineCount = 0;
@ -122,9 +150,13 @@ export async function parseJsonlStream(
}
try {
const parsed = parseJsonlLine(normalized);
if (parsed) {
messages.push(parsed);
if (collectMessages) {
const parsed = parseJsonlLine(normalized);
if (parsed) {
messages.push(parsed);
parsedLineCount += 1;
}
} else if (isCountableJsonlEntryLine(normalized)) {
parsedLineCount += 1;
}
} catch {
@ -163,9 +195,14 @@ export async function parseJsonlStream(
const trailingLine = pending.toString('utf8');
const normalized = normalizeJsonlLine(trailingLine);
if (looksLikeJsonObjectLine(normalized)) {
const parsed = parseJsonlLine(normalized);
if (parsed) {
messages.push(parsed);
if (collectMessages) {
const parsed = parseJsonlLine(normalized);
if (parsed) {
messages.push(parsed);
parsedLineCount += 1;
consumedBytes += pending.length;
}
} else if (isCountableJsonlEntryLine(normalized)) {
parsedLineCount += 1;
consumedBytes += pending.length;
}
@ -223,6 +260,45 @@ function looksLikeJsonObjectLine(line: string): boolean {
return line.startsWith('{');
}
function isCountableJsonlEntryLine(line: string): boolean {
const entry = JSON.parse(line) as Partial<ChatHistoryEntry> & {
uuid?: unknown;
type?: unknown;
message?: unknown;
};
const type = typeof entry.type === 'string' ? parseMessageType(entry.type) : null;
if (typeof entry.uuid !== 'string' || entry.uuid.length === 0 || !type) {
return false;
}
if (type === 'user') {
if (entry.message == null) {
return false;
}
const content = (entry.message as { content?: unknown }).content;
return content == null || isParserSafeContent(content);
}
if (type === 'assistant') {
if (!isJsonObjectRecord(entry.message)) {
return false;
}
const content = entry.message.content;
return isParserSafeContent(content);
}
return true;
}
function isJsonObjectRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function isParserSafeContent(value: unknown): boolean {
return typeof value === 'string' || (Array.isArray(value) && value.every((item) => item != null));
}
// =============================================================================
// Entry Parsing
// =============================================================================

View file

@ -11,7 +11,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getClaudeBasePath, getHomeDir } from './pathDecoder';
import { getAppDataPath, getClaudeBasePath, getHomeDir } from './pathDecoder';
/**
* Sensitive file patterns that should never be accessible.
@ -101,6 +101,7 @@ export function matchesSensitivePattern(normalizedPath: string): boolean {
* Allowed directories:
* - The project path itself
* - The ~/.claude directory (for session data)
* - The app-owned data directory (attachments, task attachments)
*
* @param normalizedPath - The normalized absolute path to check
* @param projectPath - The project root path (can be null for global access)
@ -114,12 +115,19 @@ export function isPathWithinAllowedDirectories(
const normalizedTarget = normalizeForCompare(normalizedPath, isWindows);
const claudeDir = getClaudeBasePath();
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
const appDataDir = getAppDataPath();
const normalizedAppDataDir = normalizeForCompare(appDataDir, isWindows);
// Always allow access to ~/.claude for session data
if (isPathWithinRoot(normalizedTarget, normalizedClaudeDir)) {
return true;
}
// Allow app-owned persisted data such as message attachment files.
if (isPathWithinRoot(normalizedTarget, normalizedAppDataDir)) {
return true;
}
// If project path provided, allow access within project
if (projectPath) {
const normalizedProjectPath = normalizeForCompare(projectPath, isWindows);
@ -137,7 +145,7 @@ export function isPathWithinAllowedDirectories(
* Security checks performed:
* 1. Path must be absolute
* 2. Path traversal prevention (no ..)
* 3. Must be within allowed directories (project or ~/.claude)
* 3. Must be within allowed directories (project, ~/.claude, or app data)
* 4. Must not match sensitive file patterns
*
* @param filePath - The file path to validate

View file

@ -0,0 +1,26 @@
import type { AppStartupMemorySnapshot } from '@shared/types';
export type MemoryUsageReader = () => NodeJS.MemoryUsage;
export function captureStartupMemorySnapshot(
readMemoryUsage: MemoryUsageReader = () => process.memoryUsage()
): AppStartupMemorySnapshot {
const memory = readMemoryUsage();
return {
rssBytes: memory.rss,
heapUsedBytes: memory.heapUsed,
heapTotalBytes: memory.heapTotal,
externalBytes: memory.external,
arrayBuffersBytes: memory.arrayBuffers,
};
}
export function formatStartupMemorySnapshot(memory: AppStartupMemorySnapshot): string {
return `rss=${formatMiB(memory.rssBytes)} heap=${formatMiB(memory.heapUsedBytes)}/${formatMiB(
memory.heapTotalBytes
)} external=${formatMiB(memory.externalBytes)}`;
}
function formatMiB(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
}

View file

@ -1036,14 +1036,14 @@ const InstalledBanner = ({
>
<div className="col-span-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="flex items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="flex min-w-0 items-center gap-2">
<ProviderBrandLogo
providerId={provider.providerId}
className="size-4 shrink-0"
/>
<span
className="text-xs font-medium"
className="truncate whitespace-nowrap text-xs font-medium"
style={{ color: 'var(--color-text)' }}
>
{provider.providerId === 'opencode'
@ -1053,7 +1053,7 @@ const InstalledBanner = ({
{openCodeDashboardChips.map((chip) => (
<span
key={chip.label}
className="rounded bg-[rgba(34,197,94,0.14)] px-1.5 py-px text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]"
className="shrink-0 whitespace-nowrap rounded bg-[rgba(34,197,94,0.14)] px-1.5 py-px text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]"
title={chip.title}
>
{chip.label}
@ -1061,7 +1061,7 @@ const InstalledBanner = ({
))}
</span>
<span
className="text-xs"
className="whitespace-nowrap text-xs"
style={{
color: getProviderStatusColor(statusText, provider.authenticated),
}}

View file

@ -25,6 +25,7 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialogLoadingFallback } from '../team/dialogs/LaunchTeamDialogLoadingFallback';
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
@ -588,7 +589,16 @@ export const SchedulesView = (): React.JSX.Element => {
{/* Create/Edit Dialog */}
{dialogOpen && (
<Suspense fallback={null}>
<Suspense
fallback={
<LaunchTeamDialogLoadingFallback
mode="schedule"
teamName={editingSchedule?.teamName}
isEditingSchedule={Boolean(editingSchedule)}
onClose={handleClose}
/>
}
>
<LaunchTeamDialog
mode="schedule"
open={dialogOpen}

View file

@ -534,20 +534,21 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<>
<div className="col-span-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span className="flex items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
<span className="flex min-w-0 items-center gap-2">
<ProviderBrandLogo
providerId={provider.providerId}
className="size-4 shrink-0"
/>
<span
className="font-medium"
className="truncate whitespace-nowrap font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
{provider.displayName}
</span>
</span>
<span
className="whitespace-nowrap"
style={{
color: getProviderStatusColor(
statusText,

View file

@ -85,6 +85,7 @@ import { useShallow } from 'zustand/react/shallow';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialogLoadingFallback } from './dialogs/LaunchTeamDialogLoadingFallback';
import { ReviewDialog } from './dialogs/ReviewDialog';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { KanbanBoard } from './kanban/KanbanBoard';
@ -2580,7 +2581,15 @@ export const TeamDetailView = memo(function TeamDetailView({
</div>
</div>
{launchDialogOpen && (
<Suspense fallback={null}>
<Suspense
fallback={
<LaunchTeamDialogLoadingFallback
mode={launchDialogState.mode}
teamName={teamName}
onClose={closeLaunchDialog}
/>
}
>
<LaunchTeamDialog
mode={launchDialogState.mode}
open={launchDialogOpen}
@ -3418,7 +3427,15 @@ export const TeamDetailView = memo(function TeamDetailView({
</Dialog>
{launchDialogOpen && (
<Suspense fallback={null}>
<Suspense
fallback={
<LaunchTeamDialogLoadingFallback
mode={launchDialogState.mode}
teamName={teamName}
onClose={closeLaunchDialog}
/>
}
>
<LaunchTeamDialog
mode={launchDialogState.mode}
open={launchDialogOpen}

View file

@ -62,6 +62,7 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialogLoadingFallback } from './dialogs/LaunchTeamDialogLoadingFallback';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
@ -1094,7 +1095,15 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
);
const launchDialogElement = launchDialogOpen && (
<Suspense fallback={null}>
<Suspense
fallback={
<LaunchTeamDialogLoadingFallback
mode={launchDialogMode}
teamName={launchDialogTeamName}
onClose={() => setLaunchDialogOpen(false)}
/>
}
>
{launchDialogMode === 'relaunch' ? (
<LaunchTeamDialog
mode="relaunch"

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { useStore } from '@renderer/store';
import { isImageMime } from '@renderer/utils/attachmentUtils';
import { Loader2 } from 'lucide-react';
@ -22,6 +23,7 @@ export const AttachmentDisplay = ({
attachments,
}: AttachmentDisplayProps): React.JSX.Element | null => {
const { t } = useAppTranslation('team');
const revealFileInEditor = useStore((s) => s.revealFileInEditor);
const [state, setState] = useState<{
loaded: AttachmentFileData[];
loading: boolean;
@ -74,10 +76,16 @@ export const AttachmentDisplay = ({
return {
meta,
dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined,
filePath: data.filePath ?? meta.filePath,
isImage,
};
})
.filter(Boolean) as { meta: AttachmentMeta; dataUrl: string | undefined; isImage: boolean }[];
.filter(Boolean) as {
meta: AttachmentMeta;
dataUrl: string | undefined;
filePath: string | undefined;
isImage: boolean;
}[];
if (items.length === 0) return null;
@ -107,10 +115,29 @@ export const AttachmentDisplay = ({
: undefined
}
/>
) : item.filePath ? (
<button
key={item.meta.id}
type="button"
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface)]"
title={item.meta.filename}
aria-label={`Open ${item.meta.filename}`}
onClick={(event) => {
event.stopPropagation();
revealFileInEditor(item.filePath!);
}}
onKeyDown={(event) => event.stopPropagation()}
>
<FileIcon fileName={item.meta.filename} className="size-5" />
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
{item.meta.filename}
</span>
</button>
) : (
<div
key={item.meta.id}
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)]"
title={item.meta.filename}
>
<FileIcon fileName={item.meta.filename} className="size-5" />
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">

View file

@ -0,0 +1,86 @@
import React from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Loader2 } from 'lucide-react';
type LaunchTeamDialogLoadingMode = 'launch' | 'relaunch' | 'schedule';
interface LaunchTeamDialogLoadingFallbackProps {
readonly mode: LaunchTeamDialogLoadingMode;
readonly teamName?: string;
readonly isEditingSchedule?: boolean;
readonly onClose: () => void;
}
export const LaunchTeamDialogLoadingFallback = ({
mode,
teamName,
isEditingSchedule = false,
onClose,
}: LaunchTeamDialogLoadingFallbackProps): React.JSX.Element => {
const { t } = useAppTranslation('team');
const { t: tCommon } = useAppTranslation('common');
const title =
mode === 'schedule'
? isEditingSchedule
? t('launch.title.editSchedule')
: t('launch.title.createSchedule')
: mode === 'relaunch'
? t('launch.title.relaunch')
: t('launch.title.launch');
const description =
mode === 'schedule'
? isEditingSchedule && teamName
? t('launch.description.editSchedule', { team: teamName })
: teamName
? t('launch.description.createScheduleForTeam', { team: teamName })
: t('launch.description.createSchedule')
: mode === 'relaunch'
? t('launch.description.relaunchPrefix')
: t('launch.description.launchPrefix');
return (
<Dialog
open
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
>
<DialogContent className="max-w-[52rem]">
<DialogHeader>
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">
{mode === 'schedule' ? (
description
) : (
<>
{description} <span className="font-mono font-medium">{teamName}</span>{' '}
{mode === 'relaunch'
? t('launch.description.relaunchSuffix')
: t('launch.description.launchSuffix')}
</>
)}
</DialogDescription>
</DialogHeader>
<div
className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]"
aria-live="polite"
>
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{tCommon('states.loading')}</span>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -19,6 +19,8 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialogLoadingFallback } from '../dialogs/LaunchTeamDialogLoadingFallback';
import { ScheduleEmptyState } from './ScheduleEmptyState';
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow';
@ -311,7 +313,16 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
{/* Create/Edit Dialog */}
{dialogOpen && (
<Suspense fallback={null}>
<Suspense
fallback={
<LaunchTeamDialogLoadingFallback
mode="schedule"
teamName={teamName}
isEditingSchedule={Boolean(editingSchedule)}
onClose={handleClose}
/>
}
>
<LaunchTeamDialog
mode="schedule"
open={dialogOpen}

View file

@ -100,6 +100,8 @@ const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_000;
const STARTUP_GLOBAL_TASKS_MIN_DELAY_MS = 5_000;
const STARTUP_GLOBAL_TASKS_MAX_DELAY_MS = 30_000;
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
@ -216,6 +218,8 @@ export function initializeNotificationListeners(): () => void {
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
let deferredProviderStatusCleanup: (() => void) | null = null;
let deferredGlobalTasksCleanup: (() => void) | null = null;
let disposed = false;
useStore.getState().subscribeProvisioningProgress();
cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress();
@ -286,18 +290,33 @@ export function initializeNotificationListeners(): () => void {
runtimeStatusTimer = null;
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS);
// Remaining visible startup fetches have no data dependency on each other.
// Keep immediately visible startup data first; global task aggregation can
// scan all team task files, so hydrate it after first paint/idle.
await Promise.all([
useStore.getState().fetchAllTasks(),
useStore.getState().fetchTeams(),
useStore.getState().fetchNotifications(),
useStore.getState().fetchSchedules(),
]);
if (disposed) {
return;
}
deferredGlobalTasksCleanup = scheduleStartupIdleTask(
() => {
deferredGlobalTasksCleanup = null;
void useStore.getState().fetchAllTasks();
},
{
minDelayMs: STARTUP_GLOBAL_TASKS_MIN_DELAY_MS,
maxDelayMs: STARTUP_GLOBAL_TASKS_MAX_DELAY_MS,
}
);
})();
cleanupFns.push(() => {
disposed = true;
if (cliStatusTimer) clearTimeout(cliStatusTimer);
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
if (deferredGlobalTasksCleanup) deferredGlobalTasksCleanup();
});
// TODO(task-change-presence): re-enable this only after the board uses a bounded
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate

View file

@ -31,6 +31,10 @@ function isSupportedFileMime(mimeType: string, supported: readonly string[]): bo
);
}
function isSupportedImageMime(mimeType: string, supported: readonly string[]): boolean {
return supported.includes(mimeType);
}
function canReceiveAnyAttachment(capability: AgentAttachmentCapability): boolean {
return capability.supportsImages || capability.supportsFiles;
}
@ -68,7 +72,7 @@ export function getAttachmentInputAcceptForMember(
}
const { capability } = resolveMemberAttachmentCapability(member);
if (capability.supportsImages && !capability.supportsFiles) {
return 'image/png,image/jpeg,image/webp';
return capability.supportedImageMimeTypes.join(',');
}
return '*/*';
}
@ -99,6 +103,10 @@ export function validateAttachmentFilesForMember(input: {
if (!capability.supportsImages) {
return capability.displayText;
}
const mimeType = getEffectiveMimeType(file);
if (!isSupportedImageMime(mimeType, capability.supportedImageMimeTypes)) {
return 'This image type is not supported by the selected model.';
}
continue;
}
if (!capability.supportsFiles) {
@ -136,6 +144,9 @@ export function validateAttachmentPayloadsForMember(input: {
if (!capability.supportsImages) {
return capability.displayText;
}
if (!isSupportedImageMime(attachment.mimeType, capability.supportedImageMimeTypes)) {
return 'This image type is not supported by the selected model.';
}
if (attachment.size > capability.maxBytesPerImage) {
return 'Image is too large for the selected model.';
}

View file

@ -337,6 +337,7 @@ export interface AppStartupStatus {
startedAt: number;
updatedAt: number;
steps?: AppStartupStep[];
memory?: AppStartupMemorySnapshot;
}
export interface AppStartupStep {
@ -346,6 +347,8 @@ export interface AppStartupStep {
updatedAt: number;
finishedAt?: number;
durationMs?: number;
memoryAtStart?: AppStartupMemorySnapshot;
memoryAtEnd?: AppStartupMemorySnapshot;
}
export interface AppStartupAPI {
@ -353,6 +356,14 @@ export interface AppStartupAPI {
onProgress: (callback: (status: AppStartupStatus) => void) => () => void;
}
export interface AppStartupMemorySnapshot {
rssBytes: number;
heapUsedBytes: number;
heapTotalBytes: number;
externalBytes: number;
arrayBuffersBytes?: number;
}
// =============================================================================
// Context API
// =============================================================================

View file

@ -302,7 +302,7 @@ export interface CliInstallationStatus {
showVersionDetails: boolean;
/** Whether binary path should be shown in the UI */
showBinaryPath: boolean;
/** Whether the CLI was found and passed the startup health check (`--version`) */
/** Whether the CLI is available. Lightweight startup status may defer the health check. */
installed: boolean;
/** Installed version string (e.g. "2.1.59"), null if unavailable or not installed */
installedVersion: string | null;

View file

@ -591,6 +591,8 @@ export interface AttachmentFileData {
id: string;
data: string;
mimeType: AttachmentMediaType;
/** Absolute path to the persisted attachment file when available. */
filePath?: string;
}
/** Lightweight metadata for a single tool call (for UI display in tooltips). */

View file

@ -1,5 +1,3 @@
import { describe, expect, it } from 'vitest';
import {
ClaudePtyWorkspaceTrustStrategy,
DefaultWorkspaceTrustCoordinator,
@ -9,9 +7,11 @@ import {
} from '@features/workspace-trust/core/application';
import {
buildWorkspaceTrustPathCandidates,
readCodexWorkspaceTrustConfigOverridesFromSettings,
type WorkspaceTrustDiagnosticStrategyResult,
type WorkspaceTrustWorkspace,
} from '@features/workspace-trust/core/domain';
import { describe, expect, it } from 'vitest';
const featureFlags = {
enabled: true,
@ -33,6 +33,18 @@ function workspace(): WorkspaceTrustWorkspace {
})[0];
}
function codexTrustOverrides(args: string[]): string[] {
const overrides: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === '--settings' && typeof args[index + 1] === 'string') {
overrides.push(
...readCodexWorkspaceTrustConfigOverridesFromSettings(JSON.parse(args[index + 1]))
);
}
}
return overrides;
}
class RecordingClaudeStrategy extends ClaudePtyWorkspaceTrustStrategy {
active = 0;
maxActive = 0;
@ -83,6 +95,31 @@ describe('WorkspaceTrustCoordinator', () => {
expect(plan.launchArgPatches[0].args.join(' ')).toContain('agent_teams_workspace_trust');
});
it('includes canonical git root overrides in Codex trust settings for worktree candidates', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planFull({
providers: ['codex'],
workspaces: buildWorkspaceTrustPathCandidates({
cwd: '/tmp/generated-worktrees/alice',
realCwd: '/private/tmp/generated-worktrees/alice',
gitRoot: '/Users/belief/project',
source: 'member-worktree',
memberId: 'alice',
platform: 'posix',
}),
featureFlags,
});
const overrides = codexTrustOverrides(plan.launchArgPatches[0].args);
expect(overrides).toEqual(
expect.arrayContaining([
'projects."/tmp/generated-worktrees/alice".trust_level="trusted"',
'projects."/private/tmp/generated-worktrees/alice".trust_level="trusted"',
'projects."/Users/belief/project".trust_level="trusted"',
])
);
});
it('does not emit Codex settings patches for Anthropic-only launches', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planArgsOnly({
@ -94,6 +131,23 @@ describe('WorkspaceTrustCoordinator', () => {
expect(plan.launchArgPatches).toEqual([]);
});
it('does not emit Codex workspace-trust patches for OpenCode-only launches', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planArgsOnly({
providers: ['opencode'],
workspaces: buildWorkspaceTrustPathCandidates({
cwd: '/tmp/generated-worktrees/alice',
gitRoot: '/Users/belief/project',
source: 'member-worktree',
memberId: 'alice',
platform: 'posix',
}),
featureFlags,
});
expect(plan.launchArgPatches).toEqual([]);
});
it('limits Codex settings patches to requested target surfaces', async () => {
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
const plan = await coordinator.planArgsOnly({

View file

@ -0,0 +1,109 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
resolveWorkspaceTrustCanonicalGitRoot,
resolveWorkspaceTrustFilesystemGitRoot,
} from '@features/workspace-trust/main';
import { afterEach, describe, expect, it } from 'vitest';
let tmpDir: string | null = null;
async function makeTmpDir(): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'workspace-trust-git-root-'));
return tmpDir;
}
async function createSyntheticWorktree(input: {
repoDir: string;
worktreeDir: string;
name: string;
}): Promise<void> {
const worktreeGitDir = path.join(input.repoDir, '.git', 'worktrees', input.name);
await fs.mkdir(input.worktreeDir, { recursive: true });
await fs.mkdir(worktreeGitDir, { recursive: true });
await fs.writeFile(path.join(input.worktreeDir, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf8');
await fs.writeFile(path.join(worktreeGitDir, 'commondir'), '../..\n', 'utf8');
await fs.writeFile(
path.join(worktreeGitDir, 'gitdir'),
`${path.join(input.worktreeDir, '.git')}\n`,
'utf8'
);
}
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
describe('resolveWorkspaceTrustCanonicalGitRoot', () => {
it('finds a git root from nested paths without spawning git', async () => {
const dir = await makeTmpDir();
const repoDir = path.join(dir, 'repo');
const nestedDir = path.join(repoDir, 'packages', 'app');
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
await fs.mkdir(nestedDir, { recursive: true });
await expect(resolveWorkspaceTrustFilesystemGitRoot(nestedDir)).resolves.toBe(repoDir);
});
it('does not infer a git root from a missing path', async () => {
const dir = await makeTmpDir();
const repoDir = path.join(dir, 'repo');
const missingDir = path.join(repoDir, 'packages', 'missing');
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
await expect(resolveWorkspaceTrustFilesystemGitRoot(missingDir)).resolves.toBeNull();
});
it('resolves a valid git worktree to the canonical repository root', async () => {
const dir = await makeTmpDir();
const repoDir = path.join(dir, 'repo');
const worktreeDir = path.join(dir, 'worktrees', 'alice');
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
await createSyntheticWorktree({ repoDir, worktreeDir, name: 'alice' });
await expect(resolveWorkspaceTrustCanonicalGitRoot(worktreeDir)).resolves.toBe(repoDir);
});
it('does not accept a forged gitdir pointer to another repository', async () => {
const dir = await makeTmpDir();
const trustedRepoDir = path.join(dir, 'trusted-repo');
const forgedDir = path.join(dir, 'forged');
await fs.mkdir(path.join(trustedRepoDir, '.git'), { recursive: true });
await fs.mkdir(forgedDir, { recursive: true });
await fs.writeFile(
path.join(forgedDir, '.git'),
`gitdir: ${path.join(trustedRepoDir, '.git')}\n`,
'utf8'
);
await expect(resolveWorkspaceTrustCanonicalGitRoot(forgedDir)).resolves.toBe(forgedDir);
});
it('does not accept borrowed worktree metadata without a backlink', async () => {
const dir = await makeTmpDir();
const trustedRepoDir = path.join(dir, 'trusted-repo');
const forgedDir = path.join(dir, 'forged');
const borrowedWorktreeGitDir = path.join(trustedRepoDir, '.git', 'worktrees', 'alice');
await fs.mkdir(path.join(trustedRepoDir, '.git'), { recursive: true });
await fs.mkdir(forgedDir, { recursive: true });
await fs.mkdir(borrowedWorktreeGitDir, { recursive: true });
await fs.writeFile(
path.join(forgedDir, '.git'),
`gitdir: ${borrowedWorktreeGitDir}\n`,
'utf8'
);
await fs.writeFile(path.join(borrowedWorktreeGitDir, 'commondir'), '../..\n', 'utf8');
await fs.writeFile(
path.join(borrowedWorktreeGitDir, 'gitdir'),
`${path.join(trustedRepoDir, '.git')}\n`,
'utf8'
);
await expect(resolveWorkspaceTrustCanonicalGitRoot(forgedDir)).resolves.toBe(forgedDir);
});
});

View file

@ -85,7 +85,9 @@ vi.mock('@shared/utils/logger', () => ({
// Mock pathDecoder
vi.mock('@main/utils/pathDecoder', () => ({
getAppDataPath: () => path.join(os.homedir(), '.agent-teams-ai', 'data'),
getClaudeBasePath: () => path.join(os.homedir(), '.claude'),
getHomeDir: () => os.homedir(),
}));
import * as fs from 'fs/promises';

View file

@ -1192,6 +1192,65 @@ describe('ipc teams handlers', () => {
}
});
it('preserves attachment delivery errors when the lead process is still alive', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
provisioningService.isTeamAlive.mockReturnValue(true);
provisioningService.sendMessageToTeam.mockRejectedValueOnce(
new Error('Claude attachment MIME unsupported: image/avif')
);
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: 'see this',
attachments: [
{
id: 'att-1',
filename: 'screenshot.png',
mimeType: 'image/png',
size: 4,
data: Buffer.from('test').toString('base64'),
},
],
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toBe(
'Failed to deliver message with attachments: Claude attachment MIME unsupported: image/avif'
);
expect(result.error).not.toContain('team process became unavailable');
expect(service.sendDirectToLead).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
it('reports attachment delivery as unavailable only when liveness confirms it', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
provisioningService.isTeamAlive.mockReturnValueOnce(true).mockReturnValueOnce(false);
provisioningService.sendMessageToTeam.mockRejectedValueOnce(new Error('write EPIPE'));
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: 'see this',
attachments: [
{
id: 'att-1',
filename: 'screenshot.png',
mimeType: 'image/png',
size: 4,
data: Buffer.from('test').toString('base64'),
},
],
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toBe(
'Failed to deliver message with attachments: team process became unavailable'
);
expect(service.sendDirectToLead).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
it('rejects delegate mode when recipient is not the team lead', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();

View file

@ -356,7 +356,6 @@ describe('CliInstallerService', () => {
showBinaryPath: false,
});
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/agent_teams_orchestrator');
vi.mocked(execCli).mockResolvedValueOnce({ stdout: '0.0.46', stderr: '' });
const getProviderStatusesSpy = vi
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses')
.mockResolvedValue([
@ -377,6 +376,8 @@ describe('CliInstallerService', () => {
.filter((event) => event.type === 'status');
expect(status.installed).toBe(true);
expect(status.binaryPath).toBe('/mock/agent_teams_orchestrator');
expect(status.installedVersion).toBeNull();
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
expect(status.authStatusChecking).toBe(false);
expect(status.authLoggedIn).toBe(false);
@ -403,11 +404,14 @@ describe('CliInstallerService', () => {
)
).toBe(true);
expect(getProviderStatusesSpy).not.toHaveBeenCalled();
expect(execCli).toHaveBeenCalledTimes(1);
expect(execCli).toHaveBeenCalledWith(
'/mock/agent_teams_orchestrator',
['--version'],
expect.objectContaining({ timeout: expect.any(Number) })
expect(execCli).not.toHaveBeenCalled();
await vi.waitFor(() =>
expect(appendCliAuthDiag).toHaveBeenCalledWith(
expect.objectContaining({
shellEnvMs: 0,
versionProbeMs: 0,
})
)
);
});
@ -422,6 +426,32 @@ describe('CliInstallerService', () => {
expect(status.installedVersion).toBeNull();
});
it('does not run a redundant version probe before an explicit multimodel provider refresh', async () => {
allowConsoleLogs();
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
});
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/agent_teams_orchestrator');
const providerStatus = createTestProviderStatus('codex', true, 'chatgpt');
const getProviderStatusSpy = vi
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatus')
.mockResolvedValue(providerStatus);
const status = await service.getProviderStatus('codex');
expect(status).toBe(providerStatus);
expect(execCli).not.toHaveBeenCalled();
expect(getProviderStatusSpy).toHaveBeenCalledWith(
'/mock/agent_teams_orchestrator',
'codex',
expect.any(Function)
);
});
it('retries the version probe once before marking the runtime unhealthy', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

View file

@ -595,4 +595,126 @@ describe('CrossPlatformFileChangeSource', () => {
source.stop();
active = false;
});
it('builds a silent startup baseline across incomplete polling cycles', async () => {
let active = true;
const emitted: Array<[string, string]> = [];
const collectPollSnapshot = vi
.fn()
.mockResolvedValueOnce({
files: new Map([['a.jsonl', '1']]),
cycleComplete: false,
})
.mockResolvedValueOnce({
files: new Map([['b.jsonl', '1']]),
cycleComplete: true,
})
.mockResolvedValueOnce({
files: new Map([
['a.jsonl', '2'],
['b.jsonl', '1'],
]),
cycleComplete: true,
});
const source = new CrossPlatformFileChangeSource({
name: 'test-source',
pollIntervalMs: 1000,
collectPollSnapshot,
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
isOwnerActive: () => active,
isWatchLimitError: () => false,
requestRetry: vi.fn(),
});
await source.pollOnce();
expect(source.isPollingPrimed).toBe(false);
await source.pollOnce();
expect(source.isPollingPrimed).toBe(true);
expect(emitted).toEqual([]);
await source.pollOnce();
expect(emitted).toEqual([['change', 'a.jsonl']]);
source.stop();
active = false;
});
it('does not emit deletes from incomplete polling snapshots', async () => {
let active = true;
const emitted: Array<[string, string]> = [];
const collectPollSnapshot = vi
.fn()
.mockResolvedValueOnce(
new Map([
['a.jsonl', '1'],
['b.jsonl', '1'],
])
)
.mockResolvedValueOnce({
files: new Map([['a.jsonl', '1']]),
cycleComplete: false,
})
.mockResolvedValueOnce({
files: new Map<string, string>(),
cycleComplete: true,
});
const source = new CrossPlatformFileChangeSource({
name: 'test-source',
pollIntervalMs: 1000,
collectPollSnapshot,
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
isOwnerActive: () => active,
isWatchLimitError: () => false,
requestRetry: vi.fn(),
});
await source.pollOnce();
await source.pollOnce();
expect(emitted).toEqual([]);
await source.pollOnce();
expect(emitted).toEqual([['rename', 'b.jsonl']]);
source.stop();
active = false;
});
it('suppresses deletes when a completed polling cycle is not delete-safe', async () => {
let active = true;
const emitted: Array<[string, string]> = [];
const collectPollSnapshot = vi
.fn()
.mockResolvedValueOnce(
new Map([
['a.jsonl', '1'],
['b.jsonl', '1'],
])
)
.mockResolvedValueOnce({
files: new Map([['a.jsonl', '1']]),
cycleComplete: false,
})
.mockResolvedValueOnce({
files: new Map<string, string>(),
cycleComplete: true,
deleteSafe: false,
});
const source = new CrossPlatformFileChangeSource({
name: 'test-source',
pollIntervalMs: 1000,
collectPollSnapshot,
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
isOwnerActive: () => active,
isWatchLimitError: () => false,
requestRetry: vi.fn(),
});
await source.pollOnce();
await source.pollOnce();
await source.pollOnce();
expect(emitted).toEqual([]);
source.stop();
active = false;
});
});

View file

@ -641,6 +641,70 @@ describe('FileWatcher', () => {
watcher.stop();
});
it('chunks broad project polling baselines and still emits changes after priming', async () => {
const projectsDir = '/virtual/projects';
const todosDir = '/virtual/todos';
const projectNames = Array.from({ length: 65 }, (_, index) =>
`encoded-project-${String(index).padStart(3, '0')}`
);
const fileState = new Map(projectNames.map((name) => [name, { size: 10, mtimeMs: 1000 }]));
const fsProvider = {
type: 'local' as const,
exists: vi.fn().mockResolvedValue(true),
readFile: vi.fn().mockResolvedValue(''),
stat: vi.fn().mockResolvedValue({
size: 10,
mtimeMs: 1000,
birthtimeMs: 1000,
isFile: () => true,
isDirectory: () => false,
}),
readdir: vi.fn(async (dirPath: string) => {
if (dirPath === projectsDir) {
return projectNames.map((name) => createFsDirent(name, 'directory'));
}
const projectName = path.basename(dirPath);
const state = fileState.get(projectName);
if (state) {
return [createFsDirent('session-1.jsonl', 'file', state)];
}
return [];
}),
createReadStream: vi.fn(() => Readable.from([])),
dispose: vi.fn(),
};
const dataCache = new DataCache(50, 10, false);
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
const events: unknown[] = [];
watcher.on('file-change', (event) => events.push(event));
setWatcherActive(watcher);
const projectsSource = getChangeSource(watcher, 'projects');
await projectsSource.pollOnce();
expect(projectsSource.isPollingPrimed).toBe(false);
expect(events).toEqual([]);
await projectsSource.pollOnce();
expect(projectsSource.isPollingPrimed).toBe(true);
expect(events).toEqual([]);
fileState.set(projectNames[0], { size: 12, mtimeMs: 2000 });
await projectsSource.pollOnce();
await vi.advanceTimersByTimeAsync(100);
expect(events).toContainEqual({
type: 'change',
path: path.join(projectsDir, projectNames[0], 'session-1.jsonl'),
projectId: projectNames[0],
sessionId: 'session-1',
isSubagent: false,
});
watcher.stop();
});
it('treats SSH not-found subagent directories as empty during project polling', async () => {
const projectsDir = '/remote/projects';
const todosDir = '/remote/todos';
@ -2542,6 +2606,56 @@ describe('FileWatcher', () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('preserves line offset for oversized pre-existing files without notifications', async () => {
vi.useRealTimers();
useRealExistsSync();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-large-baseline-'));
const projectsDir = path.join(tempDir, 'projects');
const projectDir = path.join(projectsDir, 'test-project');
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, 'session-large.jsonl');
const largeLineCount = 17_000;
const largePayload = 'old data '.repeat(120);
fs.writeFileSync(
filePath,
Array.from({ length: largeLineCount }, (_, index) =>
jsonlLine(`large-${index}`, largePayload)
).join(''),
'utf8'
);
const dataCache = new DataCache(50, 10, false);
const notificationManager = createMockNotificationManager();
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
watcher.setNotificationManager(notificationManager);
const watcherAny = watcher as unknown as {
detectErrorsInSessionFile: (
projectId: string,
sessionId: string,
filePath: string
) => Promise<void>;
lastProcessedLineCount: Map<string, number>;
lastProcessedSize: Map<string, number>;
instanceCreatedAt: number;
};
watcherAny.instanceCreatedAt = Date.now() + 60_000;
vi.mocked(errorDetector.detectErrors).mockClear();
await watcherAny.detectErrorsInSessionFile('test-project', 'session-large', filePath);
expect(errorDetector.detectErrors).not.toHaveBeenCalled();
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(largeLineCount);
expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size);
expect(notificationManager.addError).not.toHaveBeenCalled();
watcher.stop();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('detects errors immediately for files created after watcher startup', async () => {
vi.useRealTimers();
useRealExistsSync();

View file

@ -161,6 +161,47 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
);
});
it('launches isolated worktrees with the member worktree as the OpenCode project path', async () => {
const worktreePath = '/tmp/generated-worktrees/alice';
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>(async () => successfulOpenCodeLaunchData());
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-worktree')),
launchOpenCodeTeam,
});
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
const result = await adapter.launch(
launchInput({
cwd: worktreePath,
expectedMembers: [
{
name: 'alice',
providerId: 'opencode',
model: 'openai/gpt-5.4-mini',
cwd: worktreePath,
isolation: 'worktree',
},
],
})
);
expect(result.teamLaunchState).toBe('clean_success');
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
projectPath: worktreePath,
selectedModel: 'openai/gpt-5.4-mini',
requireExecutionProbe: true,
});
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
expect.objectContaining({
projectPath: worktreePath,
expectedCapabilitySnapshotId: 'cap-worktree',
members: [expect.objectContaining({ name: 'alice' })],
})
);
});
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
const firstReadiness = readiness({
state: 'mcp_unavailable',

View file

@ -10117,7 +10117,10 @@ describe('Team agent launch matrix safe e2e', () => {
message: { content: Array<Record<string, unknown>> };
};
expect(payload.message.content).toMatchObject([
{ type: 'text', text: 'review the attached files' },
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' },
},
{
type: 'document',
source: { type: 'text', media_type: 'text/plain', data: 'line one\nline two' },
@ -10128,15 +10131,48 @@ describe('Team agent launch matrix safe e2e', () => {
source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0xLjQ=' },
title: 'brief.pdf',
},
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' },
},
{ type: 'text', text: 'review the attached files' },
]);
expect(svc.isTeamAlive(firstTeamName)).toBe(true);
expect(svc.isTeamAlive(secondTeamName)).toBe(true);
});
it('serializes Claude GIF and WebP attachments without marking the team offline', async () => {
const teamName = 'pure-anthropic-extended-image-mimes-safe-e2e';
await writePureAnthropicTeamConfig({ teamName, projectPath });
await writePureAnthropicTeamMeta(teamName, projectPath);
await writePureAnthropicMembersMeta(teamName);
const svc = new TeamProvisioningService();
const run = createPureAnthropicLiveRun({ teamName, projectPath });
const writes: string[] = [];
run.child = { stdin: createWritableStdin(writes) };
trackLiveRun(svc, run);
await svc.sendMessageToTeam(teamName, 'review these browser images', [
{
filename: 'clip.gif',
mimeType: 'image/gif',
data: 'R0lGODlhAQABAAAAACw=',
},
{
filename: 'clip.webp',
mimeType: 'image/webp',
data: 'UklGRiIAAABXRUJQ',
},
]);
expect(writes).toHaveLength(1);
const payload = JSON.parse(writes[0].trim()) as {
message: { content: Array<Record<string, unknown>> };
};
expect(payload.message.content).toMatchObject([
{ type: 'image', source: { type: 'base64', media_type: 'image/gif' } },
{ type: 'image', source: { type: 'base64', media_type: 'image/webp' } },
{ type: 'text', text: 'review these browser images' },
]);
expect(svc.isTeamAlive(teamName)).toBe(true);
});
it('routes messages to the current pure Anthropic run after same-team relaunch', async () => {
const teamName = 'pure-anthropic-message-current-run-safe-e2e';
await writePureAnthropicTeamConfig({ teamName, projectPath });

View file

@ -1,4 +1,7 @@
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/main';
import {
buildWorkspaceTrustPathCandidates,
type WorkspaceTrustWorkspace,
} from '@features/workspace-trust/main';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
@ -23095,6 +23098,42 @@ describe('TeamProvisioningService', () => {
).toEqual(['claude', 'codex', 'gemini', 'opencode']);
});
it('uses the canonical repository root for workspace trust git worktree candidates', async () => {
const svc = new TeamProvisioningService();
const harness = svc as unknown as {
collectWorkspaceTrustWorkspaces(input: {
cwd: string;
members: Array<{ name: string; cwd: string; isolation: 'worktree' }>;
}): Promise<WorkspaceTrustWorkspace[]>;
};
const tempRoot = fs.realpathSync(tempClaudeRoot);
const repoDir = path.join(tempRoot, 'repo');
const worktreeDir = path.join(tempRoot, 'worktrees', 'alice');
const worktreeGitDir = path.join(repoDir, '.git', 'worktrees', 'alice');
fs.mkdirSync(worktreeDir, { recursive: true });
fs.mkdirSync(worktreeGitDir, { recursive: true });
fs.writeFileSync(path.join(worktreeDir, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf8');
fs.writeFileSync(path.join(worktreeGitDir, 'commondir'), '../..\n', 'utf8');
fs.writeFileSync(path.join(worktreeGitDir, 'gitdir'), `${path.join(worktreeDir, '.git')}\n`, 'utf8');
const workspaces = await harness.collectWorkspaceTrustWorkspaces({
cwd: repoDir,
members: [{ name: 'alice', cwd: worktreeDir, isolation: 'worktree' }],
});
const memberWorktrees = workspaces.filter(
(workspace) => workspace.source === 'member-worktree'
);
expect(memberWorktrees[0]).toMatchObject({
cwd: worktreeDir,
gitRootConfigKey: repoDir,
memberId: 'alice',
});
expect(memberWorktrees.every((workspace) => workspace.gitRootConfigKey === repoDir)).toBe(
true
);
});
it('degrades workspace trust planning failures without blocking launch preparation', async () => {
const svc = new TeamProvisioningService();
const workspaces = buildWorkspaceTrustPathCandidates({

View file

@ -1,11 +1,16 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { TeamAttachmentStore } from '../../../src/main/services/team/TeamAttachmentStore';
import { TeamTaskAttachmentStore } from '../../../src/main/services/team/TeamTaskAttachmentStore';
import {
type ElectronUserDataMigrationApp,
getLegacyElectronUserDataCandidates,
migrateElectronUserDataDirectory,
shouldCopyElectronUserDataEntry,
} from '../../../src/main/utils/electronUserDataMigration';
import {
getAppDataPath,
getBackupsBasePath,
@ -13,12 +18,6 @@ import {
getMcpServerBasePath,
setAppDataBasePath,
} from '../../../src/main/utils/pathDecoder';
import {
getLegacyElectronUserDataCandidates,
migrateElectronUserDataDirectory,
shouldCopyElectronUserDataEntry,
type ElectronUserDataMigrationApp,
} from '../../../src/main/utils/electronUserDataMigration';
class FakeElectronApp implements ElectronUserDataMigrationApp {
setPathCalls: { name: string; value: string }[] = [];
@ -407,6 +406,7 @@ describe('electron userData migration', () => {
id: 'att-1',
data: Buffer.from('message attachment').toString('base64'),
mimeType: 'text/plain',
filePath: path.join(currentPath, 'data', 'attachments', 'team-a', 'msg-1', 'att-1--note.txt'),
},
]);

View file

@ -6,9 +6,12 @@ import { describe, expect, it } from 'vitest';
import {
analyzeSessionFileMetadata,
calculateMetrics,
countJsonlFileWithStats,
parseJsonlFile,
parseJsonlFileWithStats,
parseJsonlLine,
} from '../../../src/main/utils/jsonl';
import type { ParsedMessage } from '../../../src/main/types';
// Helper to create a minimal ParsedMessage
@ -190,6 +193,105 @@ describe('jsonl', () => {
});
describe('tolerant parsing', () => {
it('counts parseable entries without retaining messages', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-count-'));
try {
const filePath = path.join(tempDir, 'session.jsonl');
const validAssistant = JSON.stringify({
type: 'assistant',
uuid: 'a1',
timestamp: '2026-01-01T00:00:01.000Z',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'hello' }],
},
});
const validSystem = JSON.stringify({
type: 'system',
uuid: 's1',
timestamp: '2026-01-01T00:00:02.000Z',
content: 'system line',
});
const validUserWithoutContent = JSON.stringify({
type: 'user',
uuid: 'u1',
timestamp: '2026-01-01T00:00:03.000Z',
message: {
role: 'user',
},
});
const validUserArrayMessage = JSON.stringify({
type: 'user',
uuid: 'u2',
timestamp: '2026-01-01T00:00:04.000Z',
message: [],
});
const invalidMissingMessage = JSON.stringify({
type: 'assistant',
uuid: 'bad-assistant',
});
const invalidEmptyUuid = JSON.stringify({
type: 'system',
uuid: '',
content: 'empty uuid',
});
const invalidAssistantMissingContent = JSON.stringify({
type: 'assistant',
uuid: 'bad-assistant-content',
message: {
role: 'assistant',
},
});
const invalidAssistantArrayMessage = JSON.stringify({
type: 'assistant',
uuid: 'bad-assistant-array',
message: [],
});
const invalidAssistantNullContentBlock = JSON.stringify({
type: 'assistant',
uuid: 'bad-assistant-null-block',
message: {
role: 'assistant',
content: [null],
},
});
const unknownType = JSON.stringify({
type: 'unknown',
uuid: 'unknown-1',
});
const partialJson =
'{"type":"assistant","uuid":"a2","timestamp":"2026-01-01T00:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"';
fs.writeFileSync(
filePath,
[
validAssistant,
validSystem,
validUserWithoutContent,
validUserArrayMessage,
invalidMissingMessage,
invalidEmptyUuid,
invalidAssistantMissingContent,
invalidAssistantArrayMessage,
invalidAssistantNullContentBlock,
unknownType,
'not json',
partialJson,
].join('\n'),
'utf8'
);
const parsed = await parseJsonlFileWithStats(filePath);
const counted = await countJsonlFileWithStats(filePath);
expect(parsed.messages.map((message) => message.uuid)).toEqual(['a1', 's1', 'u1', 'u2']);
expect(counted.parsedLineCount).toBe(parsed.parsedLineCount);
expect(counted.consumedBytes).toBe(parsed.consumedBytes);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('skips non-JSON garbage and ignores a partial trailing object', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tolerant-'));
try {

View file

@ -7,11 +7,14 @@ import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder';
import {
isPathWithinRoot,
getHomeDir,
setAppDataBasePath,
setClaudeBasePathOverride,
} from '../../../src/main/utils/pathDecoder';
import {
isPathWithinAllowedDirectories,
isPathWithinRoot,
isWindowsReservedFileName,
validateFileName,
validateFilePath,
@ -22,14 +25,18 @@ import {
describe('pathValidation', () => {
const homeDir = getHomeDir();
const claudeDir = path.join(homeDir, '.claude');
const appDataBasePath = path.join(homeDir, '.agent-teams-ai-test');
const appDataPath = path.join(appDataBasePath, 'data');
const testProjectPath = path.resolve('/home/user/my-project');
beforeEach(() => {
setClaudeBasePathOverride(claudeDir);
setAppDataBasePath(appDataBasePath);
});
afterEach(() => {
setClaudeBasePathOverride(null);
setAppDataBasePath(null);
});
describe('isPathWithinAllowedDirectories', () => {
@ -48,6 +55,15 @@ describe('pathValidation', () => {
).toBe(true);
});
it('should allow paths within app-owned data directory', () => {
expect(
isPathWithinAllowedDirectories(
path.join(appDataPath, 'attachments', 'team-a', 'msg-1', 'att-1--note.txt'),
testProjectPath
)
).toBe(true);
});
it('should reject paths outside allowed directories', () => {
expect(isPathWithinAllowedDirectories('/etc/passwd', testProjectPath)).toBe(false);
});

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
captureStartupMemorySnapshot,
formatStartupMemorySnapshot,
} from '../../../src/main/utils/startupTelemetry';
describe('startupTelemetry', () => {
it('captures only stable numeric memory fields', () => {
const snapshot = captureStartupMemorySnapshot(() => ({
rss: 128 * 1024 * 1024,
heapTotal: 64 * 1024 * 1024,
heapUsed: 32 * 1024 * 1024,
external: 8 * 1024 * 1024,
arrayBuffers: 4 * 1024 * 1024,
}));
expect(snapshot).toEqual({
rssBytes: 134217728,
heapUsedBytes: 33554432,
heapTotalBytes: 67108864,
externalBytes: 8388608,
arrayBuffersBytes: 4194304,
});
});
it('formats rss and heap values for startup logs', () => {
expect(
formatStartupMemorySnapshot({
rssBytes: 128 * 1024 * 1024,
heapUsedBytes: 32 * 1024 * 1024,
heapTotalBytes: 64 * 1024 * 1024,
externalBytes: 8 * 1024 * 1024,
arrayBuffersBytes: 4 * 1024 * 1024,
})
).toBe('rss=128.0MiB heap=32.0MiB/64.0MiB external=8.0MiB');
});
});

View file

@ -0,0 +1,81 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
import { afterEach, describe, expect, it, vi } from 'vitest';
const storeMocks = vi.hoisted(() => ({
revealFileInEditor: vi.fn(),
}));
vi.mock('@features/localization/renderer', () => ({
useAppTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('@renderer/components/team/editor/FileIcon', () => ({
FileIcon: ({ fileName }: { fileName: string }) => React.createElement('span', null, fileName),
}));
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: { revealFileInEditor: (filePath: string) => void }) => unknown) =>
selector({ revealFileInEditor: storeMocks.revealFileInEditor }),
}));
describe('AttachmentDisplay', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
storeMocks.revealFileInEditor.mockReset();
});
it('opens persisted non-image attachments in the built-in editor', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const getAttachments = vi.fn().mockResolvedValue([
{
id: 'att-1',
data: Buffer.from('verification').toString('base64'),
mimeType: 'text/markdown',
filePath: '/app/data/attachments/team-a/msg-1/att-1--verification.md',
},
]);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: { teams: { getAttachments } },
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<AttachmentDisplay
teamName="team-a"
messageId="msg-1"
attachments={[
{
id: 'att-1',
filename: 'verification.md',
mimeType: 'text/markdown',
size: 12,
},
]}
/>
);
});
await act(async () => {
await Promise.resolve();
});
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Open verification.md"]');
expect(button).not.toBeNull();
await act(async () => {
button?.click();
});
expect(storeMocks.revealFileInEditor).toHaveBeenCalledWith(
'/app/data/attachments/team-a/msg-1/att-1--verification.md'
);
});
});

View file

@ -202,6 +202,38 @@ describe('team change throttling', () => {
expect(getRepositoryGroupsSpy).not.toHaveBeenCalled();
});
it('defers the initial global task fetch until the startup idle window', async () => {
const fetchAllTasksSpy = vi.fn(async () => undefined);
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
cleanup?.();
cleanup = initializeNotificationListeners();
await vi.advanceTimersByTimeAsync(0);
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(4_999);
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1);
});
it('cancels the deferred initial global task fetch during listener cleanup', async () => {
const fetchAllTasksSpy = vi.fn(async () => undefined);
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
cleanup?.();
cleanup = initializeNotificationListeners();
await vi.advanceTimersByTimeAsync(0);
cleanup();
cleanup = null;
await vi.advanceTimersByTimeAsync(30_000);
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
});
it('allows next refresh after throttle window passes', async () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');

View file

@ -46,7 +46,9 @@ describe('attachmentRecipientCapabilities', () => {
expect(getMemberAttachmentUnavailableReason(bob)).toBe(
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
);
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBe(
expect(
validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })
).toBe(
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
);
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBe(
@ -62,8 +64,56 @@ describe('attachmentRecipientCapabilities', () => {
expect(getMemberAttachmentUnavailableReason(bob)).toBeNull();
expect(getAttachmentInputAcceptForMember(bob)).toBe('image/png,image/jpeg,image/webp');
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBeNull();
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBeNull();
expect(
validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })
).toBeNull();
expect(
validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })
).toBeNull();
});
it('blocks image MIME types not supported by an otherwise image-capable provider', () => {
const codexLead = member({
name: 'lead',
agentType: 'team-lead',
providerId: 'codex',
model: 'gpt-5.5',
});
expect(
validateAttachmentFilesForMember({
member: codexLead,
files: [file('animation.gif', 'image/gif')],
})
).toBe('This image type is not supported by the selected model.');
expect(
validateAttachmentPayloadsForMember({
member: codexLead,
attachments: [payload({ filename: 'animation.gif', mimeType: 'image/gif' })],
})
).toBe('This image type is not supported by the selected model.');
});
it('allows Claude GIF and WebP image payloads', () => {
const anthropicLead = member({
name: 'lead',
agentType: 'team-lead',
providerId: 'anthropic',
model: 'claude-opus-4-6',
});
expect(
validateAttachmentFilesForMember({
member: anthropicLead,
files: [file('clip.gif', 'image/gif')],
})
).toBeNull();
expect(
validateAttachmentPayloadsForMember({
member: anthropicLead,
attachments: [payload({ filename: 'clip.webp', mimeType: 'image/webp' })],
})
).toBeNull();
});
it('blocks non-image files for image-only providers', () => {
@ -74,7 +124,12 @@ describe('attachmentRecipientCapabilities', () => {
model: 'gpt-5.5',
});
expect(validateAttachmentFilesForMember({ member: codexLead, files: [file('notes.md', 'text/markdown')] })).toBe(
expect(
validateAttachmentFilesForMember({
member: codexLead,
files: [file('notes.md', 'text/markdown')],
})
).toBe(
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
);
expect(
@ -95,7 +150,12 @@ describe('attachmentRecipientCapabilities', () => {
model: 'claude-opus-4-6',
});
expect(validateAttachmentFilesForMember({ member: anthropicLead, files: [file('brief.pdf', 'application/pdf')] })).toBeNull();
expect(
validateAttachmentFilesForMember({
member: anthropicLead,
files: [file('brief.pdf', 'application/pdf')],
})
).toBeNull();
expect(
validateAttachmentPayloadsForMember({
member: anthropicLead,