diff --git a/docs/RELEASE.md b/docs/RELEASE.md index dd87624a..92089034 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -146,6 +146,14 @@ Public release notes must follow this standard every time: - Verify actual asset names with `gh release view v --repo 777genius/agent-teams-ai --json assets` before writing links. - Prefer versioned installer links for release-specific notes: `Agent.Teams.AI--arm64.dmg`, `Agent.Teams.AI--x64.dmg`, `Agent.Teams.AI.Setup..exe`, `Agent.Teams.AI-.AppImage`, `agent-teams-ai__amd64.deb`, `agent-teams-ai-.x86_64.rpm`, and `agent-teams-ai-.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 --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` assets. - The asset names in the notes match the assets uploaded by `release.yml`. -- `gh release view v --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 --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 --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. diff --git a/package.json b/package.json index c2ca558d..48c25107 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/patches/@radix-ui__react-checkbox@1.3.3.patch b/patches/@radix-ui__react-checkbox@1.3.3.patch new file mode 100644 index 00000000..cfcff131 --- /dev/null +++ b/patches/@radix-ui__react-checkbox@1.3.3.patch @@ -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(() => { diff --git a/patches/@radix-ui__react-dismissable-layer@1.1.11.patch b/patches/@radix-ui__react-dismissable-layer@1.1.11.patch new file mode 100644 index 00000000..572ae84a --- /dev/null +++ b/patches/@radix-ui__react-dismissable-layer@1.1.11.patch @@ -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); diff --git a/patches/@radix-ui__react-menu@2.1.16.patch b/patches/@radix-ui__react-menu@2.1.16.patch new file mode 100644 index 00000000..783b0eaa --- /dev/null +++ b/patches/@radix-ui__react-menu@2.1.16.patch @@ -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 + } + ) diff --git a/patches/@radix-ui__react-popper@1.2.8.patch b/patches/@radix-ui__react-popper@1.2.8.patch new file mode 100644 index 00000000..25dce894 --- /dev/null +++ b/patches/@radix-ui__react-popper@1.2.8.patch @@ -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; diff --git a/patches/@radix-ui__react-select@2.2.6.patch b/patches/@radix-ui__react-select@2.2.6.patch new file mode 100644 index 00000000..7c84fd8b --- /dev/null +++ b/patches/@radix-ui__react-select@2.2.6.patch @@ -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) + ); diff --git a/patches/@radix-ui__react-tooltip@1.2.8.patch b/patches/@radix-ui__react-tooltip@1.2.8.patch new file mode 100644 index 00000000..ec9742b9 --- /dev/null +++ b/patches/@radix-ui__react-tooltip@1.2.8.patch @@ -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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2793d188..f5afc456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/scripts/ci/verify-radix-presence-patch.mjs b/scripts/ci/verify-radix-presence-patch.mjs index 36cf3f21..e62b7515 100644 --- a/scripts/ci/verify-radix-presence-patch.mjs +++ b/scripts/ci/verify-radix-presence-patch.mjs @@ -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, diff --git a/scripts/ci/verify-radix-renderer-bundle.mjs b/scripts/ci/verify-radix-renderer-bundle.mjs new file mode 100644 index 00000000..527c4763 --- /dev/null +++ b/scripts/ci/verify-radix-renderer-bundle.mjs @@ -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); +} diff --git a/scripts/electron-builder/dist.mjs b/scripts/electron-builder/dist.mjs index cc98ed61..da1d8765 100644 --- a/scripts/electron-builder/dist.mjs +++ b/scripts/electron-builder/dist.mjs @@ -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); } diff --git a/src/features/agent-attachments/core/domain/capabilities.ts b/src/features/agent-attachments/core/domain/capabilities.ts index 3cff77f2..d039587a 100644 --- a/src/features/agent-attachments/core/domain/capabilities.ts +++ b/src/features/agent-attachments/core/domain/capabilities.ts @@ -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, diff --git a/src/features/agent-attachments/core/domain/types.ts b/src/features/agent-attachments/core/domain/types.ts index dc644327..cf30d3e6 100644 --- a/src/features/agent-attachments/core/domain/types.ts +++ b/src/features/agent-attachments/core/domain/types.ts @@ -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 = diff --git a/src/features/agent-attachments/core/domain/validation.test.ts b/src/features/agent-attachments/core/domain/validation.test.ts index 4a08834d..4da67d15 100644 --- a/src/features/agent-attachments/core/domain/validation.test.ts +++ b/src/features/agent-attachments/core/domain/validation.test.ts @@ -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', diff --git a/src/features/agent-attachments/core/domain/validation.ts b/src/features/agent-attachments/core/domain/validation.ts index e30db572..74805ae3 100644 --- a/src/features/agent-attachments/core/domain/validation.ts +++ b/src/features/agent-attachments/core/domain/validation.ts @@ -13,10 +13,22 @@ import type { const AGENT_IMAGE_MIME_TYPES = new Set([ 'image/png', 'image/jpeg', + 'image/gif', 'image/webp', ]); -const PROVIDER_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg']); +const OPTIMIZABLE_AGENT_IMAGE_MIME_TYPES = new Set>([ + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +const PROVIDER_IMAGE_MIME_TYPES = new Set([ + '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 { + return OPTIMIZABLE_AGENT_IMAGE_MIME_TYPES.has( + mimeType as Exclude + ); +} + 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', diff --git a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts index ad15ea14..e1ce91fc 100644 --- a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts +++ b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.test.ts @@ -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/); }); diff --git a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts index 17b14202..68bcc468 100644 --- a/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts +++ b/src/features/agent-attachments/main/providers/claudeAttachmentAdapter.ts @@ -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(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[] { diff --git a/src/features/workspace-trust/main/index.ts b/src/features/workspace-trust/main/index.ts index 9a5ab0f4..1ed6f3d0 100644 --- a/src/features/workspace-trust/main/index.ts +++ b/src/features/workspace-trust/main/index.ts @@ -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'; diff --git a/src/features/workspace-trust/main/infrastructure/WorkspaceTrustCanonicalGitRoot.ts b/src/features/workspace-trust/main/infrastructure/WorkspaceTrustCanonicalGitRoot.ts new file mode 100644 index 00000000..8f0c45ab --- /dev/null +++ b/src/features/workspace-trust/main/infrastructure/WorkspaceTrustCanonicalGitRoot.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +async function realpathOrNull(value: string): Promise { + try { + return await fs.realpath(value); + } catch { + return null; + } +} + +async function readTrimmedFileOrNull(filePath: string): Promise { + try { + const value = await fs.readFile(filePath, 'utf8'); + return value.trim(); + } catch { + return null; + } +} + +export async function resolveWorkspaceTrustFilesystemGitRoot(cwd: string): Promise { + 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 { + 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' + ); +} diff --git a/src/main/index.ts b/src/main/index.ts index 746a21c2..c3069f59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, now: number): void { +function updateStartupTimeline( + update: Partial, + now: number, + memory: AppStartupMemorySnapshot +): void { if (!update.phase && !update.message) { return; } @@ -1015,12 +1031,14 @@ function updateStartupTimeline(update: Partial, 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, 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): 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); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index de7e0a6b..64898634 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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'; diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 1bbf7d43..8d3df08b 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -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); diff --git a/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts b/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts index 636fa274..21965669 100644 --- a/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts +++ b/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts @@ -14,11 +14,19 @@ export interface WatcherLifecycle { isCurrent: () => boolean; } +export interface PollSnapshotResult { + files: Map; + cycleComplete: boolean; + deleteSafe?: boolean; +} + +type PollSnapshot = Map | PollSnapshotResult; + export interface CrossPlatformFileChangeSourceOptions { name: string; pollIntervalMs: number; createWatcher?: (lifecycle: WatcherLifecycle) => Promise | CloseableWatcher; - collectPollSnapshot: () => Promise>; + collectPollSnapshot: () => Promise; 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(); + private partialPollSnapshot = new Map(); 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 { - 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): void { + for (const [relativePath, fingerprint] of files) { + this.partialPollSnapshot.set(relativePath, fingerprint); + } } private async closeWatcher(watcher: CloseableWatcher): Promise { @@ -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; +} diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 5b37a7e9..9e2ff08e 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -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(); + /** 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> { + private async collectProjectsPollSnapshot(): Promise { const snapshot = new Map(); - 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> { @@ -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 { + 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. diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index 8b78943f..1b0b05ae 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -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; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9da14501..86390c9b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { + private async resolveWorkspaceTrustGitRoot(cwd: string): Promise { const normalizedCwd = cwd.trim(); if (!normalizedCwd) { - return Promise.resolve(null); + return null; } - return new Promise((resolve) => { + const gitRoot = await new Promise((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( diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index e61b1d1e..480235d1 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -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> { + 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 { + 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 & { + 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 { + 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 // ============================================================================= diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 9e58601d..5590b9cd 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -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 diff --git a/src/main/utils/startupTelemetry.ts b/src/main/utils/startupTelemetry.ts new file mode 100644 index 00000000..d78f6e8b --- /dev/null +++ b/src/main/utils/startupTelemetry.ts @@ -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`; +} diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index acd87928..b5ee70a6 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -1036,14 +1036,14 @@ const InstalledBanner = ({ >
-
- +
+ {provider.providerId === 'opencode' @@ -1053,7 +1053,7 @@ const InstalledBanner = ({ {openCodeDashboardChips.map((chip) => ( {chip.label} @@ -1061,7 +1061,7 @@ const InstalledBanner = ({ ))} { {/* Create/Edit Dialog */} {dialogOpen && ( - + + } + > { <>
-
- +
+ {provider.displayName}
{launchDialogOpen && ( - + + } + > {launchDialogOpen && ( - + + } + > + setLaunchDialogOpen(false)} + /> + } + > {launchDialogMode === 'relaunch' ? ( { 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 ? ( + ) : (
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialogLoadingFallback.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialogLoadingFallback.tsx new file mode 100644 index 00000000..f792709d --- /dev/null +++ b/src/renderer/components/team/dialogs/LaunchTeamDialogLoadingFallback.tsx @@ -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 ( + { + if (!nextOpen) { + onClose(); + } + }} + > + + + {title} + + {mode === 'schedule' ? ( + description + ) : ( + <> + {description} {teamName}{' '} + {mode === 'relaunch' + ? t('launch.description.relaunchSuffix') + : t('launch.description.launchSuffix')} + + )} + + +
+ + {tCommon('states.loading')} +
+
+
+ ); +}; diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index 8375e9c3..b8c39491 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -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 && ( - + + } + > = 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 | null = null; let runtimeStatusTimer: ReturnType | 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 diff --git a/src/renderer/utils/attachmentRecipientCapabilities.ts b/src/renderer/utils/attachmentRecipientCapabilities.ts index 007fa8fb..73be8faf 100644 --- a/src/renderer/utils/attachmentRecipientCapabilities.ts +++ b/src/renderer/utils/attachmentRecipientCapabilities.ts @@ -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.'; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e1bf644b..babea1bf 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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 // ============================================================================= diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 391f51e7..5bc39708 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -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; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index cff49c08..7d690285 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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). */ diff --git a/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts b/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts index aa089aa0..271af68b 100644 --- a/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts +++ b/test/features/workspace-trust/core/WorkspaceTrustCoordinator.test.ts @@ -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({ diff --git a/test/features/workspace-trust/main/WorkspaceTrustCanonicalGitRoot.test.ts b/test/features/workspace-trust/main/WorkspaceTrustCanonicalGitRoot.test.ts new file mode 100644 index 00000000..739d9c9c --- /dev/null +++ b/test/features/workspace-trust/main/WorkspaceTrustCanonicalGitRoot.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/test/main/ipc/editor.test.ts b/test/main/ipc/editor.test.ts index a174625d..86f0cfc4 100644 --- a/test/main/ipc/editor.test.ts +++ b/test/main/ipc/editor.test.ts @@ -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'; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 0fa721bd..3b39d005 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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(); diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 4ba73b75..7b1cf99f 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -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'); diff --git a/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts b/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts index 0ea2b0d1..205e7470 100644 --- a/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts +++ b/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts @@ -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(), + 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(), + 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; + }); }); diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 497d953c..15c5d621 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -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; + lastProcessedLineCount: Map; + lastProcessedSize: Map; + 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(); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 01788ce2..50e98f0d 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -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 + >(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', diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 4c972500..47bad9c3 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -10117,7 +10117,10 @@ describe('Team agent launch matrix safe e2e', () => { message: { content: Array> }; }; 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> }; + }; + 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 }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7b43ddb7..35bb481a 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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; + }; + 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({ diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts index 3b00d4ff..a7ee7e17 100644 --- a/test/main/utils/electronUserDataMigration.test.ts +++ b/test/main/utils/electronUserDataMigration.test.ts @@ -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'), }, ]); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index 00ea479b..b670e1bb 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -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 { diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts index 9965f72f..de8bbb77 100644 --- a/test/main/utils/pathValidation.test.ts +++ b/test/main/utils/pathValidation.test.ts @@ -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); }); diff --git a/test/main/utils/startupTelemetry.test.ts b/test/main/utils/startupTelemetry.test.ts new file mode 100644 index 00000000..4df4bc06 --- /dev/null +++ b/test/main/utils/startupTelemetry.test.ts @@ -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'); + }); +}); diff --git a/test/renderer/components/team/attachments/AttachmentDisplay.test.tsx b/test/renderer/components/team/attachments/AttachmentDisplay.test.tsx new file mode 100644 index 00000000..a942c317 --- /dev/null +++ b/test/renderer/components/team/attachments/AttachmentDisplay.test.tsx @@ -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( + + ); + }); + await act(async () => { + await Promise.resolve(); + }); + + const button = host.querySelector('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' + ); + }); +}); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 4ce938e3..8bc2710a 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -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'); diff --git a/test/renderer/utils/attachmentRecipientCapabilities.test.ts b/test/renderer/utils/attachmentRecipientCapabilities.test.ts index 5a54dd40..94e7e022 100644 --- a/test/renderer/utils/attachmentRecipientCapabilities.test.ts +++ b/test/renderer/utils/attachmentRecipientCapabilities.test.ts @@ -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,