merge: dev into main
# Conflicts: # scripts/ci/verify-radix-presence-patch.mjs
This commit is contained in:
commit
33bbf949ec
60 changed files with 2853 additions and 175 deletions
|
|
@ -146,6 +146,14 @@ Public release notes must follow this standard every time:
|
|||
- Verify actual asset names with `gh release view v<VERSION> --repo 777genius/agent-teams-ai --json assets` before writing links.
|
||||
- Prefer versioned installer links for release-specific notes: `Agent.Teams.AI-<VERSION>-arm64.dmg`, `Agent.Teams.AI-<VERSION>-x64.dmg`, `Agent.Teams.AI.Setup.<VERSION>.exe`, `Agent.Teams.AI-<VERSION>.AppImage`, `agent-teams-ai_<VERSION>_amd64.deb`, `agent-teams-ai-<VERSION>.x86_64.rpm`, and `agent-teams-ai-<VERSION>.pacman`.
|
||||
|
||||
Draft releases must be treated as review artifacts:
|
||||
|
||||
- Do not hand off a draft release for review while it still has generated notes, stale notes from an earlier run, or a `Full Changelog`-only body.
|
||||
- Before telling the user a draft is ready, always edit the draft body with the current release notes template and then re-check it with `gh release view v<VERSION> --repo 777genius/agent-teams-ai --json body,assets,isDraft,isPrerelease,targetCommitish`.
|
||||
- Confirm the notes describe the exact target commit that the draft was built from, including any commits added after a previous draft attempt.
|
||||
- If a draft already exists when starting or retrying a release, do not delete it automatically. Ask for explicit permission to delete, replace, or reuse it.
|
||||
- Never delete a draft release just because the user said to "make a release" or "redo the release". Deleting a draft requires a separate explicit command such as "delete the draft release".
|
||||
|
||||
### 4. Required release closeout gate
|
||||
|
||||
Do not publish or call a release finished until this is true:
|
||||
|
|
@ -154,7 +162,8 @@ Do not publish or call a release finished until this is true:
|
|||
- The release body starts with short user-facing notes: what changed, why users care, and the most important fixes.
|
||||
- The `Downloads` table from the template is present and every link points to the current `v<VERSION>` assets.
|
||||
- The asset names in the notes match the assets uploaded by `release.yml`.
|
||||
- `gh release view v<VERSION> --json body,assets,isDraft,isPrerelease` confirms the release is public, has notes, and has the expected installer assets.
|
||||
- For a draft handoff, `gh release view v<VERSION> --json body,assets,isDraft,isPrerelease,targetCommitish` confirms the release is still a draft, targets the intended commit, has current notes, and has the expected installer assets.
|
||||
- For final publication, `gh release view v<VERSION> --json body,assets,isDraft,isPrerelease,targetCommitish` confirms the release is public, has current notes, targets the intended commit, and has the expected installer assets.
|
||||
|
||||
If a draft was published before notes were written, immediately edit the public release body with `gh release edit`; do not leave a release with only generated notes.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
178
patches/@radix-ui__react-checkbox@1.3.3.patch
Normal file
178
patches/@radix-ui__react-checkbox@1.3.3.patch
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 6fb1a27897e543acf531704205f561f98eba0229..5180fb3fbd81c43ad539c3b2bf8047ee845f9602 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -59,6 +59,29 @@ var import_jsx_runtime = require("react/jsx-runtime");
|
||||
var CHECKBOX_NAME = "Checkbox";
|
||||
var [createCheckboxContext, createCheckboxScope] = (0, import_react_context.createContextScope)(CHECKBOX_NAME);
|
||||
var [CheckboxProviderImpl, useCheckboxContext] = createCheckboxContext(CHECKBOX_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
function CheckboxProvider(props) {
|
||||
const {
|
||||
__scopeCheckbox,
|
||||
@@ -82,6 +105,8 @@ function CheckboxProvider(props) {
|
||||
});
|
||||
const [control, setControl] = React.useState(null);
|
||||
const [bubbleInput, setBubbleInput] = React.useState(null);
|
||||
+ const setControlRef = useGuardedNodeSetter(setControl);
|
||||
+ const setBubbleInputRef = useGuardedNodeSetter(setBubbleInput);
|
||||
const hasConsumerStoppedPropagationRef = React.useRef(false);
|
||||
const isFormControl = control ? !!form || !!control.closest("form") : (
|
||||
// We set this to true by default so that events bubble to forms without JS (SSR)
|
||||
@@ -92,7 +117,7 @@ function CheckboxProvider(props) {
|
||||
disabled,
|
||||
setChecked,
|
||||
control,
|
||||
- setControl,
|
||||
+ setControl: setControlRef,
|
||||
name,
|
||||
form,
|
||||
value,
|
||||
@@ -101,7 +126,7 @@ function CheckboxProvider(props) {
|
||||
defaultChecked: isIndeterminate(defaultChecked) ? false : defaultChecked,
|
||||
isFormControl,
|
||||
bubbleInput,
|
||||
- setBubbleInput
|
||||
+ setBubbleInput: setBubbleInputRef
|
||||
};
|
||||
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
CheckboxProviderImpl,
|
||||
@@ -121,13 +146,13 @@ var CheckboxTrigger = React.forwardRef(
|
||||
disabled,
|
||||
checked,
|
||||
required,
|
||||
- setControl,
|
||||
+ setControl: setControlRef,
|
||||
setChecked,
|
||||
hasConsumerStoppedPropagationRef,
|
||||
isFormControl,
|
||||
bubbleInput
|
||||
} = useCheckboxContext(TRIGGER_NAME, __scopeCheckbox);
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setControl);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setControlRef);
|
||||
const initialCheckedStateRef = React.useRef(checked);
|
||||
React.useEffect(() => {
|
||||
const form = control?.form;
|
||||
@@ -250,9 +275,9 @@ var CheckboxBubbleInput = React.forwardRef(
|
||||
value,
|
||||
form,
|
||||
bubbleInput,
|
||||
- setBubbleInput
|
||||
+ setBubbleInput: setBubbleInputRef
|
||||
} = useCheckboxContext(BUBBLE_INPUT_NAME, __scopeCheckbox);
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setBubbleInput);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setBubbleInputRef);
|
||||
const prevChecked = (0, import_react_use_previous.usePrevious)(checked);
|
||||
const controlSize = (0, import_react_use_size.useSize)(control);
|
||||
React.useEffect(() => {
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 3f718f60b0032a25ad9386082aab9329bd6f5c7a..0688026b0d188d36dc0c4c036c9ededcc8e4f9fe 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -14,6 +14,29 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
||||
var CHECKBOX_NAME = "Checkbox";
|
||||
var [createCheckboxContext, createCheckboxScope] = createContextScope(CHECKBOX_NAME);
|
||||
var [CheckboxProviderImpl, useCheckboxContext] = createCheckboxContext(CHECKBOX_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
function CheckboxProvider(props) {
|
||||
const {
|
||||
__scopeCheckbox,
|
||||
@@ -37,6 +60,8 @@ function CheckboxProvider(props) {
|
||||
});
|
||||
const [control, setControl] = React.useState(null);
|
||||
const [bubbleInput, setBubbleInput] = React.useState(null);
|
||||
+ const setControlRef = useGuardedNodeSetter(setControl);
|
||||
+ const setBubbleInputRef = useGuardedNodeSetter(setBubbleInput);
|
||||
const hasConsumerStoppedPropagationRef = React.useRef(false);
|
||||
const isFormControl = control ? !!form || !!control.closest("form") : (
|
||||
// We set this to true by default so that events bubble to forms without JS (SSR)
|
||||
@@ -47,7 +72,7 @@ function CheckboxProvider(props) {
|
||||
disabled,
|
||||
setChecked,
|
||||
control,
|
||||
- setControl,
|
||||
+ setControl: setControlRef,
|
||||
name,
|
||||
form,
|
||||
value,
|
||||
@@ -56,7 +81,7 @@ function CheckboxProvider(props) {
|
||||
defaultChecked: isIndeterminate(defaultChecked) ? false : defaultChecked,
|
||||
isFormControl,
|
||||
bubbleInput,
|
||||
- setBubbleInput
|
||||
+ setBubbleInput: setBubbleInputRef
|
||||
};
|
||||
return /* @__PURE__ */ jsx(
|
||||
CheckboxProviderImpl,
|
||||
@@ -76,13 +101,13 @@ var CheckboxTrigger = React.forwardRef(
|
||||
disabled,
|
||||
checked,
|
||||
required,
|
||||
- setControl,
|
||||
+ setControl: setControlRef,
|
||||
setChecked,
|
||||
hasConsumerStoppedPropagationRef,
|
||||
isFormControl,
|
||||
bubbleInput
|
||||
} = useCheckboxContext(TRIGGER_NAME, __scopeCheckbox);
|
||||
- const composedRefs = useComposedRefs(forwardedRef, setControl);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setControlRef);
|
||||
const initialCheckedStateRef = React.useRef(checked);
|
||||
React.useEffect(() => {
|
||||
const form = control?.form;
|
||||
@@ -205,9 +230,9 @@ var CheckboxBubbleInput = React.forwardRef(
|
||||
value,
|
||||
form,
|
||||
bubbleInput,
|
||||
- setBubbleInput
|
||||
+ setBubbleInput: setBubbleInputRef
|
||||
} = useCheckboxContext(BUBBLE_INPUT_NAME, __scopeCheckbox);
|
||||
- const composedRefs = useComposedRefs(forwardedRef, setBubbleInput);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setBubbleInputRef);
|
||||
const prevChecked = usePrevious(checked);
|
||||
const controlSize = useSize(control);
|
||||
React.useEffect(() => {
|
||||
70
patches/@radix-ui__react-dismissable-layer@1.1.11.patch
Normal file
70
patches/@radix-ui__react-dismissable-layer@1.1.11.patch
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -71,9 +71,30 @@ var DismissableLayer = React.forwardRef(
|
||||
} = props;
|
||||
const context = React.useContext(DismissableLayerContext);
|
||||
const [node, setNode] = React.useState(null);
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
const ownerDocument = node?.ownerDocument ?? globalThis?.document;
|
||||
const [, force] = React.useState({});
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node2) => setNode(node2));
|
||||
+ const setNodeRef = React.useCallback((node2) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node2) {
|
||||
+ syncNode(node2);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, []);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setNodeRef);
|
||||
const layers = Array.from(context.layers);
|
||||
const [highestLayerWithOutsidePointerEventsDisabled] = [...context.layersWithOutsidePointerEventsDisabled].slice(-1);
|
||||
const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf(highestLayerWithOutsidePointerEventsDisabled);
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -33,9 +33,30 @@ var DismissableLayer = React.forwardRef(
|
||||
} = props;
|
||||
const context = React.useContext(DismissableLayerContext);
|
||||
const [node, setNode] = React.useState(null);
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
const ownerDocument = node?.ownerDocument ?? globalThis?.document;
|
||||
const [, force] = React.useState({});
|
||||
- const composedRefs = useComposedRefs(forwardedRef, (node2) => setNode(node2));
|
||||
+ const setNodeRef = React.useCallback((node2) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node2) {
|
||||
+ syncNode(node2);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, []);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setNodeRef);
|
||||
const layers = Array.from(context.layers);
|
||||
const [highestLayerWithOutsidePointerEventsDisabled] = [...context.layersWithOutsidePointerEventsDisabled].slice(-1);
|
||||
const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf(highestLayerWithOutsidePointerEventsDisabled);
|
||||
150
patches/@radix-ui__react-menu@2.1.16.patch
Normal file
150
patches/@radix-ui__react-menu@2.1.16.patch
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 4d102d0cf4c9f82dcf13a384e5af0af36a4285d3..252229df648edad3752bd8db172ad5aa52a527de 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -113,10 +113,34 @@ var usePopperScope = (0, import_react_popper.createPopperScope)();
|
||||
var useRovingFocusGroupScope = (0, import_react_roving_focus.createRovingFocusGroupScope)();
|
||||
var [MenuProvider, useMenuContext] = createMenuContext(MENU_NAME);
|
||||
var [MenuRootProvider, useMenuRootContext] = createMenuContext(MENU_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var Menu = (props) => {
|
||||
const { __scopeMenu, open = false, children, dir, onOpenChange, modal = true } = props;
|
||||
const popperScope = usePopperScope(__scopeMenu);
|
||||
const [content, setContent] = React.useState(null);
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
const isUsingKeyboardRef = React.useRef(false);
|
||||
const handleOpenChange = (0, import_react_use_callback_ref.useCallbackRef)(onOpenChange);
|
||||
const direction = (0, import_react_direction.useDirection)(dir);
|
||||
@@ -141,7 +165,7 @@ var Menu = (props) => {
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
content,
|
||||
- onContentChange: setContent,
|
||||
+ onContentChange: setContentRef,
|
||||
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
MenuRootProvider,
|
||||
{
|
||||
@@ -653,6 +677,8 @@ var MenuSub = (props) => {
|
||||
const popperScope = usePopperScope(__scopeMenu);
|
||||
const [trigger, setTrigger] = React.useState(null);
|
||||
const [content, setContent] = React.useState(null);
|
||||
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
const handleOpenChange = (0, import_react_use_callback_ref.useCallbackRef)(onOpenChange);
|
||||
React.useEffect(() => {
|
||||
if (parentMenuContext.open === false) handleOpenChange(false);
|
||||
@@ -665,7 +691,7 @@ var MenuSub = (props) => {
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
content,
|
||||
- onContentChange: setContent,
|
||||
+ onContentChange: setContentRef,
|
||||
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
MenuSubProvider,
|
||||
{
|
||||
@@ -673,7 +699,7 @@ var MenuSub = (props) => {
|
||||
contentId: (0, import_react_id.useId)(),
|
||||
triggerId: (0, import_react_id.useId)(),
|
||||
trigger,
|
||||
- onTriggerChange: setTrigger,
|
||||
+ onTriggerChange: setTriggerRef,
|
||||
children
|
||||
}
|
||||
)
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 10eefb0533ee4fa16b7f0e42671969c2c4464835..680d56317917bff5bec1added766c553c5c00c52 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -46,10 +46,34 @@ var usePopperScope = createPopperScope();
|
||||
var useRovingFocusGroupScope = createRovingFocusGroupScope();
|
||||
var [MenuProvider, useMenuContext] = createMenuContext(MENU_NAME);
|
||||
var [MenuRootProvider, useMenuRootContext] = createMenuContext(MENU_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var Menu = (props) => {
|
||||
const { __scopeMenu, open = false, children, dir, onOpenChange, modal = true } = props;
|
||||
const popperScope = usePopperScope(__scopeMenu);
|
||||
const [content, setContent] = React.useState(null);
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
const isUsingKeyboardRef = React.useRef(false);
|
||||
const handleOpenChange = useCallbackRef(onOpenChange);
|
||||
const direction = useDirection(dir);
|
||||
@@ -74,7 +98,7 @@ var Menu = (props) => {
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
content,
|
||||
- onContentChange: setContent,
|
||||
+ onContentChange: setContentRef,
|
||||
children: /* @__PURE__ */ jsx(
|
||||
MenuRootProvider,
|
||||
{
|
||||
@@ -586,6 +610,8 @@ var MenuSub = (props) => {
|
||||
const popperScope = usePopperScope(__scopeMenu);
|
||||
const [trigger, setTrigger] = React.useState(null);
|
||||
const [content, setContent] = React.useState(null);
|
||||
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
const handleOpenChange = useCallbackRef(onOpenChange);
|
||||
React.useEffect(() => {
|
||||
if (parentMenuContext.open === false) handleOpenChange(false);
|
||||
@@ -598,7 +624,7 @@ var MenuSub = (props) => {
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
content,
|
||||
- onContentChange: setContent,
|
||||
+ onContentChange: setContentRef,
|
||||
children: /* @__PURE__ */ jsx(
|
||||
MenuSubProvider,
|
||||
{
|
||||
@@ -606,7 +632,7 @@ var MenuSub = (props) => {
|
||||
contentId: useId(),
|
||||
triggerId: useId(),
|
||||
trigger,
|
||||
- onTriggerChange: setTrigger,
|
||||
+ onTriggerChange: setTriggerRef,
|
||||
children
|
||||
}
|
||||
)
|
||||
88
patches/@radix-ui__react-popper@1.2.8.patch
Normal file
88
patches/@radix-ui__react-popper@1.2.8.patch
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 5cb1fd007d32021ad950d961dd4a0868576a2c0d..795a24fe0e75aaa042cad0a376a776f3e1436269 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -61,6 +61,29 @@ var ALIGN_OPTIONS = ["start", "center", "end"];
|
||||
var POPPER_NAME = "Popper";
|
||||
var [createPopperContext, createPopperScope] = (0, import_react_context.createContextScope)(POPPER_NAME);
|
||||
var [PopperProvider, usePopperContext] = createPopperContext(POPPER_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var Popper = (props) => {
|
||||
const { __scopePopper, children } = props;
|
||||
const [anchor, setAnchor] = React.useState(null);
|
||||
@@ -108,7 +131,8 @@ var PopperContent = React.forwardRef(
|
||||
} = props;
|
||||
const context = usePopperContext(CONTENT_NAME, __scopePopper);
|
||||
const [content, setContent] = React.useState(null);
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContent(node));
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContentRef);
|
||||
const [arrow, setArrow] = React.useState(null);
|
||||
const arrowSize = (0, import_react_use_size.useSize)(arrow);
|
||||
const arrowWidth = arrowSize?.width ?? 0;
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 9f84984eab84e32d20d6c052217da0e3b8374b40..0ffdb6313708ab3113c0ce40fe4519b6b605deff 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -26,6 +26,29 @@ var ALIGN_OPTIONS = ["start", "center", "end"];
|
||||
var POPPER_NAME = "Popper";
|
||||
var [createPopperContext, createPopperScope] = createContextScope(POPPER_NAME);
|
||||
var [PopperProvider, usePopperContext] = createPopperContext(POPPER_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var Popper = (props) => {
|
||||
const { __scopePopper, children } = props;
|
||||
const [anchor, setAnchor] = React.useState(null);
|
||||
@@ -73,7 +96,8 @@ var PopperContent = React.forwardRef(
|
||||
} = props;
|
||||
const context = usePopperContext(CONTENT_NAME, __scopePopper);
|
||||
const [content, setContent] = React.useState(null);
|
||||
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node));
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setContentRef);
|
||||
const [arrow, setArrow] = React.useState(null);
|
||||
const arrowSize = useSize(arrow);
|
||||
const arrowWidth = arrowSize?.width ?? 0;
|
||||
280
patches/@radix-ui__react-select@2.2.6.patch
Normal file
280
patches/@radix-ui__react-select@2.2.6.patch
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index dc37ac4a018a086c4244a09a67215dbaa9b4de65..fc80522666f91087ce1bce3a34844b17c74cdf6c 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -104,6 +104,29 @@ var [createSelectContext, createSelectScope] = (0, import_react_context.createCo
|
||||
var usePopperScope = (0, import_react_popper.createPopperScope)();
|
||||
var [SelectProvider, useSelectContext] = createSelectContext(SELECT_NAME);
|
||||
var [SelectNativeOptionsProvider, useSelectNativeOptionsContext] = createSelectContext(SELECT_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var Select = (props) => {
|
||||
const {
|
||||
__scopeSelect,
|
||||
@@ -124,6 +147,8 @@ var Select = (props) => {
|
||||
const popperScope = usePopperScope(__scopeSelect);
|
||||
const [trigger, setTrigger] = React.useState(null);
|
||||
const [valueNode, setValueNode] = React.useState(null);
|
||||
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
|
||||
+ const setValueNodeRef = useGuardedNodeSetter(setValueNode);
|
||||
const [valueNodeHasChildren, setValueNodeHasChildren] = React.useState(false);
|
||||
const direction = (0, import_react_direction.useDirection)(dir);
|
||||
const [open, setOpen] = (0, import_react_use_controllable_state.useControllableState)({
|
||||
@@ -148,9 +173,9 @@ var Select = (props) => {
|
||||
required,
|
||||
scope: __scopeSelect,
|
||||
trigger,
|
||||
- onTriggerChange: setTrigger,
|
||||
+ onTriggerChange: setTriggerRef,
|
||||
valueNode,
|
||||
- onValueNodeChange: setValueNode,
|
||||
+ onValueNodeChange: setValueNodeRef,
|
||||
valueNodeHasChildren,
|
||||
onValueNodeHasChildrenChange: setValueNodeHasChildren,
|
||||
contentId: (0, import_react_id.useId)(),
|
||||
@@ -366,11 +391,15 @@ var SelectContentImpl = React.forwardRef(
|
||||
const context = useSelectContext(CONTENT_NAME, __scopeSelect);
|
||||
const [content, setContent] = React.useState(null);
|
||||
const [viewport, setViewport] = React.useState(null);
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContent(node));
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
+ const setViewportRef = useGuardedNodeSetter(setViewport);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContentRef);
|
||||
const [selectedItem, setSelectedItem] = React.useState(null);
|
||||
const [selectedItemText, setSelectedItemText] = React.useState(
|
||||
null
|
||||
);
|
||||
+ const setSelectedItemRef = useGuardedNodeSetter(setSelectedItem);
|
||||
+ const setSelectedItemTextRef = useGuardedNodeSetter(setSelectedItemText);
|
||||
const getItems = useCollection(__scopeSelect);
|
||||
const [isPositioned, setIsPositioned] = React.useState(false);
|
||||
const firstValidItemFoundRef = React.useRef(false);
|
||||
@@ -456,11 +485,11 @@ var SelectContentImpl = React.forwardRef(
|
||||
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
|
||||
const isSelectedItem = context.value !== void 0 && context.value === value;
|
||||
if (isSelectedItem || isFirstValidItem) {
|
||||
- setSelectedItem(node);
|
||||
+ setSelectedItemRef(node);
|
||||
if (isFirstValidItem) firstValidItemFoundRef.current = true;
|
||||
}
|
||||
},
|
||||
- [context.value]
|
||||
+ [context.value, setSelectedItemRef]
|
||||
);
|
||||
const handleItemLeave = React.useCallback(() => content?.focus(), [content]);
|
||||
const itemTextRefCallback = React.useCallback(
|
||||
@@ -468,10 +497,10 @@ var SelectContentImpl = React.forwardRef(
|
||||
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
|
||||
const isSelectedItem = context.value !== void 0 && context.value === value;
|
||||
if (isSelectedItem || isFirstValidItem) {
|
||||
- setSelectedItemText(node);
|
||||
+ setSelectedItemTextRef(node);
|
||||
}
|
||||
},
|
||||
- [context.value]
|
||||
+ [context.value, setSelectedItemTextRef]
|
||||
);
|
||||
const SelectPosition = position === "popper" ? SelectPopperPosition : SelectItemAlignedPosition;
|
||||
const popperContentProps = SelectPosition === SelectPopperPosition ? {
|
||||
@@ -492,7 +521,7 @@ var SelectContentImpl = React.forwardRef(
|
||||
scope: __scopeSelect,
|
||||
content,
|
||||
viewport,
|
||||
- onViewportChange: setViewport,
|
||||
+ onViewportChange: setViewportRef,
|
||||
itemRefCallback,
|
||||
selectedItem,
|
||||
onItemLeave: handleItemLeave,
|
||||
@@ -580,7 +609,9 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
|
||||
const contentContext = useSelectContentContext(CONTENT_NAME, __scopeSelect);
|
||||
const [contentWrapper, setContentWrapper] = React.useState(null);
|
||||
const [content, setContent] = React.useState(null);
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContent(node));
|
||||
+ const setContentWrapperRef = useGuardedNodeSetter(setContentWrapper);
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContentRef);
|
||||
const getItems = useCollection(__scopeSelect);
|
||||
const shouldExpandOnScrollRef = React.useRef(false);
|
||||
const shouldRepositionRef = React.useRef(true);
|
||||
@@ -709,7 +740,7 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
|
||||
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
"div",
|
||||
{
|
||||
- ref: setContentWrapper,
|
||||
+ ref: setContentWrapperRef,
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -971,9 +1002,10 @@ var SelectItemText = React.forwardRef(
|
||||
const itemContext = useSelectItemContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const nativeOptionsContext = useSelectNativeOptionsContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const [itemTextNode, setItemTextNode] = React.useState(null);
|
||||
+ const setItemTextNodeRef = useGuardedNodeSetter(setItemTextNode);
|
||||
const composedRefs = (0, import_react_compose_refs.useComposedRefs)(
|
||||
forwardedRef,
|
||||
- (node) => setItemTextNode(node),
|
||||
+ setItemTextNodeRef,
|
||||
itemContext.onItemTextChange,
|
||||
(node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
|
||||
);
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index f9b94f39dfddef678ef3086354f8d7413ae27e52..4a53ec0d65aa051b95e3e8ea4aff4fdd52a17406 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -37,6 +37,29 @@ var [createSelectContext, createSelectScope] = createContextScope(SELECT_NAME, [
|
||||
var usePopperScope = createPopperScope();
|
||||
var [SelectProvider, useSelectContext] = createSelectContext(SELECT_NAME);
|
||||
var [SelectNativeOptionsProvider, useSelectNativeOptionsContext] = createSelectContext(SELECT_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var Select = (props) => {
|
||||
const {
|
||||
__scopeSelect,
|
||||
@@ -57,6 +80,8 @@ var Select = (props) => {
|
||||
const popperScope = usePopperScope(__scopeSelect);
|
||||
const [trigger, setTrigger] = React.useState(null);
|
||||
const [valueNode, setValueNode] = React.useState(null);
|
||||
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
|
||||
+ const setValueNodeRef = useGuardedNodeSetter(setValueNode);
|
||||
const [valueNodeHasChildren, setValueNodeHasChildren] = React.useState(false);
|
||||
const direction = useDirection(dir);
|
||||
const [open, setOpen] = useControllableState({
|
||||
@@ -81,9 +106,9 @@ var Select = (props) => {
|
||||
required,
|
||||
scope: __scopeSelect,
|
||||
trigger,
|
||||
- onTriggerChange: setTrigger,
|
||||
+ onTriggerChange: setTriggerRef,
|
||||
valueNode,
|
||||
- onValueNodeChange: setValueNode,
|
||||
+ onValueNodeChange: setValueNodeRef,
|
||||
valueNodeHasChildren,
|
||||
onValueNodeHasChildrenChange: setValueNodeHasChildren,
|
||||
contentId: useId(),
|
||||
@@ -299,11 +324,15 @@ var SelectContentImpl = React.forwardRef(
|
||||
const context = useSelectContext(CONTENT_NAME, __scopeSelect);
|
||||
const [content, setContent] = React.useState(null);
|
||||
const [viewport, setViewport] = React.useState(null);
|
||||
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node));
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
+ const setViewportRef = useGuardedNodeSetter(setViewport);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setContentRef);
|
||||
const [selectedItem, setSelectedItem] = React.useState(null);
|
||||
const [selectedItemText, setSelectedItemText] = React.useState(
|
||||
null
|
||||
);
|
||||
+ const setSelectedItemRef = useGuardedNodeSetter(setSelectedItem);
|
||||
+ const setSelectedItemTextRef = useGuardedNodeSetter(setSelectedItemText);
|
||||
const getItems = useCollection(__scopeSelect);
|
||||
const [isPositioned, setIsPositioned] = React.useState(false);
|
||||
const firstValidItemFoundRef = React.useRef(false);
|
||||
@@ -389,11 +418,11 @@ var SelectContentImpl = React.forwardRef(
|
||||
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
|
||||
const isSelectedItem = context.value !== void 0 && context.value === value;
|
||||
if (isSelectedItem || isFirstValidItem) {
|
||||
- setSelectedItem(node);
|
||||
+ setSelectedItemRef(node);
|
||||
if (isFirstValidItem) firstValidItemFoundRef.current = true;
|
||||
}
|
||||
},
|
||||
- [context.value]
|
||||
+ [context.value, setSelectedItemRef]
|
||||
);
|
||||
const handleItemLeave = React.useCallback(() => content?.focus(), [content]);
|
||||
const itemTextRefCallback = React.useCallback(
|
||||
@@ -401,10 +430,10 @@ var SelectContentImpl = React.forwardRef(
|
||||
const isFirstValidItem = !firstValidItemFoundRef.current && !disabled;
|
||||
const isSelectedItem = context.value !== void 0 && context.value === value;
|
||||
if (isSelectedItem || isFirstValidItem) {
|
||||
- setSelectedItemText(node);
|
||||
+ setSelectedItemTextRef(node);
|
||||
}
|
||||
},
|
||||
- [context.value]
|
||||
+ [context.value, setSelectedItemTextRef]
|
||||
);
|
||||
const SelectPosition = position === "popper" ? SelectPopperPosition : SelectItemAlignedPosition;
|
||||
const popperContentProps = SelectPosition === SelectPopperPosition ? {
|
||||
@@ -425,7 +454,7 @@ var SelectContentImpl = React.forwardRef(
|
||||
scope: __scopeSelect,
|
||||
content,
|
||||
viewport,
|
||||
- onViewportChange: setViewport,
|
||||
+ onViewportChange: setViewportRef,
|
||||
itemRefCallback,
|
||||
selectedItem,
|
||||
onItemLeave: handleItemLeave,
|
||||
@@ -513,7 +542,9 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
|
||||
const contentContext = useSelectContentContext(CONTENT_NAME, __scopeSelect);
|
||||
const [contentWrapper, setContentWrapper] = React.useState(null);
|
||||
const [content, setContent] = React.useState(null);
|
||||
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContent(node));
|
||||
+ const setContentWrapperRef = useGuardedNodeSetter(setContentWrapper);
|
||||
+ const setContentRef = useGuardedNodeSetter(setContent);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setContentRef);
|
||||
const getItems = useCollection(__scopeSelect);
|
||||
const shouldExpandOnScrollRef = React.useRef(false);
|
||||
const shouldRepositionRef = React.useRef(true);
|
||||
@@ -642,7 +673,7 @@ var SelectItemAlignedPosition = React.forwardRef((props, forwardedRef) => {
|
||||
children: /* @__PURE__ */ jsx(
|
||||
"div",
|
||||
{
|
||||
- ref: setContentWrapper,
|
||||
+ ref: setContentWrapperRef,
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -904,9 +935,10 @@ var SelectItemText = React.forwardRef(
|
||||
const itemContext = useSelectItemContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const nativeOptionsContext = useSelectNativeOptionsContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const [itemTextNode, setItemTextNode] = React.useState(null);
|
||||
+ const setItemTextNodeRef = useGuardedNodeSetter(setItemTextNode);
|
||||
const composedRefs = useComposedRefs(
|
||||
forwardedRef,
|
||||
- (node) => setItemTextNode(node),
|
||||
+ setItemTextNodeRef,
|
||||
itemContext.onItemTextChange,
|
||||
(node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
|
||||
);
|
||||
102
patches/@radix-ui__react-tooltip@1.2.8.patch
Normal file
102
patches/@radix-ui__react-tooltip@1.2.8.patch
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 2d0d314a00082c458d2f551d715572cd1fa1b5c3..1954a52d7e25a2d733d57f2f32113af8a69a18bf 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -71,6 +71,29 @@ var PROVIDER_NAME = "TooltipProvider";
|
||||
var DEFAULT_DELAY_DURATION = 700;
|
||||
var TOOLTIP_OPEN = "tooltip.open";
|
||||
var [TooltipProviderContextProvider, useTooltipProviderContext] = createTooltipContext(PROVIDER_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var TooltipProvider = (props) => {
|
||||
const {
|
||||
__scopeTooltip,
|
||||
@@ -128,6 +151,7 @@ var Tooltip = (props) => {
|
||||
const providerContext = useTooltipProviderContext(TOOLTIP_NAME, props.__scopeTooltip);
|
||||
const popperScope = usePopperScope(__scopeTooltip);
|
||||
const [trigger, setTrigger] = React.useState(null);
|
||||
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
|
||||
const contentId = (0, import_react_id.useId)();
|
||||
const openTimerRef = React.useRef(0);
|
||||
const disableHoverableContent = disableHoverableContentProp ?? providerContext.disableHoverableContent;
|
||||
@@ -185,7 +209,7 @@ var Tooltip = (props) => {
|
||||
open,
|
||||
stateAttribute,
|
||||
trigger,
|
||||
- onTriggerChange: setTrigger,
|
||||
+ onTriggerChange: setTriggerRef,
|
||||
onTriggerEnter: React.useCallback(() => {
|
||||
if (providerContext.isOpenDelayedRef.current) handleDelayedOpen();
|
||||
else handleOpen();
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 568389bf3ce8123fa6de6d298878b32d613e25cf..a809f654d8b2d84be0f5747384a180a0864c7d44 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -24,6 +24,29 @@ var PROVIDER_NAME = "TooltipProvider";
|
||||
var DEFAULT_DELAY_DURATION = 700;
|
||||
var TOOLTIP_OPEN = "tooltip.open";
|
||||
var [TooltipProviderContextProvider, useTooltipProviderContext] = createTooltipContext(PROVIDER_NAME);
|
||||
+function useGuardedNodeSetter(setNode) {
|
||||
+ const nodeRef = React.useRef(null);
|
||||
+ const nodeCleanupGenerationRef = React.useRef(0);
|
||||
+ return React.useCallback((node) => {
|
||||
+ const syncNode = (nextNode) => {
|
||||
+ if (nodeRef.current === nextNode) return;
|
||||
+ nodeRef.current = nextNode;
|
||||
+ setNode(nextNode);
|
||||
+ };
|
||||
+ nodeCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = nodeCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncNode(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (nodeCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncNode(null);
|
||||
+ });
|
||||
+ }, [setNode]);
|
||||
+}
|
||||
var TooltipProvider = (props) => {
|
||||
const {
|
||||
__scopeTooltip,
|
||||
@@ -81,6 +104,7 @@ var Tooltip = (props) => {
|
||||
const providerContext = useTooltipProviderContext(TOOLTIP_NAME, props.__scopeTooltip);
|
||||
const popperScope = usePopperScope(__scopeTooltip);
|
||||
const [trigger, setTrigger] = React.useState(null);
|
||||
+ const setTriggerRef = useGuardedNodeSetter(setTrigger);
|
||||
const contentId = useId();
|
||||
const openTimerRef = React.useRef(0);
|
||||
const disableHoverableContent = disableHoverableContentProp ?? providerContext.disableHoverableContent;
|
||||
@@ -138,7 +162,7 @@ var Tooltip = (props) => {
|
||||
open,
|
||||
stateAttribute,
|
||||
trigger,
|
||||
- onTriggerChange: setTrigger,
|
||||
+ onTriggerChange: setTriggerRef,
|
||||
onTriggerEnter: React.useCallback(() => {
|
||||
if (providerContext.isOpenDelayedRef.current) handleDelayedOpen();
|
||||
else handleOpen();
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
86
scripts/ci/verify-radix-renderer-bundle.mjs
Normal file
86
scripts/ci/verify-radix-renderer-bundle.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const assetsDir = join(process.cwd(), 'out', 'renderer', 'assets');
|
||||
const rendererBundles = readdirSync(assetsDir)
|
||||
.filter((entry) => entry.endsWith('.js'))
|
||||
.sort();
|
||||
|
||||
if (rendererBundles.length === 0) {
|
||||
console.error(
|
||||
[
|
||||
'No renderer JavaScript bundles found under out/renderer/assets.',
|
||||
'Run `pnpm build` before packaging production artifacts.',
|
||||
].join('\n')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const requiredMarkers = [
|
||||
'nodeCleanupGenerationRef',
|
||||
'syncNode(null)',
|
||||
'useGuardedNodeSetter',
|
||||
'setTriggerRef',
|
||||
'setValueNodeRef',
|
||||
'setContentRef',
|
||||
'setViewportRef',
|
||||
'setSelectedItemRef',
|
||||
'setSelectedItemTextRef',
|
||||
'setItemTextNodeRef',
|
||||
'setControlRef',
|
||||
'setBubbleInputRef',
|
||||
];
|
||||
|
||||
const forbiddenSnippets = [
|
||||
'(node) => setContent(node)',
|
||||
'(node2) => setNode(node2)',
|
||||
'(node) => setItemTextNode(node)',
|
||||
'onContentChange: setContent,',
|
||||
'onTriggerChange: setTrigger,',
|
||||
'onValueNodeChange: setValueNode,',
|
||||
'onViewportChange: setViewport,',
|
||||
'ref: setContentWrapper,',
|
||||
'setSelectedItem(node);',
|
||||
'setSelectedItemText(node);',
|
||||
'useComposedRefs(forwardedRef, setControl)',
|
||||
'useComposedRefs(forwardedRef, setBubbleInput)',
|
||||
'useComposedRefs)(forwardedRef, setControl)',
|
||||
'useComposedRefs)(forwardedRef, setBubbleInput)',
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
const bundleSources = new Map();
|
||||
let combinedSource = '';
|
||||
|
||||
for (const bundleName of rendererBundles) {
|
||||
const bundlePath = join(assetsDir, bundleName);
|
||||
const source = readFileSync(bundlePath, 'utf8');
|
||||
bundleSources.set(bundleName, source);
|
||||
combinedSource += source;
|
||||
}
|
||||
|
||||
const missingMarkers = requiredMarkers.filter((marker) => !combinedSource.includes(marker));
|
||||
if (missingMarkers.length > 0) {
|
||||
failures.push(`renderer bundles: missing markers: ${missingMarkers.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const [bundleName, source] of bundleSources) {
|
||||
const presentForbiddenSnippets = forbiddenSnippets.filter((snippet) => source.includes(snippet));
|
||||
|
||||
if (presentForbiddenSnippets.length > 0) {
|
||||
failures.push(
|
||||
`${bundleName}: forbidden snippets still present: ${presentForbiddenSnippets.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
[
|
||||
'Renderer bundle was built without the complete Radix React 19 ref-cleanup guards.',
|
||||
'',
|
||||
...failures,
|
||||
].join('\n')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -13,10 +13,22 @@ import type {
|
|||
const AGENT_IMAGE_MIME_TYPES = new Set<AgentImageMimeType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const PROVIDER_IMAGE_MIME_TYPES = new Set<ProviderImageMimeType>(['image/png', 'image/jpeg']);
|
||||
const OPTIMIZABLE_AGENT_IMAGE_MIME_TYPES = new Set<Exclude<AgentImageMimeType, 'image/gif'>>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const PROVIDER_IMAGE_MIME_TYPES = new Set<ProviderImageMimeType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export function isAgentImageMimeType(mimeType: string): mimeType is AgentImageMimeType {
|
||||
return AGENT_IMAGE_MIME_TYPES.has(mimeType as AgentImageMimeType);
|
||||
|
|
@ -26,12 +38,27 @@ export function isProviderImageMimeType(mimeType: string): mimeType is ProviderI
|
|||
return PROVIDER_IMAGE_MIME_TYPES.has(mimeType as ProviderImageMimeType);
|
||||
}
|
||||
|
||||
function isOptimizableAgentImageMimeType(
|
||||
mimeType: string
|
||||
): mimeType is Exclude<AgentImageMimeType, 'image/gif'> {
|
||||
return OPTIMIZABLE_AGENT_IMAGE_MIME_TYPES.has(
|
||||
mimeType as Exclude<AgentImageMimeType, 'image/gif'>
|
||||
);
|
||||
}
|
||||
|
||||
function isProviderFileMimeType(mimeType: string, supported: readonly string[]): boolean {
|
||||
return supported.some((candidate) =>
|
||||
candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType
|
||||
);
|
||||
}
|
||||
|
||||
function isCapabilityImageMimeType(
|
||||
mimeType: string,
|
||||
supported: readonly ProviderImageMimeType[]
|
||||
): boolean {
|
||||
return supported.includes(mimeType as ProviderImageMimeType);
|
||||
}
|
||||
|
||||
export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind {
|
||||
if (isAgentImageMimeType(mimeType)) return 'image';
|
||||
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
|
||||
|
|
@ -48,11 +75,11 @@ export function validateImageOptimizationInput(input: {
|
|||
budget?: ImageOptimizationBudget;
|
||||
}): AttachmentValidationResult {
|
||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||
if (!isAgentImageMimeType(input.mimeType)) {
|
||||
if (!isOptimizableAgentImageMimeType(input.mimeType)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'This file type is not supported for agent image delivery.',
|
||||
message: 'This image type is not supported for optimization.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -139,7 +166,7 @@ export function validateAttachmentForCapability(input: {
|
|||
};
|
||||
}
|
||||
|
||||
if (!isProviderImageMimeType(attachment.mimeType)) {
|
||||
if (!isCapabilityImageMimeType(attachment.mimeType, capability.supportedImageMimeTypes)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
|
||||
import {
|
||||
AgentAttachmentError,
|
||||
CLAUDE_IMAGE_MIME_TYPES,
|
||||
} from '@features/agent-attachments/core/domain';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
|
|
@ -27,20 +30,25 @@ function decodeBase64Text(data: string): { ok: true; text: string } | { ok: fals
|
|||
return { ok: true, text: decoded };
|
||||
}
|
||||
|
||||
const CLAUDE_IMAGE_MIME_TYPE_SET = new Set<string>(CLAUDE_IMAGE_MIME_TYPES);
|
||||
|
||||
export function buildClaudeAttachmentDeliveryParts(input: {
|
||||
text: string;
|
||||
attachments?: AttachmentPayload[];
|
||||
}): ClaudeAttachmentDeliveryParts {
|
||||
const contentBlocks: ClaudeInputBlock[] = [{ type: 'text', text: input.text }];
|
||||
const textBlock: ClaudeInputBlock = { type: 'text', text: input.text };
|
||||
const attachments = input.attachments ?? [];
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return { kind: 'legacy_text', blocks: contentBlocks };
|
||||
return { kind: 'legacy_text', blocks: [textBlock] };
|
||||
}
|
||||
|
||||
const imageBlocks: ClaudeInputBlock[] = [];
|
||||
const documentBlocks: ClaudeInputBlock[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.mimeType === 'application/pdf') {
|
||||
contentBlocks.push({
|
||||
documentBlocks.push({
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
|
|
@ -54,7 +62,7 @@ export function buildClaudeAttachmentDeliveryParts(input: {
|
|||
|
||||
if (attachment.mimeType === 'text/plain' || attachment.mimeType.startsWith('text/')) {
|
||||
const decoded = decodeBase64Text(attachment.data);
|
||||
contentBlocks.push(
|
||||
documentBlocks.push(
|
||||
decoded.ok
|
||||
? {
|
||||
type: 'document',
|
||||
|
|
@ -78,8 +86,8 @@ export function buildClaudeAttachmentDeliveryParts(input: {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (attachment.mimeType === 'image/png' || attachment.mimeType === 'image/jpeg') {
|
||||
contentBlocks.push({
|
||||
if (CLAUDE_IMAGE_MIME_TYPE_SET.has(attachment.mimeType)) {
|
||||
imageBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
// Claude expects image bytes inside the structured image block as base64.
|
||||
|
|
@ -99,7 +107,7 @@ export function buildClaudeAttachmentDeliveryParts(input: {
|
|||
);
|
||||
}
|
||||
|
||||
return { kind: 'structured_blocks', blocks: contentBlocks };
|
||||
return { kind: 'structured_blocks', blocks: [...imageBlocks, ...documentBlocks, textBlock] };
|
||||
}
|
||||
|
||||
export function redactClaudeBlocksForDiagnostics(blocks: ClaudeInputBlock[]): ClaudeInputBlock[] {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
async function realpathOrNull(value: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.realpath(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readTrimmedFileOrNull(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const value = await fs.readFile(filePath, 'utf8');
|
||||
return value.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceTrustFilesystemGitRoot(cwd: string): Promise<string | null> {
|
||||
let current = path.resolve(cwd).normalize('NFC');
|
||||
const root = path.parse(current).root;
|
||||
try {
|
||||
const cwdStat = await fs.stat(current);
|
||||
if (!cwdStat.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(current, '.git'));
|
||||
if (stat.isDirectory() || stat.isFile()) {
|
||||
return current;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking until the filesystem root.
|
||||
}
|
||||
|
||||
if (current === root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceTrustCanonicalGitRoot(gitRoot: string): Promise<string> {
|
||||
const normalizedGitRoot = path.resolve(gitRoot).normalize('NFC');
|
||||
const gitFileContent = await readTrimmedFileOrNull(path.join(normalizedGitRoot, '.git'));
|
||||
if (!gitFileContent?.startsWith('gitdir:')) {
|
||||
return normalizedGitRoot;
|
||||
}
|
||||
|
||||
const worktreeGitDir = path
|
||||
.resolve(normalizedGitRoot, gitFileContent.slice('gitdir:'.length).trim())
|
||||
.normalize('NFC');
|
||||
const commonDirRaw = await readTrimmedFileOrNull(path.join(worktreeGitDir, 'commondir'));
|
||||
if (!commonDirRaw) {
|
||||
return normalizedGitRoot;
|
||||
}
|
||||
|
||||
const commonDir = path.resolve(worktreeGitDir, commonDirRaw).normalize('NFC');
|
||||
// Guard against a repo borrowing another trusted repo's worktree metadata.
|
||||
if (path.resolve(path.dirname(worktreeGitDir)) !== path.join(commonDir, 'worktrees')) {
|
||||
return normalizedGitRoot;
|
||||
}
|
||||
|
||||
const gitdirBacklink = await readTrimmedFileOrNull(path.join(worktreeGitDir, 'gitdir'));
|
||||
if (!gitdirBacklink) {
|
||||
return normalizedGitRoot;
|
||||
}
|
||||
|
||||
const [backlink, realGitRoot] = await Promise.all([
|
||||
realpathOrNull(gitdirBacklink),
|
||||
realpathOrNull(normalizedGitRoot),
|
||||
]);
|
||||
if (!backlink || !realGitRoot || backlink !== path.join(realGitRoot, '.git')) {
|
||||
return normalizedGitRoot;
|
||||
}
|
||||
|
||||
return (path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir).normalize(
|
||||
'NFC'
|
||||
);
|
||||
}
|
||||
|
|
@ -191,6 +191,10 @@ import {
|
|||
markRendererUnavailable,
|
||||
safeSendToRenderer,
|
||||
} from './utils/safeWebContentsSend';
|
||||
import {
|
||||
captureStartupMemorySnapshot,
|
||||
formatStartupMemorySnapshot,
|
||||
} from './utils/startupTelemetry';
|
||||
import { syncTelemetryFlag } from './sentry';
|
||||
import { setCodexRuntimeMainWindow } from './ipc/codexRuntime';
|
||||
import {
|
||||
|
|
@ -236,7 +240,12 @@ import {
|
|||
} from './services';
|
||||
|
||||
import type { FileChangeEvent } from '@main/types';
|
||||
import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
|
||||
import type {
|
||||
AppStartupMemorySnapshot,
|
||||
AppStartupStatus,
|
||||
AppStartupStep,
|
||||
TeamChangeEvent,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('App');
|
||||
const appStartedAtMs = Date.now();
|
||||
|
|
@ -936,12 +945,14 @@ const STARTUP_CLI_WARMUP_DELAY_MS = 90_000;
|
|||
const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000;
|
||||
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||
const appStartupStartedAt = Date.now();
|
||||
const initialStartupMemory = captureStartupMemorySnapshot();
|
||||
let appStartupSteps: AppStartupStep[] = [
|
||||
{
|
||||
phase: 'boot',
|
||||
message: 'Starting Agent Teams AI...',
|
||||
startedAt: appStartupStartedAt,
|
||||
updatedAt: appStartupStartedAt,
|
||||
memoryAtStart: initialStartupMemory,
|
||||
},
|
||||
];
|
||||
let appStartupStatus: AppStartupStatus = {
|
||||
|
|
@ -951,6 +962,7 @@ let appStartupStatus: AppStartupStatus = {
|
|||
error: null,
|
||||
startedAt: appStartupStartedAt,
|
||||
updatedAt: appStartupStartedAt,
|
||||
memory: initialStartupMemory,
|
||||
steps: appStartupSteps,
|
||||
};
|
||||
|
||||
|
|
@ -1001,7 +1013,11 @@ function cloneStartupSteps(): AppStartupStep[] {
|
|||
return appStartupSteps.map((step) => ({ ...step }));
|
||||
}
|
||||
|
||||
function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number): void {
|
||||
function updateStartupTimeline(
|
||||
update: Partial<AppStartupStatus>,
|
||||
now: number,
|
||||
memory: AppStartupMemorySnapshot
|
||||
): void {
|
||||
if (!update.phase && !update.message) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1015,12 +1031,14 @@ function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number):
|
|||
current.finishedAt = now;
|
||||
current.durationMs = now - current.startedAt;
|
||||
current.updatedAt = now;
|
||||
current.memoryAtEnd = memory;
|
||||
}
|
||||
appStartupSteps.push({
|
||||
phase,
|
||||
message,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
memoryAtStart: memory,
|
||||
});
|
||||
if (appStartupSteps.length > 32) {
|
||||
appStartupSteps = appStartupSteps.slice(-32);
|
||||
|
|
@ -1031,7 +1049,7 @@ function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number):
|
|||
}
|
||||
}
|
||||
|
||||
function finishCurrentStartupStep(now: number): void {
|
||||
function finishCurrentStartupStep(now: number, memory: AppStartupMemorySnapshot): void {
|
||||
const current = appStartupSteps[appStartupSteps.length - 1];
|
||||
if (!current || current.finishedAt) {
|
||||
return;
|
||||
|
|
@ -1039,20 +1057,30 @@ function finishCurrentStartupStep(now: number): void {
|
|||
current.finishedAt = now;
|
||||
current.durationMs = now - current.startedAt;
|
||||
current.updatedAt = now;
|
||||
current.memoryAtEnd = memory;
|
||||
}
|
||||
|
||||
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
|
||||
const now = Date.now();
|
||||
updateStartupTimeline(update, now);
|
||||
const memory = captureStartupMemorySnapshot();
|
||||
updateStartupTimeline(update, now, memory);
|
||||
if (update.ready === true || update.error) {
|
||||
finishCurrentStartupStep(now);
|
||||
finishCurrentStartupStep(now, memory);
|
||||
}
|
||||
appStartupStatus = {
|
||||
...appStartupStatus,
|
||||
...update,
|
||||
updatedAt: now,
|
||||
memory,
|
||||
steps: cloneStartupSteps(),
|
||||
};
|
||||
if (update.phase || update.ready === true || update.error) {
|
||||
logger.info(
|
||||
`[startup] phase=${appStartupStatus.phase} ready=${appStartupStatus.ready} elapsedMs=${
|
||||
now - appStartupStartedAt
|
||||
} ${formatStartupMemorySnapshot(memory)}`
|
||||
);
|
||||
}
|
||||
safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,19 @@ export interface WatcherLifecycle {
|
|||
isCurrent: () => boolean;
|
||||
}
|
||||
|
||||
export interface PollSnapshotResult {
|
||||
files: Map<string, string>;
|
||||
cycleComplete: boolean;
|
||||
deleteSafe?: boolean;
|
||||
}
|
||||
|
||||
type PollSnapshot = Map<string, string> | PollSnapshotResult;
|
||||
|
||||
export interface CrossPlatformFileChangeSourceOptions {
|
||||
name: string;
|
||||
pollIntervalMs: number;
|
||||
createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher;
|
||||
collectPollSnapshot: () => Promise<Map<string, string>>;
|
||||
collectPollSnapshot: () => Promise<PollSnapshot>;
|
||||
emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void;
|
||||
isOwnerActive: () => boolean;
|
||||
isWatchLimitError: (error: unknown) => boolean;
|
||||
|
|
@ -34,6 +42,7 @@ export class CrossPlatformFileChangeSource {
|
|||
private pollingGenerationInProgress: number | null = null;
|
||||
private pollingPrimed = false;
|
||||
private pollSnapshot = new Map<string, string>();
|
||||
private partialPollSnapshot = new Map<string, string>();
|
||||
private closedGeneration: number | null = null;
|
||||
private rejectedGeneration: number | null = null;
|
||||
private generation = 0;
|
||||
|
|
@ -180,6 +189,7 @@ export class CrossPlatformFileChangeSource {
|
|||
this.pollingGenerationInProgress = null;
|
||||
this.pollingPrimed = false;
|
||||
this.pollSnapshot.clear();
|
||||
this.partialPollSnapshot.clear();
|
||||
|
||||
const timer = this.pollingTimer;
|
||||
this.pollingTimer = null;
|
||||
|
|
@ -265,34 +275,51 @@ export class CrossPlatformFileChangeSource {
|
|||
}
|
||||
|
||||
private async pollForChanges(expectedGeneration: number): Promise<void> {
|
||||
const nextSnapshot = await this.options.collectPollSnapshot();
|
||||
const nextSnapshot = normalizePollSnapshot(await this.options.collectPollSnapshot());
|
||||
if (expectedGeneration !== this.generation || !this.options.isOwnerActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mergePartialPollSnapshot(nextSnapshot.files);
|
||||
|
||||
if (!this.pollingPrimed) {
|
||||
logger.info(`${this.options.name} polling baseline captured`);
|
||||
this.pollSnapshot = nextSnapshot;
|
||||
this.pollingPrimed = true;
|
||||
if (nextSnapshot.cycleComplete) {
|
||||
logger.info(`${this.options.name} polling baseline captured`);
|
||||
this.pollSnapshot = this.partialPollSnapshot;
|
||||
this.partialPollSnapshot = new Map();
|
||||
this.pollingPrimed = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [relativePath, fingerprint] of nextSnapshot) {
|
||||
for (const [relativePath, fingerprint] of nextSnapshot.files) {
|
||||
const previous = this.pollSnapshot.get(relativePath);
|
||||
if (previous === undefined) {
|
||||
this.options.emitPolledChange('rename', relativePath);
|
||||
} else if (previous !== fingerprint) {
|
||||
this.options.emitPolledChange('change', relativePath);
|
||||
}
|
||||
this.pollSnapshot.set(relativePath, fingerprint);
|
||||
}
|
||||
|
||||
for (const relativePath of this.pollSnapshot.keys()) {
|
||||
if (!nextSnapshot.has(relativePath)) {
|
||||
this.options.emitPolledChange('rename', relativePath);
|
||||
if (nextSnapshot.cycleComplete) {
|
||||
const completedSnapshot = this.partialPollSnapshot;
|
||||
if (nextSnapshot.deleteSafe !== false) {
|
||||
for (const relativePath of this.pollSnapshot.keys()) {
|
||||
if (!completedSnapshot.has(relativePath)) {
|
||||
this.options.emitPolledChange('rename', relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pollSnapshot = completedSnapshot;
|
||||
this.partialPollSnapshot = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
this.pollSnapshot = nextSnapshot;
|
||||
private mergePartialPollSnapshot(files: Map<string, string>): void {
|
||||
for (const [relativePath, fingerprint] of files) {
|
||||
this.partialPollSnapshot.set(relativePath, fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
private async closeWatcher(watcher: CloseableWatcher): Promise<void> {
|
||||
|
|
@ -318,3 +345,14 @@ export class CrossPlatformFileChangeSource {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePollSnapshot(snapshot: PollSnapshot): PollSnapshotResult {
|
||||
if (snapshot instanceof Map) {
|
||||
return {
|
||||
files: snapshot,
|
||||
cycleComplete: true,
|
||||
deleteSafe: true,
|
||||
};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@
|
|||
*/
|
||||
|
||||
import { type FileChangeEvent, type ParsedMessage } from '@main/types';
|
||||
import { parseJsonlFileWithStats, parseJsonlStream } from '@main/utils/jsonl';
|
||||
import {
|
||||
countJsonlFileWithStats,
|
||||
parseJsonlFileWithStats,
|
||||
parseJsonlStream,
|
||||
} from '@main/utils/jsonl';
|
||||
import {
|
||||
getProjectsBasePath,
|
||||
getTasksBasePath,
|
||||
|
|
@ -29,7 +33,10 @@ import { projectPathResolver } from '../discovery/ProjectPathResolver';
|
|||
import { errorDetector } from '../error/ErrorDetector';
|
||||
|
||||
import { ConfigManager } from './ConfigManager';
|
||||
import { CrossPlatformFileChangeSource } from './CrossPlatformFileChangeSource';
|
||||
import {
|
||||
CrossPlatformFileChangeSource,
|
||||
type PollSnapshotResult,
|
||||
} from './CrossPlatformFileChangeSource';
|
||||
import { type DataCache } from './DataCache';
|
||||
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
||||
import { type NotificationManager } from './NotificationManager';
|
||||
|
|
@ -48,6 +55,10 @@ const WATCHER_RETRY_MS = 2000;
|
|||
const TEAMS_POLL_INTERVAL_MS = 1000;
|
||||
/** Poll interval for task files, which can be much larger than team metadata/inboxes */
|
||||
const TASKS_POLL_INTERVAL_MS = 3000;
|
||||
/** Bound each projects polling slice so fallback/SSH mode cannot rescan huge histories every tick. */
|
||||
const PROJECTS_POLL_PROJECT_SLICE_BUDGET = 64;
|
||||
/** Soft cap: a single large project can exceed this, but broad trees are split across ticks. */
|
||||
const PROJECTS_POLL_FILE_SOFT_BUDGET = 1024;
|
||||
/** Interval for periodic catch-up scan to detect missed fs.watch events */
|
||||
const CATCH_UP_INTERVAL_MS = 30_000;
|
||||
/** Only catch-up scan files modified within this window */
|
||||
|
|
@ -102,6 +113,10 @@ export class FileWatcher extends EventEmitter {
|
|||
private catchUpCursor = 0;
|
||||
/** Consecutive catch-up stat timeouts per file. */
|
||||
private catchUpStatFailures = new Map<string, number>();
|
||||
/** Cursor for chunked project polling snapshots. */
|
||||
private projectsPollCursor = 0;
|
||||
/** Whether the current project polling cycle has already been split across ticks. */
|
||||
private projectsPollCycleChunked = false;
|
||||
/** Polling interval for projects fallback and SSH mode. */
|
||||
private static readonly SSH_POLL_INTERVAL_MS = 3000;
|
||||
/** Files currently being processed (concurrency guard) */
|
||||
|
|
@ -298,6 +313,8 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.projectsPollCursor = 0;
|
||||
this.projectsPollCycleChunked = false;
|
||||
this.processingInProgress.clear();
|
||||
this.pendingReprocess.clear();
|
||||
|
||||
|
|
@ -679,15 +696,30 @@ export class FileWatcher extends EventEmitter {
|
|||
this.changeSources.projects.startPolling();
|
||||
}
|
||||
|
||||
private async collectProjectsPollSnapshot(): Promise<Map<string, string>> {
|
||||
private async collectProjectsPollSnapshot(): Promise<PollSnapshotResult> {
|
||||
const snapshot = new Map<string, string>();
|
||||
const projectDirs = await this.readProviderSnapshotDir(this.projectsPath);
|
||||
const projectDirs = (await this.readProviderSnapshotDir(this.projectsPath))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (projectDirs.length === 0) {
|
||||
this.projectsPollCursor = 0;
|
||||
this.projectsPollCycleChunked = false;
|
||||
return { files: snapshot, cycleComplete: true, deleteSafe: true };
|
||||
}
|
||||
|
||||
if (this.projectsPollCursor >= projectDirs.length) {
|
||||
this.projectsPollCursor = 0;
|
||||
this.projectsPollCycleChunked = false;
|
||||
}
|
||||
|
||||
let index = this.projectsPollCursor;
|
||||
let visitedProjects = 0;
|
||||
let collectedFiles = 0;
|
||||
|
||||
while (visitedProjects < projectDirs.length) {
|
||||
const projectDir = projectDirs[index];
|
||||
const sizeBefore = snapshot.size;
|
||||
const projectPath = path.join(this.projectsPath, projectDir.name);
|
||||
const entries = await this.readProviderSnapshotDir(projectPath);
|
||||
for (const entry of entries) {
|
||||
|
|
@ -722,9 +754,31 @@ export class FileWatcher extends EventEmitter {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
collectedFiles += snapshot.size - sizeBefore;
|
||||
visitedProjects += 1;
|
||||
index = (index + 1) % projectDirs.length;
|
||||
|
||||
if (index === 0) {
|
||||
const deleteSafe = !this.projectsPollCycleChunked;
|
||||
this.projectsPollCursor = 0;
|
||||
this.projectsPollCycleChunked = false;
|
||||
return { files: snapshot, cycleComplete: true, deleteSafe };
|
||||
}
|
||||
|
||||
if (
|
||||
visitedProjects >= PROJECTS_POLL_PROJECT_SLICE_BUDGET ||
|
||||
collectedFiles >= PROJECTS_POLL_FILE_SOFT_BUDGET
|
||||
) {
|
||||
this.projectsPollCursor = index;
|
||||
this.projectsPollCycleChunked = true;
|
||||
return { files: snapshot, cycleComplete: false };
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
this.projectsPollCursor = 0;
|
||||
this.projectsPollCycleChunked = false;
|
||||
return { files: snapshot, cycleComplete: true, deleteSafe: true };
|
||||
}
|
||||
|
||||
private async collectTodosPollSnapshot(): Promise<Map<string, string>> {
|
||||
|
|
@ -928,6 +982,11 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
|
||||
const isFirstRead = lastLineCount === 0 && lastSize === 0;
|
||||
if (isFirstRead && fileStats.birthtimeMs < this.instanceCreatedAt) {
|
||||
await this.establishPreExistingFileBaseline(filePath, currentSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize;
|
||||
let newMessages: ParsedMessage[] = [];
|
||||
let currentLineCount: number;
|
||||
|
|
@ -952,22 +1011,6 @@ export class FileWatcher extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
// On first read (after app restart), establish baseline without detecting errors
|
||||
// for files that existed BEFORE this FileWatcher started. This prevents flooding
|
||||
// notifications with historical errors from old sessions.
|
||||
// Files created AFTER startup are new sessions — detect errors normally.
|
||||
if (isFirstRead) {
|
||||
const isPreExistingFile = fileStats.birthtimeMs < this.instanceCreatedAt;
|
||||
if (isPreExistingFile) {
|
||||
this.lastProcessedLineCount.set(filePath, currentLineCount);
|
||||
this.lastProcessedSize.set(filePath, processedSize);
|
||||
logger.info(
|
||||
`FileWatcher: Baseline established for ${filePath} (${currentLineCount} lines, ${processedSize} bytes)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect errors in new messages
|
||||
// Note: We pass the offset-adjusted line numbers to errorDetector
|
||||
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
|
||||
|
|
@ -1009,6 +1052,18 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private async establishPreExistingFileBaseline(
|
||||
filePath: string,
|
||||
currentSize: number
|
||||
): Promise<void> {
|
||||
const baseline = await countJsonlFileWithStats(filePath, this.fsProvider);
|
||||
this.lastProcessedLineCount.set(filePath, baseline.parsedLineCount);
|
||||
this.lastProcessedSize.set(filePath, baseline.consumedBytes);
|
||||
logger.info(
|
||||
`FileWatcher: Baseline established for ${filePath} (${baseline.parsedLineCount} lines, ${baseline.consumedBytes}/${currentSize} bytes)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the error detection tracking for a specific file.
|
||||
* Call this when a file is deleted or to force re-processing.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ import {
|
|||
budgetWorkspaceTrustDiagnosticsManifest,
|
||||
buildWorkspaceTrustPathCandidates,
|
||||
buildWorkspaceTrustPreflightEnv,
|
||||
resolveWorkspaceTrustCanonicalGitRoot,
|
||||
resolveWorkspaceTrustFeatureFlags,
|
||||
resolveWorkspaceTrustFilesystemGitRoot,
|
||||
type WorkspaceTrustArgsOnlyPlanRequest,
|
||||
type WorkspaceTrustArgsOnlyPlanResult,
|
||||
type WorkspaceTrustCoordinator,
|
||||
|
|
@ -3764,12 +3766,12 @@ export class TeamProvisioningService {
|
|||
return [...providers];
|
||||
}
|
||||
|
||||
private resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
|
||||
private async resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
|
||||
const normalizedCwd = cwd.trim();
|
||||
if (!normalizedCwd) {
|
||||
return Promise.resolve(null);
|
||||
return null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const gitRoot = await new Promise<string | null>((resolve) => {
|
||||
execFile(
|
||||
'git',
|
||||
['-C', normalizedCwd, 'rev-parse', '--show-toplevel'],
|
||||
|
|
@ -3789,6 +3791,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
);
|
||||
});
|
||||
return gitRoot ?? resolveWorkspaceTrustFilesystemGitRoot(normalizedCwd);
|
||||
}
|
||||
|
||||
private async collectWorkspaceTrustWorkspaces(input: {
|
||||
|
|
@ -3807,9 +3810,10 @@ export class TeamProvisioningService {
|
|||
let gitRoot = gitRootCache.get(cwd);
|
||||
if (gitRoot === undefined) {
|
||||
const resolvedGitRoot = await this.resolveWorkspaceTrustGitRoot(cwd);
|
||||
gitRoot = resolvedGitRoot
|
||||
const realGitRoot = resolvedGitRoot
|
||||
? await fs.promises.realpath(resolvedGitRoot).catch(() => resolvedGitRoot)
|
||||
: null;
|
||||
gitRoot = realGitRoot ? await resolveWorkspaceTrustCanonicalGitRoot(realGitRoot) : null;
|
||||
gitRootCache.set(cwd, gitRoot);
|
||||
}
|
||||
candidates.push(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ export interface JsonlParseResult {
|
|||
consumedBytes: number;
|
||||
}
|
||||
|
||||
interface JsonlStreamParseOptions {
|
||||
collectMessages?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSONL file line by line using streaming.
|
||||
* This avoids loading the entire file into memory.
|
||||
|
|
@ -88,14 +92,38 @@ export async function parseJsonlFileWithStats(
|
|||
return parseJsonlStream(fsProvider.createReadStream(filePath), filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count parseable JSONL messages and consumed bytes without retaining message
|
||||
* objects. Useful for first-read baselines where old transcript contents should
|
||||
* not be surfaced and can be too large to keep in memory.
|
||||
*/
|
||||
export async function countJsonlFileWithStats(
|
||||
filePath: string,
|
||||
fsProvider: FileSystemProvider = defaultProvider
|
||||
): Promise<Omit<JsonlParseResult, 'messages'>> {
|
||||
if (!(await fsProvider.exists(filePath))) {
|
||||
return { parsedLineCount: 0, consumedBytes: 0 };
|
||||
}
|
||||
|
||||
const result = await parseJsonlStream(fsProvider.createReadStream(filePath), filePath, {
|
||||
collectMessages: false,
|
||||
});
|
||||
return {
|
||||
parsedLineCount: result.parsedLineCount,
|
||||
consumedBytes: result.consumedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSONL data from a readable stream while tracking how many bytes were
|
||||
* safely consumed as complete lines.
|
||||
*/
|
||||
export async function parseJsonlStream(
|
||||
stream: Readable,
|
||||
filePath?: string
|
||||
filePath?: string,
|
||||
options: JsonlStreamParseOptions = {}
|
||||
): Promise<JsonlParseResult> {
|
||||
const collectMessages = options.collectMessages !== false;
|
||||
const messages: ParsedMessage[] = [];
|
||||
let pending = Buffer.alloc(0);
|
||||
let parsedLineCount = 0;
|
||||
|
|
@ -122,9 +150,13 @@ export async function parseJsonlStream(
|
|||
}
|
||||
|
||||
try {
|
||||
const parsed = parseJsonlLine(normalized);
|
||||
if (parsed) {
|
||||
messages.push(parsed);
|
||||
if (collectMessages) {
|
||||
const parsed = parseJsonlLine(normalized);
|
||||
if (parsed) {
|
||||
messages.push(parsed);
|
||||
parsedLineCount += 1;
|
||||
}
|
||||
} else if (isCountableJsonlEntryLine(normalized)) {
|
||||
parsedLineCount += 1;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -163,9 +195,14 @@ export async function parseJsonlStream(
|
|||
const trailingLine = pending.toString('utf8');
|
||||
const normalized = normalizeJsonlLine(trailingLine);
|
||||
if (looksLikeJsonObjectLine(normalized)) {
|
||||
const parsed = parseJsonlLine(normalized);
|
||||
if (parsed) {
|
||||
messages.push(parsed);
|
||||
if (collectMessages) {
|
||||
const parsed = parseJsonlLine(normalized);
|
||||
if (parsed) {
|
||||
messages.push(parsed);
|
||||
parsedLineCount += 1;
|
||||
consumedBytes += pending.length;
|
||||
}
|
||||
} else if (isCountableJsonlEntryLine(normalized)) {
|
||||
parsedLineCount += 1;
|
||||
consumedBytes += pending.length;
|
||||
}
|
||||
|
|
@ -223,6 +260,45 @@ function looksLikeJsonObjectLine(line: string): boolean {
|
|||
return line.startsWith('{');
|
||||
}
|
||||
|
||||
function isCountableJsonlEntryLine(line: string): boolean {
|
||||
const entry = JSON.parse(line) as Partial<ChatHistoryEntry> & {
|
||||
uuid?: unknown;
|
||||
type?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
const type = typeof entry.type === 'string' ? parseMessageType(entry.type) : null;
|
||||
if (typeof entry.uuid !== 'string' || entry.uuid.length === 0 || !type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'user') {
|
||||
if (entry.message == null) {
|
||||
return false;
|
||||
}
|
||||
const content = (entry.message as { content?: unknown }).content;
|
||||
return content == null || isParserSafeContent(content);
|
||||
}
|
||||
|
||||
if (type === 'assistant') {
|
||||
if (!isJsonObjectRecord(entry.message)) {
|
||||
return false;
|
||||
}
|
||||
const content = entry.message.content;
|
||||
return isParserSafeContent(content);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isJsonObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isParserSafeContent(value: unknown): boolean {
|
||||
return typeof value === 'string' || (Array.isArray(value) && value.every((item) => item != null));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Entry Parsing
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
src/main/utils/startupTelemetry.ts
Normal file
26
src/main/utils/startupTelemetry.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { AppStartupMemorySnapshot } from '@shared/types';
|
||||
|
||||
export type MemoryUsageReader = () => NodeJS.MemoryUsage;
|
||||
|
||||
export function captureStartupMemorySnapshot(
|
||||
readMemoryUsage: MemoryUsageReader = () => process.memoryUsage()
|
||||
): AppStartupMemorySnapshot {
|
||||
const memory = readMemoryUsage();
|
||||
return {
|
||||
rssBytes: memory.rss,
|
||||
heapUsedBytes: memory.heapUsed,
|
||||
heapTotalBytes: memory.heapTotal,
|
||||
externalBytes: memory.external,
|
||||
arrayBuffersBytes: memory.arrayBuffers,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatStartupMemorySnapshot(memory: AppStartupMemorySnapshot): string {
|
||||
return `rss=${formatMiB(memory.rssBytes)} heap=${formatMiB(memory.heapUsedBytes)}/${formatMiB(
|
||||
memory.heapTotalBytes
|
||||
)} external=${formatMiB(memory.externalBytes)}`;
|
||||
}
|
||||
|
||||
function formatMiB(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
|
||||
}
|
||||
|
|
@ -1036,14 +1036,14 @@ const InstalledBanner = ({
|
|||
>
|
||||
<div className="col-span-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderBrandLogo
|
||||
providerId={provider.providerId}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
className="truncate whitespace-nowrap text-xs font-medium"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{provider.providerId === 'opencode'
|
||||
|
|
@ -1053,7 +1053,7 @@ const InstalledBanner = ({
|
|||
{openCodeDashboardChips.map((chip) => (
|
||||
<span
|
||||
key={chip.label}
|
||||
className="rounded bg-[rgba(34,197,94,0.14)] px-1.5 py-px text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]"
|
||||
className="shrink-0 whitespace-nowrap rounded bg-[rgba(34,197,94,0.14)] px-1.5 py-px text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]"
|
||||
title={chip.title}
|
||||
>
|
||||
{chip.label}
|
||||
|
|
@ -1061,7 +1061,7 @@ const InstalledBanner = ({
|
|||
))}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
className="whitespace-nowrap text-xs"
|
||||
style={{
|
||||
color: getProviderStatusColor(statusText, provider.authenticated),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { LaunchTeamDialogLoadingFallback } from '../team/dialogs/LaunchTeamDialogLoadingFallback';
|
||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
||||
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
||||
|
|
@ -588,7 +589,16 @@ export const SchedulesView = (): React.JSX.Element => {
|
|||
|
||||
{/* Create/Edit Dialog */}
|
||||
{dialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LaunchTeamDialogLoadingFallback
|
||||
mode="schedule"
|
||||
teamName={editingSchedule?.teamName}
|
||||
isEditingSchedule={Boolean(editingSchedule)}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LaunchTeamDialog
|
||||
mode="schedule"
|
||||
open={dialogOpen}
|
||||
|
|
|
|||
|
|
@ -534,20 +534,21 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
<>
|
||||
<div className="col-span-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderBrandLogo
|
||||
providerId={provider.providerId}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="font-medium"
|
||||
className="truncate whitespace-nowrap font-medium"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="whitespace-nowrap"
|
||||
style={{
|
||||
color: getProviderStatusColor(
|
||||
statusText,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
|
||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||
import { LaunchTeamDialogLoadingFallback } from './dialogs/LaunchTeamDialogLoadingFallback';
|
||||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||
|
|
@ -2580,7 +2581,15 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
</div>
|
||||
</div>
|
||||
{launchDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LaunchTeamDialogLoadingFallback
|
||||
mode={launchDialogState.mode}
|
||||
teamName={teamName}
|
||||
onClose={closeLaunchDialog}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LaunchTeamDialog
|
||||
mode={launchDialogState.mode}
|
||||
open={launchDialogOpen}
|
||||
|
|
@ -3418,7 +3427,15 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
</Dialog>
|
||||
|
||||
{launchDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LaunchTeamDialogLoadingFallback
|
||||
mode={launchDialogState.mode}
|
||||
teamName={teamName}
|
||||
onClose={closeLaunchDialog}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LaunchTeamDialog
|
||||
mode={launchDialogState.mode}
|
||||
open={launchDialogOpen}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { LaunchTeamDialogLoadingFallback } from './dialogs/LaunchTeamDialogLoadingFallback';
|
||||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||
import { TeamEmptyState } from './TeamEmptyState';
|
||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||
|
|
@ -1094,7 +1095,15 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
|||
);
|
||||
|
||||
const launchDialogElement = launchDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LaunchTeamDialogLoadingFallback
|
||||
mode={launchDialogMode}
|
||||
teamName={launchDialogTeamName}
|
||||
onClose={() => setLaunchDialogOpen(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{launchDialogMode === 'relaunch' ? (
|
||||
<LaunchTeamDialog
|
||||
mode="relaunch"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||
|
||||
import { useAppTranslation } from '@features/localization/renderer';
|
||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isImageMime } from '@renderer/utils/attachmentUtils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export const AttachmentDisplay = ({
|
|||
attachments,
|
||||
}: AttachmentDisplayProps): React.JSX.Element | null => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const revealFileInEditor = useStore((s) => s.revealFileInEditor);
|
||||
const [state, setState] = useState<{
|
||||
loaded: AttachmentFileData[];
|
||||
loading: boolean;
|
||||
|
|
@ -74,10 +76,16 @@ export const AttachmentDisplay = ({
|
|||
return {
|
||||
meta,
|
||||
dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined,
|
||||
filePath: data.filePath ?? meta.filePath,
|
||||
isImage,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as { meta: AttachmentMeta; dataUrl: string | undefined; isImage: boolean }[];
|
||||
.filter(Boolean) as {
|
||||
meta: AttachmentMeta;
|
||||
dataUrl: string | undefined;
|
||||
filePath: string | undefined;
|
||||
isImage: boolean;
|
||||
}[];
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
|
|
@ -107,10 +115,29 @@ export const AttachmentDisplay = ({
|
|||
: undefined
|
||||
}
|
||||
/>
|
||||
) : item.filePath ? (
|
||||
<button
|
||||
key={item.meta.id}
|
||||
type="button"
|
||||
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface)]"
|
||||
title={item.meta.filename}
|
||||
aria-label={`Open ${item.meta.filename}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
revealFileInEditor(item.filePath!);
|
||||
}}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<FileIcon fileName={item.meta.filename} className="size-5" />
|
||||
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
|
||||
{item.meta.filename}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={item.meta.id}
|
||||
className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)]"
|
||||
title={item.meta.filename}
|
||||
>
|
||||
<FileIcon fileName={item.meta.filename} className="size-5" />
|
||||
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useAppTranslation } from '@features/localization/renderer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type LaunchTeamDialogLoadingMode = 'launch' | 'relaunch' | 'schedule';
|
||||
|
||||
interface LaunchTeamDialogLoadingFallbackProps {
|
||||
readonly mode: LaunchTeamDialogLoadingMode;
|
||||
readonly teamName?: string;
|
||||
readonly isEditingSchedule?: boolean;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export const LaunchTeamDialogLoadingFallback = ({
|
||||
mode,
|
||||
teamName,
|
||||
isEditingSchedule = false,
|
||||
onClose,
|
||||
}: LaunchTeamDialogLoadingFallbackProps): React.JSX.Element => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const { t: tCommon } = useAppTranslation('common');
|
||||
|
||||
const title =
|
||||
mode === 'schedule'
|
||||
? isEditingSchedule
|
||||
? t('launch.title.editSchedule')
|
||||
: t('launch.title.createSchedule')
|
||||
: mode === 'relaunch'
|
||||
? t('launch.title.relaunch')
|
||||
: t('launch.title.launch');
|
||||
|
||||
const description =
|
||||
mode === 'schedule'
|
||||
? isEditingSchedule && teamName
|
||||
? t('launch.description.editSchedule', { team: teamName })
|
||||
: teamName
|
||||
? t('launch.description.createScheduleForTeam', { team: teamName })
|
||||
: t('launch.description.createSchedule')
|
||||
: mode === 'relaunch'
|
||||
? t('launch.description.relaunchPrefix')
|
||||
: t('launch.description.launchPrefix');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{title}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{mode === 'schedule' ? (
|
||||
description
|
||||
) : (
|
||||
<>
|
||||
{description} <span className="font-mono font-medium">{teamName}</span>{' '}
|
||||
{mode === 'relaunch'
|
||||
? t('launch.description.relaunchSuffix')
|
||||
: t('launch.description.launchSuffix')}
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] px-3 py-2 text-xs text-[var(--color-text-muted)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
<span>{tCommon('states.loading')}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { LaunchTeamDialogLoadingFallback } from '../dialogs/LaunchTeamDialogLoadingFallback';
|
||||
|
||||
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
||||
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
||||
import { ScheduleRunRow } from './ScheduleRunRow';
|
||||
|
|
@ -311,7 +313,16 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
|
|||
|
||||
{/* Create/Edit Dialog */}
|
||||
{dialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LaunchTeamDialogLoadingFallback
|
||||
mode="schedule"
|
||||
teamName={teamName}
|
||||
isEditingSchedule={Boolean(editingSchedule)}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LaunchTeamDialog
|
||||
mode="schedule"
|
||||
open={dialogOpen}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
|
|||
const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
|
||||
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
|
||||
const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_000;
|
||||
const STARTUP_GLOBAL_TASKS_MIN_DELAY_MS = 5_000;
|
||||
const STARTUP_GLOBAL_TASKS_MAX_DELAY_MS = 30_000;
|
||||
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
||||
|
|
@ -216,6 +218,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let deferredProviderStatusCleanup: (() => void) | null = null;
|
||||
let deferredGlobalTasksCleanup: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
useStore.getState().subscribeProvisioningProgress();
|
||||
cleanupFns.push(() => {
|
||||
useStore.getState().unsubscribeProvisioningProgress();
|
||||
|
|
@ -286,18 +290,33 @@ export function initializeNotificationListeners(): () => void {
|
|||
runtimeStatusTimer = null;
|
||||
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS);
|
||||
|
||||
// Remaining visible startup fetches have no data dependency on each other.
|
||||
// Keep immediately visible startup data first; global task aggregation can
|
||||
// scan all team task files, so hydrate it after first paint/idle.
|
||||
await Promise.all([
|
||||
useStore.getState().fetchAllTasks(),
|
||||
useStore.getState().fetchTeams(),
|
||||
useStore.getState().fetchNotifications(),
|
||||
useStore.getState().fetchSchedules(),
|
||||
]);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
deferredGlobalTasksCleanup = scheduleStartupIdleTask(
|
||||
() => {
|
||||
deferredGlobalTasksCleanup = null;
|
||||
void useStore.getState().fetchAllTasks();
|
||||
},
|
||||
{
|
||||
minDelayMs: STARTUP_GLOBAL_TASKS_MIN_DELAY_MS,
|
||||
maxDelayMs: STARTUP_GLOBAL_TASKS_MAX_DELAY_MS,
|
||||
}
|
||||
);
|
||||
})();
|
||||
cleanupFns.push(() => {
|
||||
disposed = true;
|
||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
|
||||
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
|
||||
if (deferredGlobalTasksCleanup) deferredGlobalTasksCleanup();
|
||||
});
|
||||
// TODO(task-change-presence): re-enable this only after the board uses a bounded
|
||||
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
resolveWorkspaceTrustCanonicalGitRoot,
|
||||
resolveWorkspaceTrustFilesystemGitRoot,
|
||||
} from '@features/workspace-trust/main';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
let tmpDir: string | null = null;
|
||||
|
||||
async function makeTmpDir(): Promise<string> {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'workspace-trust-git-root-'));
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
async function createSyntheticWorktree(input: {
|
||||
repoDir: string;
|
||||
worktreeDir: string;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const worktreeGitDir = path.join(input.repoDir, '.git', 'worktrees', input.name);
|
||||
await fs.mkdir(input.worktreeDir, { recursive: true });
|
||||
await fs.mkdir(worktreeGitDir, { recursive: true });
|
||||
await fs.writeFile(path.join(input.worktreeDir, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf8');
|
||||
await fs.writeFile(path.join(worktreeGitDir, 'commondir'), '../..\n', 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(worktreeGitDir, 'gitdir'),
|
||||
`${path.join(input.worktreeDir, '.git')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('resolveWorkspaceTrustCanonicalGitRoot', () => {
|
||||
it('finds a git root from nested paths without spawning git', async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const repoDir = path.join(dir, 'repo');
|
||||
const nestedDir = path.join(repoDir, 'packages', 'app');
|
||||
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
|
||||
await fs.mkdir(nestedDir, { recursive: true });
|
||||
|
||||
await expect(resolveWorkspaceTrustFilesystemGitRoot(nestedDir)).resolves.toBe(repoDir);
|
||||
});
|
||||
|
||||
it('does not infer a git root from a missing path', async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const repoDir = path.join(dir, 'repo');
|
||||
const missingDir = path.join(repoDir, 'packages', 'missing');
|
||||
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
|
||||
|
||||
await expect(resolveWorkspaceTrustFilesystemGitRoot(missingDir)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a valid git worktree to the canonical repository root', async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const repoDir = path.join(dir, 'repo');
|
||||
const worktreeDir = path.join(dir, 'worktrees', 'alice');
|
||||
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
|
||||
await createSyntheticWorktree({ repoDir, worktreeDir, name: 'alice' });
|
||||
|
||||
await expect(resolveWorkspaceTrustCanonicalGitRoot(worktreeDir)).resolves.toBe(repoDir);
|
||||
});
|
||||
|
||||
it('does not accept a forged gitdir pointer to another repository', async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const trustedRepoDir = path.join(dir, 'trusted-repo');
|
||||
const forgedDir = path.join(dir, 'forged');
|
||||
await fs.mkdir(path.join(trustedRepoDir, '.git'), { recursive: true });
|
||||
await fs.mkdir(forgedDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(forgedDir, '.git'),
|
||||
`gitdir: ${path.join(trustedRepoDir, '.git')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(resolveWorkspaceTrustCanonicalGitRoot(forgedDir)).resolves.toBe(forgedDir);
|
||||
});
|
||||
|
||||
it('does not accept borrowed worktree metadata without a backlink', async () => {
|
||||
const dir = await makeTmpDir();
|
||||
const trustedRepoDir = path.join(dir, 'trusted-repo');
|
||||
const forgedDir = path.join(dir, 'forged');
|
||||
const borrowedWorktreeGitDir = path.join(trustedRepoDir, '.git', 'worktrees', 'alice');
|
||||
await fs.mkdir(path.join(trustedRepoDir, '.git'), { recursive: true });
|
||||
await fs.mkdir(forgedDir, { recursive: true });
|
||||
await fs.mkdir(borrowedWorktreeGitDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(forgedDir, '.git'),
|
||||
`gitdir: ${borrowedWorktreeGitDir}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(path.join(borrowedWorktreeGitDir, 'commondir'), '../..\n', 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(borrowedWorktreeGitDir, 'gitdir'),
|
||||
`${path.join(trustedRepoDir, '.git')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(resolveWorkspaceTrustCanonicalGitRoot(forgedDir)).resolves.toBe(forgedDir);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -595,4 +595,126 @@ describe('CrossPlatformFileChangeSource', () => {
|
|||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('builds a silent startup baseline across incomplete polling cycles', async () => {
|
||||
let active = true;
|
||||
const emitted: Array<[string, string]> = [];
|
||||
const collectPollSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
files: new Map([['a.jsonl', '1']]),
|
||||
cycleComplete: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
files: new Map([['b.jsonl', '1']]),
|
||||
cycleComplete: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
files: new Map([
|
||||
['a.jsonl', '2'],
|
||||
['b.jsonl', '1'],
|
||||
]),
|
||||
cycleComplete: true,
|
||||
});
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
collectPollSnapshot,
|
||||
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
|
||||
await source.pollOnce();
|
||||
expect(source.isPollingPrimed).toBe(false);
|
||||
await source.pollOnce();
|
||||
expect(source.isPollingPrimed).toBe(true);
|
||||
expect(emitted).toEqual([]);
|
||||
|
||||
await source.pollOnce();
|
||||
|
||||
expect(emitted).toEqual([['change', 'a.jsonl']]);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('does not emit deletes from incomplete polling snapshots', async () => {
|
||||
let active = true;
|
||||
const emitted: Array<[string, string]> = [];
|
||||
const collectPollSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Map([
|
||||
['a.jsonl', '1'],
|
||||
['b.jsonl', '1'],
|
||||
])
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
files: new Map([['a.jsonl', '1']]),
|
||||
cycleComplete: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
files: new Map<string, string>(),
|
||||
cycleComplete: true,
|
||||
});
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
collectPollSnapshot,
|
||||
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
|
||||
await source.pollOnce();
|
||||
await source.pollOnce();
|
||||
expect(emitted).toEqual([]);
|
||||
|
||||
await source.pollOnce();
|
||||
|
||||
expect(emitted).toEqual([['rename', 'b.jsonl']]);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('suppresses deletes when a completed polling cycle is not delete-safe', async () => {
|
||||
let active = true;
|
||||
const emitted: Array<[string, string]> = [];
|
||||
const collectPollSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Map([
|
||||
['a.jsonl', '1'],
|
||||
['b.jsonl', '1'],
|
||||
])
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
files: new Map([['a.jsonl', '1']]),
|
||||
cycleComplete: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
files: new Map<string, string>(),
|
||||
cycleComplete: true,
|
||||
deleteSafe: false,
|
||||
});
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
collectPollSnapshot,
|
||||
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
|
||||
await source.pollOnce();
|
||||
await source.pollOnce();
|
||||
await source.pollOnce();
|
||||
|
||||
expect(emitted).toEqual([]);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -641,6 +641,70 @@ describe('FileWatcher', () => {
|
|||
watcher.stop();
|
||||
});
|
||||
|
||||
it('chunks broad project polling baselines and still emits changes after priming', async () => {
|
||||
const projectsDir = '/virtual/projects';
|
||||
const todosDir = '/virtual/todos';
|
||||
const projectNames = Array.from({ length: 65 }, (_, index) =>
|
||||
`encoded-project-${String(index).padStart(3, '0')}`
|
||||
);
|
||||
const fileState = new Map(projectNames.map((name) => [name, { size: 10, mtimeMs: 1000 }]));
|
||||
const fsProvider = {
|
||||
type: 'local' as const,
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 10,
|
||||
mtimeMs: 1000,
|
||||
birthtimeMs: 1000,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
}),
|
||||
readdir: vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === projectsDir) {
|
||||
return projectNames.map((name) => createFsDirent(name, 'directory'));
|
||||
}
|
||||
const projectName = path.basename(dirPath);
|
||||
const state = fileState.get(projectName);
|
||||
if (state) {
|
||||
return [createFsDirent('session-1.jsonl', 'file', state)];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
|
||||
setWatcherActive(watcher);
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
|
||||
await projectsSource.pollOnce();
|
||||
expect(projectsSource.isPollingPrimed).toBe(false);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
await projectsSource.pollOnce();
|
||||
expect(projectsSource.isPollingPrimed).toBe(true);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
fileState.set(projectNames[0], { size: 12, mtimeMs: 2000 });
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'change',
|
||||
path: path.join(projectsDir, projectNames[0], 'session-1.jsonl'),
|
||||
projectId: projectNames[0],
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('treats SSH not-found subagent directories as empty during project polling', async () => {
|
||||
const projectsDir = '/remote/projects';
|
||||
const todosDir = '/remote/todos';
|
||||
|
|
@ -2542,6 +2606,56 @@ describe('FileWatcher', () => {
|
|||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('preserves line offset for oversized pre-existing files without notifications', async () => {
|
||||
vi.useRealTimers();
|
||||
useRealExistsSync();
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-large-baseline-'));
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const projectDir = path.join(projectsDir, 'test-project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, 'session-large.jsonl');
|
||||
const largeLineCount = 17_000;
|
||||
const largePayload = 'old data '.repeat(120);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
Array.from({ length: largeLineCount }, (_, index) =>
|
||||
jsonlLine(`large-${index}`, largePayload)
|
||||
).join(''),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const notificationManager = createMockNotificationManager();
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
|
||||
watcher.setNotificationManager(notificationManager);
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
detectErrorsInSessionFile: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
lastProcessedLineCount: Map<string, number>;
|
||||
lastProcessedSize: Map<string, number>;
|
||||
instanceCreatedAt: number;
|
||||
};
|
||||
watcherAny.instanceCreatedAt = Date.now() + 60_000;
|
||||
|
||||
vi.mocked(errorDetector.detectErrors).mockClear();
|
||||
|
||||
await watcherAny.detectErrorsInSessionFile('test-project', 'session-large', filePath);
|
||||
|
||||
expect(errorDetector.detectErrors).not.toHaveBeenCalled();
|
||||
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(largeLineCount);
|
||||
expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size);
|
||||
expect(notificationManager.addError).not.toHaveBeenCalled();
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('detects errors immediately for files created after watcher startup', async () => {
|
||||
vi.useRealTimers();
|
||||
useRealExistsSync();
|
||||
|
|
|
|||
|
|
@ -161,6 +161,47 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('launches isolated worktrees with the member worktree as the OpenCode project path', async () => {
|
||||
const worktreePath = '/tmp/generated-worktrees/alice';
|
||||
const launchOpenCodeTeam = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
||||
>(async () => successfulOpenCodeLaunchData());
|
||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-worktree')),
|
||||
launchOpenCodeTeam,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||
|
||||
const result = await adapter.launch(
|
||||
launchInput({
|
||||
cwd: worktreePath,
|
||||
expectedMembers: [
|
||||
{
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
cwd: worktreePath,
|
||||
isolation: 'worktree',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.teamLaunchState).toBe('clean_success');
|
||||
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
|
||||
projectPath: worktreePath,
|
||||
selectedModel: 'openai/gpt-5.4-mini',
|
||||
requireExecutionProbe: true,
|
||||
});
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectPath: worktreePath,
|
||||
expectedCapabilitySnapshotId: 'cap-worktree',
|
||||
members: [expect.objectContaining({ name: 'alice' })],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
|
||||
const firstReadiness = readiness({
|
||||
state: 'mcp_unavailable',
|
||||
|
|
|
|||
|
|
@ -10117,7 +10117,10 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
message: { content: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(payload.message.content).toMatchObject([
|
||||
{ type: 'text', text: 'review the attached files' },
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' },
|
||||
},
|
||||
{
|
||||
type: 'document',
|
||||
source: { type: 'text', media_type: 'text/plain', data: 'line one\nline two' },
|
||||
|
|
@ -10128,15 +10131,48 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0xLjQ=' },
|
||||
title: 'brief.pdf',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' },
|
||||
},
|
||||
{ type: 'text', text: 'review the attached files' },
|
||||
]);
|
||||
expect(svc.isTeamAlive(firstTeamName)).toBe(true);
|
||||
expect(svc.isTeamAlive(secondTeamName)).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes Claude GIF and WebP attachments without marking the team offline', async () => {
|
||||
const teamName = 'pure-anthropic-extended-image-mimes-safe-e2e';
|
||||
await writePureAnthropicTeamConfig({ teamName, projectPath });
|
||||
await writePureAnthropicTeamMeta(teamName, projectPath);
|
||||
await writePureAnthropicMembersMeta(teamName);
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createPureAnthropicLiveRun({ teamName, projectPath });
|
||||
const writes: string[] = [];
|
||||
run.child = { stdin: createWritableStdin(writes) };
|
||||
trackLiveRun(svc, run);
|
||||
|
||||
await svc.sendMessageToTeam(teamName, 'review these browser images', [
|
||||
{
|
||||
filename: 'clip.gif',
|
||||
mimeType: 'image/gif',
|
||||
data: 'R0lGODlhAQABAAAAACw=',
|
||||
},
|
||||
{
|
||||
filename: 'clip.webp',
|
||||
mimeType: 'image/webp',
|
||||
data: 'UklGRiIAAABXRUJQ',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
const payload = JSON.parse(writes[0].trim()) as {
|
||||
message: { content: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(payload.message.content).toMatchObject([
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/gif' } },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/webp' } },
|
||||
{ type: 'text', text: 'review these browser images' },
|
||||
]);
|
||||
expect(svc.isTeamAlive(teamName)).toBe(true);
|
||||
});
|
||||
|
||||
it('routes messages to the current pure Anthropic run after same-team relaunch', async () => {
|
||||
const teamName = 'pure-anthropic-message-current-run-safe-e2e';
|
||||
await writePureAnthropicTeamConfig({ teamName, projectPath });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { buildWorkspaceTrustPathCandidates } from '@features/workspace-trust/main';
|
||||
import {
|
||||
buildWorkspaceTrustPathCandidates,
|
||||
type WorkspaceTrustWorkspace,
|
||||
} from '@features/workspace-trust/main';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
|
|
@ -23095,6 +23098,42 @@ describe('TeamProvisioningService', () => {
|
|||
).toEqual(['claude', 'codex', 'gemini', 'opencode']);
|
||||
});
|
||||
|
||||
it('uses the canonical repository root for workspace trust git worktree candidates', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const harness = svc as unknown as {
|
||||
collectWorkspaceTrustWorkspaces(input: {
|
||||
cwd: string;
|
||||
members: Array<{ name: string; cwd: string; isolation: 'worktree' }>;
|
||||
}): Promise<WorkspaceTrustWorkspace[]>;
|
||||
};
|
||||
const tempRoot = fs.realpathSync(tempClaudeRoot);
|
||||
const repoDir = path.join(tempRoot, 'repo');
|
||||
const worktreeDir = path.join(tempRoot, 'worktrees', 'alice');
|
||||
const worktreeGitDir = path.join(repoDir, '.git', 'worktrees', 'alice');
|
||||
fs.mkdirSync(worktreeDir, { recursive: true });
|
||||
fs.mkdirSync(worktreeGitDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreeDir, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf8');
|
||||
fs.writeFileSync(path.join(worktreeGitDir, 'commondir'), '../..\n', 'utf8');
|
||||
fs.writeFileSync(path.join(worktreeGitDir, 'gitdir'), `${path.join(worktreeDir, '.git')}\n`, 'utf8');
|
||||
|
||||
const workspaces = await harness.collectWorkspaceTrustWorkspaces({
|
||||
cwd: repoDir,
|
||||
members: [{ name: 'alice', cwd: worktreeDir, isolation: 'worktree' }],
|
||||
});
|
||||
|
||||
const memberWorktrees = workspaces.filter(
|
||||
(workspace) => workspace.source === 'member-worktree'
|
||||
);
|
||||
expect(memberWorktrees[0]).toMatchObject({
|
||||
cwd: worktreeDir,
|
||||
gitRootConfigKey: repoDir,
|
||||
memberId: 'alice',
|
||||
});
|
||||
expect(memberWorktrees.every((workspace) => workspace.gitRootConfigKey === repoDir)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('degrades workspace trust planning failures without blocking launch preparation', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const workspaces = buildWorkspaceTrustPathCandidates({
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
38
test/main/utils/startupTelemetry.test.ts
Normal file
38
test/main/utils/startupTelemetry.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
captureStartupMemorySnapshot,
|
||||
formatStartupMemorySnapshot,
|
||||
} from '../../../src/main/utils/startupTelemetry';
|
||||
|
||||
describe('startupTelemetry', () => {
|
||||
it('captures only stable numeric memory fields', () => {
|
||||
const snapshot = captureStartupMemorySnapshot(() => ({
|
||||
rss: 128 * 1024 * 1024,
|
||||
heapTotal: 64 * 1024 * 1024,
|
||||
heapUsed: 32 * 1024 * 1024,
|
||||
external: 8 * 1024 * 1024,
|
||||
arrayBuffers: 4 * 1024 * 1024,
|
||||
}));
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
rssBytes: 134217728,
|
||||
heapUsedBytes: 33554432,
|
||||
heapTotalBytes: 67108864,
|
||||
externalBytes: 8388608,
|
||||
arrayBuffersBytes: 4194304,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats rss and heap values for startup logs', () => {
|
||||
expect(
|
||||
formatStartupMemorySnapshot({
|
||||
rssBytes: 128 * 1024 * 1024,
|
||||
heapUsedBytes: 32 * 1024 * 1024,
|
||||
heapTotalBytes: 64 * 1024 * 1024,
|
||||
externalBytes: 8 * 1024 * 1024,
|
||||
arrayBuffersBytes: 4 * 1024 * 1024,
|
||||
})
|
||||
).toBe('rss=128.0MiB heap=32.0MiB/64.0MiB external=8.0MiB');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const storeMocks = vi.hoisted(() => ({
|
||||
revealFileInEditor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@features/localization/renderer', () => ({
|
||||
useAppTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/editor/FileIcon', () => ({
|
||||
FileIcon: ({ fileName }: { fileName: string }) => React.createElement('span', null, fileName),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: { revealFileInEditor: (filePath: string) => void }) => unknown) =>
|
||||
selector({ revealFileInEditor: storeMocks.revealFileInEditor }),
|
||||
}));
|
||||
|
||||
describe('AttachmentDisplay', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
storeMocks.revealFileInEditor.mockReset();
|
||||
});
|
||||
|
||||
it('opens persisted non-image attachments in the built-in editor', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const getAttachments = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'att-1',
|
||||
data: Buffer.from('verification').toString('base64'),
|
||||
mimeType: 'text/markdown',
|
||||
filePath: '/app/data/attachments/team-a/msg-1/att-1--verification.md',
|
||||
},
|
||||
]);
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: { teams: { getAttachments } },
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AttachmentDisplay
|
||||
teamName="team-a"
|
||||
messageId="msg-1"
|
||||
attachments={[
|
||||
{
|
||||
id: 'att-1',
|
||||
filename: 'verification.md',
|
||||
mimeType: 'text/markdown',
|
||||
size: 12,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Open verification.md"]');
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(storeMocks.revealFileInEditor).toHaveBeenCalledWith(
|
||||
'/app/data/attachments/team-a/msg-1/att-1--verification.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue