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.
|
- 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`.
|
- 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
|
### 4. Required release closeout gate
|
||||||
|
|
||||||
Do not publish or call a release finished until this is true:
|
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 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 `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`.
|
- 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.
|
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",
|
"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",
|
"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",
|
"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",
|
"stage-runtime": "node ./scripts/stage-runtime.mjs",
|
||||||
"clean:runtime": "node ./scripts/stage-runtime.mjs --clean",
|
"clean:runtime": "node ./scripts/stage-runtime.mjs --clean",
|
||||||
"pack:mac": "node ./scripts/electron-builder/dist.mjs --mac",
|
"pack:mac": "node ./scripts/electron-builder/dist.mjs --mac",
|
||||||
|
|
@ -439,7 +440,13 @@
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@radix-ui/react-presence@1.1.5": "patches/@radix-ui__react-presence@1.1.5.patch",
|
"@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": {
|
"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
|
yaml: 2.9.0
|
||||||
|
|
||||||
patchedDependencies:
|
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':
|
'@radix-ui/react-focus-scope@1.1.7':
|
||||||
hash: cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829
|
hash: cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829
|
||||||
path: patches/@radix-ui__react-focus-scope@1.1.7.patch
|
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':
|
'@radix-ui/react-presence@1.1.5':
|
||||||
hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e
|
hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e
|
||||||
path: patches/@radix-ui__react-presence@1.1.5.patch
|
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:
|
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)
|
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':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
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':
|
'@radix-ui/react-collapsible':
|
||||||
specifier: ^1.1.12
|
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)
|
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)
|
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':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.2.6
|
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':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/react@19.2.14)(react@19.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)
|
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':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.8
|
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':
|
'@sentry/electron':
|
||||||
specifier: ^7.10.0
|
specifier: ^7.10.0
|
||||||
version: 7.10.0
|
version: 7.10.0
|
||||||
|
|
@ -14465,7 +14483,7 @@ snapshots:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@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-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||||
|
|
@ -14519,7 +14537,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@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-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-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-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)
|
'@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/primitive': 1.1.3
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(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-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-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-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-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||||
|
|
@ -14563,7 +14581,7 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@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-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-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-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-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-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)
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
@ -14613,8 +14631,8 @@ snapshots:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@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-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-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-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-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-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)
|
'@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': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@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-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-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-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-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-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-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-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-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-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)
|
'@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/primitive': 1.1.3
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(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-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-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-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-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-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-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)
|
'@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': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@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:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
'@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)
|
'@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': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@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:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.1
|
'@radix-ui/number': 1.1.1
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@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-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-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-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-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-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-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-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-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)
|
'@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': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@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-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-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-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-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-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)
|
'@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 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 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 = [];
|
const missing = [];
|
||||||
|
|
||||||
for (const relativePath of filesToCheck) {
|
for (const check of patchChecks) {
|
||||||
const filePath = join(packageRoot, relativePath);
|
const packageRoot = resolvePackageRoot(check);
|
||||||
const source = readFileSync(filePath, 'utf8');
|
|
||||||
const missingMarkers = requiredMarkers.filter((marker) => !source.includes(marker));
|
for (const relativePath of filesToCheck) {
|
||||||
if (missingMarkers.length > 0) {
|
const filePath = join(packageRoot, relativePath);
|
||||||
missing.push(`${relativePath}: ${missingMarkers.join(', ')}`);
|
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) {
|
if (missing.length > 0) {
|
||||||
console.error(
|
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.',
|
'Run `pnpm install --force` before building production artifacts.',
|
||||||
'',
|
'',
|
||||||
...missing,
|
...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
|
#!/usr/bin/env node
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs');
|
const { buildElectronBuilderInvocations } = require('./dist-invocations.cjs');
|
||||||
|
|
||||||
export { buildElectronBuilderInvocations };
|
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) {
|
async function runElectronBuilder(args) {
|
||||||
const cliPath = require.resolve('electron-builder/cli.js');
|
const cliPath = require.resolve('electron-builder/cli.js');
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
|
|
@ -41,6 +60,8 @@ async function main(argv) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await runRendererBundleGuard();
|
||||||
|
|
||||||
for (const invocation of invocations) {
|
for (const invocation of invocations) {
|
||||||
await runElectronBuilder(invocation.args);
|
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_PER_PROVIDER = 4 * 1024 * 1024;
|
||||||
const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024;
|
const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024;
|
||||||
const DEFAULT_FILE_BYTES_PER_PROVIDER = 4 * 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 {
|
return {
|
||||||
supportsImages: true,
|
supportsImages: true,
|
||||||
supportsFiles: false,
|
supportsFiles: false,
|
||||||
supportedImageMimeTypes: ['image/png', 'image/jpeg'],
|
supportedImageMimeTypes: [...supportedImageMimeTypes],
|
||||||
supportedFileMimeTypes: [],
|
supportedFileMimeTypes: [],
|
||||||
maxImages: 5,
|
maxImages: 5,
|
||||||
maxFiles: 0,
|
maxFiles: 0,
|
||||||
|
|
@ -24,7 +39,7 @@ function supportedImagesOnly(displayText: string): AgentAttachmentCapability {
|
||||||
|
|
||||||
function supportedClaude(displayText: string): AgentAttachmentCapability {
|
function supportedClaude(displayText: string): AgentAttachmentCapability {
|
||||||
return {
|
return {
|
||||||
...supportedImagesOnly(displayText),
|
...supportedImagesOnly(displayText, CLAUDE_IMAGE_MIME_TYPES),
|
||||||
supportsFiles: true,
|
supportsFiles: true,
|
||||||
supportedFileMimeTypes: ['application/pdf', 'text/*'],
|
supportedFileMimeTypes: ['application/pdf', 'text/*'],
|
||||||
maxFiles: 5,
|
maxFiles: 5,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ export const AGENT_ATTACHMENT_SCHEMA_VERSION = 1 as const;
|
||||||
|
|
||||||
export type AgentAttachmentKind = 'image' | 'file' | 'unsupported';
|
export type AgentAttachmentKind = 'image' | 'file' | 'unsupported';
|
||||||
|
|
||||||
export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
|
export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||||
export type ProviderImageMimeType = 'image/png' | 'image/jpeg';
|
export type ProviderImageMimeType = AgentImageMimeType;
|
||||||
export type ProviderFileMimeType = 'application/pdf' | 'text/*';
|
export type ProviderFileMimeType = 'application/pdf' | 'text/*';
|
||||||
|
|
||||||
export type AttachmentDeliveryFailureCode =
|
export type AttachmentDeliveryFailureCode =
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,44 @@ describe('agent attachment validation', () => {
|
||||||
expect(result).toEqual({ ok: true, warnings: [] });
|
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', () => {
|
it('blocks non-image files for Codex native delivery', () => {
|
||||||
const capability = resolveAgentAttachmentCapability({
|
const capability = resolveAgentAttachmentCapability({
|
||||||
providerId: 'codex',
|
providerId: 'codex',
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,22 @@ import type {
|
||||||
const AGENT_IMAGE_MIME_TYPES = new Set<AgentImageMimeType>([
|
const AGENT_IMAGE_MIME_TYPES = new Set<AgentImageMimeType>([
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
'image/webp',
|
'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 {
|
export function isAgentImageMimeType(mimeType: string): mimeType is AgentImageMimeType {
|
||||||
return AGENT_IMAGE_MIME_TYPES.has(mimeType as 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);
|
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 {
|
function isProviderFileMimeType(mimeType: string, supported: readonly string[]): boolean {
|
||||||
return supported.some((candidate) =>
|
return supported.some((candidate) =>
|
||||||
candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType
|
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 {
|
export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind {
|
||||||
if (isAgentImageMimeType(mimeType)) return 'image';
|
if (isAgentImageMimeType(mimeType)) return 'image';
|
||||||
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
|
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
|
||||||
|
|
@ -48,11 +75,11 @@ export function validateImageOptimizationInput(input: {
|
||||||
budget?: ImageOptimizationBudget;
|
budget?: ImageOptimizationBudget;
|
||||||
}): AttachmentValidationResult {
|
}): AttachmentValidationResult {
|
||||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||||
if (!isAgentImageMimeType(input.mimeType)) {
|
if (!isOptimizableAgentImageMimeType(input.mimeType)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
code: 'attachment_type_unsupported',
|
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: [],
|
warnings: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +166,7 @@ export function validateAttachmentForCapability(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isProviderImageMimeType(attachment.mimeType)) {
|
if (!isCapabilityImageMimeType(attachment.mimeType, capability.supportedImageMimeTypes)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
code: 'attachment_type_unsupported',
|
code: 'attachment_type_unsupported',
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,30 @@ describe('Claude attachment adapter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.kind).toBe('structured_blocks');
|
expect(result.kind).toBe('structured_blocks');
|
||||||
expect(result.blocks[0]).toEqual({ type: 'text', text: 'What color?' });
|
expect(result.blocks[0]).toMatchObject({
|
||||||
expect(result.blocks[1]).toMatchObject({
|
|
||||||
type: 'image',
|
type: 'image',
|
||||||
source: { type: 'base64', media_type: 'image/png' },
|
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', () => {
|
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',
|
type: 'document',
|
||||||
source: { type: 'text', media_type: 'text/plain', data: 'hello' },
|
source: { type: 'text', media_type: 'text/plain', data: 'hello' },
|
||||||
title: 'note.txt',
|
title: 'note.txt',
|
||||||
});
|
});
|
||||||
|
expect(result.blocks[1]).toEqual({ type: 'text', text: 'Read this' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes text subtypes as text document blocks', () => {
|
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',
|
type: 'document',
|
||||||
source: { type: 'text', media_type: 'text/plain', data: '# hello' },
|
source: { type: 'text', media_type: 'text/plain', data: '# hello' },
|
||||||
title: 'notes.md',
|
title: 'notes.md',
|
||||||
});
|
});
|
||||||
|
expect(result.blocks[1]).toEqual({ type: 'text', text: 'Read this' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unsupported non-image files before provider send', () => {
|
it('rejects unsupported non-image files before provider send', () => {
|
||||||
|
|
@ -85,11 +106,11 @@ describe('Claude attachment adapter', () => {
|
||||||
).toThrow(/Claude attachment MIME unsupported/);
|
).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(() =>
|
expect(() =>
|
||||||
buildClaudeAttachmentDeliveryParts({
|
buildClaudeAttachmentDeliveryParts({
|
||||||
text: 'see gif',
|
text: 'see avif',
|
||||||
attachments: [attachment({ mimeType: 'image/gif' })],
|
attachments: [attachment({ mimeType: 'image/avif' })],
|
||||||
})
|
})
|
||||||
).toThrow(/Claude attachment MIME unsupported/);
|
).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';
|
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 };
|
return { ok: true, text: decoded };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLAUDE_IMAGE_MIME_TYPE_SET = new Set<string>(CLAUDE_IMAGE_MIME_TYPES);
|
||||||
|
|
||||||
export function buildClaudeAttachmentDeliveryParts(input: {
|
export function buildClaudeAttachmentDeliveryParts(input: {
|
||||||
text: string;
|
text: string;
|
||||||
attachments?: AttachmentPayload[];
|
attachments?: AttachmentPayload[];
|
||||||
}): ClaudeAttachmentDeliveryParts {
|
}): ClaudeAttachmentDeliveryParts {
|
||||||
const contentBlocks: ClaudeInputBlock[] = [{ type: 'text', text: input.text }];
|
const textBlock: ClaudeInputBlock = { type: 'text', text: input.text };
|
||||||
const attachments = input.attachments ?? [];
|
const attachments = input.attachments ?? [];
|
||||||
|
|
||||||
if (attachments.length === 0) {
|
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) {
|
for (const attachment of attachments) {
|
||||||
if (attachment.mimeType === 'application/pdf') {
|
if (attachment.mimeType === 'application/pdf') {
|
||||||
contentBlocks.push({
|
documentBlocks.push({
|
||||||
type: 'document',
|
type: 'document',
|
||||||
source: {
|
source: {
|
||||||
type: 'base64',
|
type: 'base64',
|
||||||
|
|
@ -54,7 +62,7 @@ export function buildClaudeAttachmentDeliveryParts(input: {
|
||||||
|
|
||||||
if (attachment.mimeType === 'text/plain' || attachment.mimeType.startsWith('text/')) {
|
if (attachment.mimeType === 'text/plain' || attachment.mimeType.startsWith('text/')) {
|
||||||
const decoded = decodeBase64Text(attachment.data);
|
const decoded = decodeBase64Text(attachment.data);
|
||||||
contentBlocks.push(
|
documentBlocks.push(
|
||||||
decoded.ok
|
decoded.ok
|
||||||
? {
|
? {
|
||||||
type: 'document',
|
type: 'document',
|
||||||
|
|
@ -78,8 +86,8 @@ export function buildClaudeAttachmentDeliveryParts(input: {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment.mimeType === 'image/png' || attachment.mimeType === 'image/jpeg') {
|
if (CLAUDE_IMAGE_MIME_TYPE_SET.has(attachment.mimeType)) {
|
||||||
contentBlocks.push({
|
imageBlocks.push({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
source: {
|
source: {
|
||||||
// Claude expects image bytes inside the structured image block as base64.
|
// 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[] {
|
export function redactClaudeBlocksForDiagnostics(blocks: ClaudeInputBlock[]): ClaudeInputBlock[] {
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,9 @@ export { FileClaudeStateProbe } from './adapters/output/ClaudeStateProbe';
|
||||||
export { NodePtyProcessAdapter } from './adapters/output/NodePtyProcessAdapter';
|
export { NodePtyProcessAdapter } from './adapters/output/NodePtyProcessAdapter';
|
||||||
export { FileTempEmptyMcpConfigStore } from './adapters/output/TempEmptyMcpConfigStore';
|
export { FileTempEmptyMcpConfigStore } from './adapters/output/TempEmptyMcpConfigStore';
|
||||||
export { createWorkspaceTrustCoordinator } from './composition/createWorkspaceTrustCoordinator';
|
export { createWorkspaceTrustCoordinator } from './composition/createWorkspaceTrustCoordinator';
|
||||||
|
export {
|
||||||
|
resolveWorkspaceTrustCanonicalGitRoot,
|
||||||
|
resolveWorkspaceTrustFilesystemGitRoot,
|
||||||
|
} from './infrastructure/WorkspaceTrustCanonicalGitRoot';
|
||||||
export { resolveWorkspaceTrustFeatureFlags } from './infrastructure/WorkspaceTrustFeatureFlags';
|
export { resolveWorkspaceTrustFeatureFlags } from './infrastructure/WorkspaceTrustFeatureFlags';
|
||||||
export { buildWorkspaceTrustPreflightEnv } from './infrastructure/workspaceTrustPreflightEnv';
|
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,
|
markRendererUnavailable,
|
||||||
safeSendToRenderer,
|
safeSendToRenderer,
|
||||||
} from './utils/safeWebContentsSend';
|
} from './utils/safeWebContentsSend';
|
||||||
|
import {
|
||||||
|
captureStartupMemorySnapshot,
|
||||||
|
formatStartupMemorySnapshot,
|
||||||
|
} from './utils/startupTelemetry';
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
import { setCodexRuntimeMainWindow } from './ipc/codexRuntime';
|
import { setCodexRuntimeMainWindow } from './ipc/codexRuntime';
|
||||||
import {
|
import {
|
||||||
|
|
@ -236,7 +240,12 @@ import {
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
import type { FileChangeEvent } from '@main/types';
|
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 logger = createLogger('App');
|
||||||
const appStartedAtMs = Date.now();
|
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_BACKGROUND_SERVICE_DELAY_MS = 5_000;
|
||||||
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||||
const appStartupStartedAt = Date.now();
|
const appStartupStartedAt = Date.now();
|
||||||
|
const initialStartupMemory = captureStartupMemorySnapshot();
|
||||||
let appStartupSteps: AppStartupStep[] = [
|
let appStartupSteps: AppStartupStep[] = [
|
||||||
{
|
{
|
||||||
phase: 'boot',
|
phase: 'boot',
|
||||||
message: 'Starting Agent Teams AI...',
|
message: 'Starting Agent Teams AI...',
|
||||||
startedAt: appStartupStartedAt,
|
startedAt: appStartupStartedAt,
|
||||||
updatedAt: appStartupStartedAt,
|
updatedAt: appStartupStartedAt,
|
||||||
|
memoryAtStart: initialStartupMemory,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let appStartupStatus: AppStartupStatus = {
|
let appStartupStatus: AppStartupStatus = {
|
||||||
|
|
@ -951,6 +962,7 @@ let appStartupStatus: AppStartupStatus = {
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: appStartupStartedAt,
|
startedAt: appStartupStartedAt,
|
||||||
updatedAt: appStartupStartedAt,
|
updatedAt: appStartupStartedAt,
|
||||||
|
memory: initialStartupMemory,
|
||||||
steps: appStartupSteps,
|
steps: appStartupSteps,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1001,7 +1013,11 @@ function cloneStartupSteps(): AppStartupStep[] {
|
||||||
return appStartupSteps.map((step) => ({ ...step }));
|
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) {
|
if (!update.phase && !update.message) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1015,12 +1031,14 @@ function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number):
|
||||||
current.finishedAt = now;
|
current.finishedAt = now;
|
||||||
current.durationMs = now - current.startedAt;
|
current.durationMs = now - current.startedAt;
|
||||||
current.updatedAt = now;
|
current.updatedAt = now;
|
||||||
|
current.memoryAtEnd = memory;
|
||||||
}
|
}
|
||||||
appStartupSteps.push({
|
appStartupSteps.push({
|
||||||
phase,
|
phase,
|
||||||
message,
|
message,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
memoryAtStart: memory,
|
||||||
});
|
});
|
||||||
if (appStartupSteps.length > 32) {
|
if (appStartupSteps.length > 32) {
|
||||||
appStartupSteps = appStartupSteps.slice(-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];
|
const current = appStartupSteps[appStartupSteps.length - 1];
|
||||||
if (!current || current.finishedAt) {
|
if (!current || current.finishedAt) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1039,20 +1057,30 @@ function finishCurrentStartupStep(now: number): void {
|
||||||
current.finishedAt = now;
|
current.finishedAt = now;
|
||||||
current.durationMs = now - current.startedAt;
|
current.durationMs = now - current.startedAt;
|
||||||
current.updatedAt = now;
|
current.updatedAt = now;
|
||||||
|
current.memoryAtEnd = memory;
|
||||||
}
|
}
|
||||||
|
|
||||||
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
|
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
updateStartupTimeline(update, now);
|
const memory = captureStartupMemorySnapshot();
|
||||||
|
updateStartupTimeline(update, now, memory);
|
||||||
if (update.ready === true || update.error) {
|
if (update.ready === true || update.error) {
|
||||||
finishCurrentStartupStep(now);
|
finishCurrentStartupStep(now, memory);
|
||||||
}
|
}
|
||||||
appStartupStatus = {
|
appStartupStatus = {
|
||||||
...appStartupStatus,
|
...appStartupStatus,
|
||||||
...update,
|
...update,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
memory,
|
||||||
steps: cloneStartupSteps(),
|
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);
|
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(
|
function buildMessageDeliveryText(
|
||||||
baseText: string,
|
baseText: string,
|
||||||
opts: {
|
opts: {
|
||||||
|
|
@ -2920,11 +2931,11 @@ async function handleSendMessage(
|
||||||
);
|
);
|
||||||
stdinSent = true;
|
stdinSent = true;
|
||||||
} catch (stdinError: unknown) {
|
} 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) {
|
if (validatedAttachments?.length) {
|
||||||
throw new Error(
|
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';
|
const errMsg = stdinError instanceof Error ? stdinError.message : 'unknown error';
|
||||||
|
|
|
||||||
|
|
@ -931,11 +931,6 @@ export class CliInstallerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const generation = this.statusGatherGeneration;
|
const generation = this.statusGatherGeneration;
|
||||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
|
||||||
if (!versionProbe.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
||||||
binaryPath,
|
binaryPath,
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -1024,6 +1019,7 @@ export class CliInstallerService {
|
||||||
* on timeout, getStatus() returns whatever fields were populated so far.
|
* on timeout, getStatus() returns whatever fields were populated so far.
|
||||||
*
|
*
|
||||||
* Flow: binary resolve → --version (sequential) → Promise.all([auth, GCS]) (parallel)
|
* 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(
|
private async gatherStatus(
|
||||||
ref: { current: CliInstallationStatus },
|
ref: { current: CliInstallationStatus },
|
||||||
|
|
@ -1050,6 +1046,18 @@ export class CliInstallerService {
|
||||||
diag.binaryResolveMs = Date.now() - binaryResolveStartedAt;
|
diag.binaryResolveMs = Date.now() - binaryResolveStartedAt;
|
||||||
if (binaryPath) {
|
if (binaryPath) {
|
||||||
r.binaryPath = 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 versionProbeStartedAt = Date.now();
|
||||||
const versionProbe = await this.probeCliVersion(binaryPath);
|
const versionProbe = await this.probeCliVersion(binaryPath);
|
||||||
diag.versionProbeMs = Date.now() - versionProbeStartedAt;
|
diag.versionProbeMs = Date.now() - versionProbeStartedAt;
|
||||||
|
|
@ -1059,13 +1067,6 @@ export class CliInstallerService {
|
||||||
r.launchError = null;
|
r.launchError = null;
|
||||||
r.authStatusChecking = true;
|
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.rememberHealthyStatus(r);
|
||||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,19 @@ export interface WatcherLifecycle {
|
||||||
isCurrent: () => boolean;
|
isCurrent: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PollSnapshotResult {
|
||||||
|
files: Map<string, string>;
|
||||||
|
cycleComplete: boolean;
|
||||||
|
deleteSafe?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PollSnapshot = Map<string, string> | PollSnapshotResult;
|
||||||
|
|
||||||
export interface CrossPlatformFileChangeSourceOptions {
|
export interface CrossPlatformFileChangeSourceOptions {
|
||||||
name: string;
|
name: string;
|
||||||
pollIntervalMs: number;
|
pollIntervalMs: number;
|
||||||
createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher;
|
createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher;
|
||||||
collectPollSnapshot: () => Promise<Map<string, string>>;
|
collectPollSnapshot: () => Promise<PollSnapshot>;
|
||||||
emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void;
|
emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void;
|
||||||
isOwnerActive: () => boolean;
|
isOwnerActive: () => boolean;
|
||||||
isWatchLimitError: (error: unknown) => boolean;
|
isWatchLimitError: (error: unknown) => boolean;
|
||||||
|
|
@ -34,6 +42,7 @@ export class CrossPlatformFileChangeSource {
|
||||||
private pollingGenerationInProgress: number | null = null;
|
private pollingGenerationInProgress: number | null = null;
|
||||||
private pollingPrimed = false;
|
private pollingPrimed = false;
|
||||||
private pollSnapshot = new Map<string, string>();
|
private pollSnapshot = new Map<string, string>();
|
||||||
|
private partialPollSnapshot = new Map<string, string>();
|
||||||
private closedGeneration: number | null = null;
|
private closedGeneration: number | null = null;
|
||||||
private rejectedGeneration: number | null = null;
|
private rejectedGeneration: number | null = null;
|
||||||
private generation = 0;
|
private generation = 0;
|
||||||
|
|
@ -180,6 +189,7 @@ export class CrossPlatformFileChangeSource {
|
||||||
this.pollingGenerationInProgress = null;
|
this.pollingGenerationInProgress = null;
|
||||||
this.pollingPrimed = false;
|
this.pollingPrimed = false;
|
||||||
this.pollSnapshot.clear();
|
this.pollSnapshot.clear();
|
||||||
|
this.partialPollSnapshot.clear();
|
||||||
|
|
||||||
const timer = this.pollingTimer;
|
const timer = this.pollingTimer;
|
||||||
this.pollingTimer = null;
|
this.pollingTimer = null;
|
||||||
|
|
@ -265,34 +275,51 @@ export class CrossPlatformFileChangeSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pollForChanges(expectedGeneration: number): Promise<void> {
|
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()) {
|
if (expectedGeneration !== this.generation || !this.options.isOwnerActive()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.mergePartialPollSnapshot(nextSnapshot.files);
|
||||||
|
|
||||||
if (!this.pollingPrimed) {
|
if (!this.pollingPrimed) {
|
||||||
logger.info(`${this.options.name} polling baseline captured`);
|
if (nextSnapshot.cycleComplete) {
|
||||||
this.pollSnapshot = nextSnapshot;
|
logger.info(`${this.options.name} polling baseline captured`);
|
||||||
this.pollingPrimed = true;
|
this.pollSnapshot = this.partialPollSnapshot;
|
||||||
|
this.partialPollSnapshot = new Map();
|
||||||
|
this.pollingPrimed = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [relativePath, fingerprint] of nextSnapshot) {
|
for (const [relativePath, fingerprint] of nextSnapshot.files) {
|
||||||
const previous = this.pollSnapshot.get(relativePath);
|
const previous = this.pollSnapshot.get(relativePath);
|
||||||
if (previous === undefined) {
|
if (previous === undefined) {
|
||||||
this.options.emitPolledChange('rename', relativePath);
|
this.options.emitPolledChange('rename', relativePath);
|
||||||
} else if (previous !== fingerprint) {
|
} else if (previous !== fingerprint) {
|
||||||
this.options.emitPolledChange('change', relativePath);
|
this.options.emitPolledChange('change', relativePath);
|
||||||
}
|
}
|
||||||
|
this.pollSnapshot.set(relativePath, fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const relativePath of this.pollSnapshot.keys()) {
|
if (nextSnapshot.cycleComplete) {
|
||||||
if (!nextSnapshot.has(relativePath)) {
|
const completedSnapshot = this.partialPollSnapshot;
|
||||||
this.options.emitPolledChange('rename', relativePath);
|
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> {
|
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 { type FileChangeEvent, type ParsedMessage } from '@main/types';
|
||||||
import { parseJsonlFileWithStats, parseJsonlStream } from '@main/utils/jsonl';
|
import {
|
||||||
|
countJsonlFileWithStats,
|
||||||
|
parseJsonlFileWithStats,
|
||||||
|
parseJsonlStream,
|
||||||
|
} from '@main/utils/jsonl';
|
||||||
import {
|
import {
|
||||||
getProjectsBasePath,
|
getProjectsBasePath,
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
|
|
@ -29,7 +33,10 @@ import { projectPathResolver } from '../discovery/ProjectPathResolver';
|
||||||
import { errorDetector } from '../error/ErrorDetector';
|
import { errorDetector } from '../error/ErrorDetector';
|
||||||
|
|
||||||
import { ConfigManager } from './ConfigManager';
|
import { ConfigManager } from './ConfigManager';
|
||||||
import { CrossPlatformFileChangeSource } from './CrossPlatformFileChangeSource';
|
import {
|
||||||
|
CrossPlatformFileChangeSource,
|
||||||
|
type PollSnapshotResult,
|
||||||
|
} from './CrossPlatformFileChangeSource';
|
||||||
import { type DataCache } from './DataCache';
|
import { type DataCache } from './DataCache';
|
||||||
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
||||||
import { type NotificationManager } from './NotificationManager';
|
import { type NotificationManager } from './NotificationManager';
|
||||||
|
|
@ -48,6 +55,10 @@ const WATCHER_RETRY_MS = 2000;
|
||||||
const TEAMS_POLL_INTERVAL_MS = 1000;
|
const TEAMS_POLL_INTERVAL_MS = 1000;
|
||||||
/** Poll interval for task files, which can be much larger than team metadata/inboxes */
|
/** Poll interval for task files, which can be much larger than team metadata/inboxes */
|
||||||
const TASKS_POLL_INTERVAL_MS = 3000;
|
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 */
|
/** Interval for periodic catch-up scan to detect missed fs.watch events */
|
||||||
const CATCH_UP_INTERVAL_MS = 30_000;
|
const CATCH_UP_INTERVAL_MS = 30_000;
|
||||||
/** Only catch-up scan files modified within this window */
|
/** Only catch-up scan files modified within this window */
|
||||||
|
|
@ -102,6 +113,10 @@ export class FileWatcher extends EventEmitter {
|
||||||
private catchUpCursor = 0;
|
private catchUpCursor = 0;
|
||||||
/** Consecutive catch-up stat timeouts per file. */
|
/** Consecutive catch-up stat timeouts per file. */
|
||||||
private catchUpStatFailures = new Map<string, number>();
|
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. */
|
/** Polling interval for projects fallback and SSH mode. */
|
||||||
private static readonly SSH_POLL_INTERVAL_MS = 3000;
|
private static readonly SSH_POLL_INTERVAL_MS = 3000;
|
||||||
/** Files currently being processed (concurrency guard) */
|
/** Files currently being processed (concurrency guard) */
|
||||||
|
|
@ -298,6 +313,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedSize.clear();
|
this.lastProcessedSize.clear();
|
||||||
this.activeSessionFiles.clear();
|
this.activeSessionFiles.clear();
|
||||||
this.catchUpStatFailures.clear();
|
this.catchUpStatFailures.clear();
|
||||||
|
this.projectsPollCursor = 0;
|
||||||
|
this.projectsPollCycleChunked = false;
|
||||||
this.processingInProgress.clear();
|
this.processingInProgress.clear();
|
||||||
this.pendingReprocess.clear();
|
this.pendingReprocess.clear();
|
||||||
|
|
||||||
|
|
@ -679,15 +696,30 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.changeSources.projects.startPolling();
|
this.changeSources.projects.startPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectProjectsPollSnapshot(): Promise<Map<string, string>> {
|
private async collectProjectsPollSnapshot(): Promise<PollSnapshotResult> {
|
||||||
const snapshot = new Map<string, string>();
|
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 (projectDirs.length === 0) {
|
||||||
if (!projectDir.isDirectory()) {
|
this.projectsPollCursor = 0;
|
||||||
continue;
|
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 projectPath = path.join(this.projectsPath, projectDir.name);
|
||||||
const entries = await this.readProviderSnapshotDir(projectPath);
|
const entries = await this.readProviderSnapshotDir(projectPath);
|
||||||
for (const entry of entries) {
|
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>> {
|
private async collectTodosPollSnapshot(): Promise<Map<string, string>> {
|
||||||
|
|
@ -928,6 +982,11 @@ export class FileWatcher extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFirstRead = lastLineCount === 0 && lastSize === 0;
|
const isFirstRead = lastLineCount === 0 && lastSize === 0;
|
||||||
|
if (isFirstRead && fileStats.birthtimeMs < this.instanceCreatedAt) {
|
||||||
|
await this.establishPreExistingFileBaseline(filePath, currentSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize;
|
const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize;
|
||||||
let newMessages: ParsedMessage[] = [];
|
let newMessages: ParsedMessage[] = [];
|
||||||
let currentLineCount: number;
|
let currentLineCount: number;
|
||||||
|
|
@ -952,22 +1011,6 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
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
|
// Detect errors in new messages
|
||||||
// Note: We pass the offset-adjusted line numbers to errorDetector
|
// Note: We pass the offset-adjusted line numbers to errorDetector
|
||||||
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
|
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.
|
* Clears the error detection tracking for a specific file.
|
||||||
* Call this when a file is deleted or to force re-processing.
|
* Call this when a file is deleted or to force re-processing.
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ export class TeamAttachmentStore {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
data: buffer.toString('base64'),
|
data: buffer.toString('base64'),
|
||||||
mimeType,
|
mimeType,
|
||||||
|
filePath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ import {
|
||||||
budgetWorkspaceTrustDiagnosticsManifest,
|
budgetWorkspaceTrustDiagnosticsManifest,
|
||||||
buildWorkspaceTrustPathCandidates,
|
buildWorkspaceTrustPathCandidates,
|
||||||
buildWorkspaceTrustPreflightEnv,
|
buildWorkspaceTrustPreflightEnv,
|
||||||
|
resolveWorkspaceTrustCanonicalGitRoot,
|
||||||
resolveWorkspaceTrustFeatureFlags,
|
resolveWorkspaceTrustFeatureFlags,
|
||||||
|
resolveWorkspaceTrustFilesystemGitRoot,
|
||||||
type WorkspaceTrustArgsOnlyPlanRequest,
|
type WorkspaceTrustArgsOnlyPlanRequest,
|
||||||
type WorkspaceTrustArgsOnlyPlanResult,
|
type WorkspaceTrustArgsOnlyPlanResult,
|
||||||
type WorkspaceTrustCoordinator,
|
type WorkspaceTrustCoordinator,
|
||||||
|
|
@ -3764,12 +3766,12 @@ export class TeamProvisioningService {
|
||||||
return [...providers];
|
return [...providers];
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
|
private async resolveWorkspaceTrustGitRoot(cwd: string): Promise<string | null> {
|
||||||
const normalizedCwd = cwd.trim();
|
const normalizedCwd = cwd.trim();
|
||||||
if (!normalizedCwd) {
|
if (!normalizedCwd) {
|
||||||
return Promise.resolve(null);
|
return null;
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
const gitRoot = await new Promise<string | null>((resolve) => {
|
||||||
execFile(
|
execFile(
|
||||||
'git',
|
'git',
|
||||||
['-C', normalizedCwd, 'rev-parse', '--show-toplevel'],
|
['-C', normalizedCwd, 'rev-parse', '--show-toplevel'],
|
||||||
|
|
@ -3789,6 +3791,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
return gitRoot ?? resolveWorkspaceTrustFilesystemGitRoot(normalizedCwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectWorkspaceTrustWorkspaces(input: {
|
private async collectWorkspaceTrustWorkspaces(input: {
|
||||||
|
|
@ -3807,9 +3810,10 @@ export class TeamProvisioningService {
|
||||||
let gitRoot = gitRootCache.get(cwd);
|
let gitRoot = gitRootCache.get(cwd);
|
||||||
if (gitRoot === undefined) {
|
if (gitRoot === undefined) {
|
||||||
const resolvedGitRoot = await this.resolveWorkspaceTrustGitRoot(cwd);
|
const resolvedGitRoot = await this.resolveWorkspaceTrustGitRoot(cwd);
|
||||||
gitRoot = resolvedGitRoot
|
const realGitRoot = resolvedGitRoot
|
||||||
? await fs.promises.realpath(resolvedGitRoot).catch(() => resolvedGitRoot)
|
? await fs.promises.realpath(resolvedGitRoot).catch(() => resolvedGitRoot)
|
||||||
: null;
|
: null;
|
||||||
|
gitRoot = realGitRoot ? await resolveWorkspaceTrustCanonicalGitRoot(realGitRoot) : null;
|
||||||
gitRootCache.set(cwd, gitRoot);
|
gitRootCache.set(cwd, gitRoot);
|
||||||
}
|
}
|
||||||
candidates.push(
|
candidates.push(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ export interface JsonlParseResult {
|
||||||
consumedBytes: number;
|
consumedBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface JsonlStreamParseOptions {
|
||||||
|
collectMessages?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a JSONL file line by line using streaming.
|
* Parse a JSONL file line by line using streaming.
|
||||||
* This avoids loading the entire file into memory.
|
* This avoids loading the entire file into memory.
|
||||||
|
|
@ -88,14 +92,38 @@ export async function parseJsonlFileWithStats(
|
||||||
return parseJsonlStream(fsProvider.createReadStream(filePath), filePath);
|
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
|
* Parse JSONL data from a readable stream while tracking how many bytes were
|
||||||
* safely consumed as complete lines.
|
* safely consumed as complete lines.
|
||||||
*/
|
*/
|
||||||
export async function parseJsonlStream(
|
export async function parseJsonlStream(
|
||||||
stream: Readable,
|
stream: Readable,
|
||||||
filePath?: string
|
filePath?: string,
|
||||||
|
options: JsonlStreamParseOptions = {}
|
||||||
): Promise<JsonlParseResult> {
|
): Promise<JsonlParseResult> {
|
||||||
|
const collectMessages = options.collectMessages !== false;
|
||||||
const messages: ParsedMessage[] = [];
|
const messages: ParsedMessage[] = [];
|
||||||
let pending = Buffer.alloc(0);
|
let pending = Buffer.alloc(0);
|
||||||
let parsedLineCount = 0;
|
let parsedLineCount = 0;
|
||||||
|
|
@ -122,9 +150,13 @@ export async function parseJsonlStream(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = parseJsonlLine(normalized);
|
if (collectMessages) {
|
||||||
if (parsed) {
|
const parsed = parseJsonlLine(normalized);
|
||||||
messages.push(parsed);
|
if (parsed) {
|
||||||
|
messages.push(parsed);
|
||||||
|
parsedLineCount += 1;
|
||||||
|
}
|
||||||
|
} else if (isCountableJsonlEntryLine(normalized)) {
|
||||||
parsedLineCount += 1;
|
parsedLineCount += 1;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -163,9 +195,14 @@ export async function parseJsonlStream(
|
||||||
const trailingLine = pending.toString('utf8');
|
const trailingLine = pending.toString('utf8');
|
||||||
const normalized = normalizeJsonlLine(trailingLine);
|
const normalized = normalizeJsonlLine(trailingLine);
|
||||||
if (looksLikeJsonObjectLine(normalized)) {
|
if (looksLikeJsonObjectLine(normalized)) {
|
||||||
const parsed = parseJsonlLine(normalized);
|
if (collectMessages) {
|
||||||
if (parsed) {
|
const parsed = parseJsonlLine(normalized);
|
||||||
messages.push(parsed);
|
if (parsed) {
|
||||||
|
messages.push(parsed);
|
||||||
|
parsedLineCount += 1;
|
||||||
|
consumedBytes += pending.length;
|
||||||
|
}
|
||||||
|
} else if (isCountableJsonlEntryLine(normalized)) {
|
||||||
parsedLineCount += 1;
|
parsedLineCount += 1;
|
||||||
consumedBytes += pending.length;
|
consumedBytes += pending.length;
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +260,45 @@ function looksLikeJsonObjectLine(line: string): boolean {
|
||||||
return line.startsWith('{');
|
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
|
// Entry Parsing
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { getClaudeBasePath, getHomeDir } from './pathDecoder';
|
import { getAppDataPath, getClaudeBasePath, getHomeDir } from './pathDecoder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sensitive file patterns that should never be accessible.
|
* Sensitive file patterns that should never be accessible.
|
||||||
|
|
@ -101,6 +101,7 @@ export function matchesSensitivePattern(normalizedPath: string): boolean {
|
||||||
* Allowed directories:
|
* Allowed directories:
|
||||||
* - The project path itself
|
* - The project path itself
|
||||||
* - The ~/.claude directory (for session data)
|
* - The ~/.claude directory (for session data)
|
||||||
|
* - The app-owned data directory (attachments, task attachments)
|
||||||
*
|
*
|
||||||
* @param normalizedPath - The normalized absolute path to check
|
* @param normalizedPath - The normalized absolute path to check
|
||||||
* @param projectPath - The project root path (can be null for global access)
|
* @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 normalizedTarget = normalizeForCompare(normalizedPath, isWindows);
|
||||||
const claudeDir = getClaudeBasePath();
|
const claudeDir = getClaudeBasePath();
|
||||||
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
|
const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows);
|
||||||
|
const appDataDir = getAppDataPath();
|
||||||
|
const normalizedAppDataDir = normalizeForCompare(appDataDir, isWindows);
|
||||||
|
|
||||||
// Always allow access to ~/.claude for session data
|
// Always allow access to ~/.claude for session data
|
||||||
if (isPathWithinRoot(normalizedTarget, normalizedClaudeDir)) {
|
if (isPathWithinRoot(normalizedTarget, normalizedClaudeDir)) {
|
||||||
return true;
|
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 project path provided, allow access within project
|
||||||
if (projectPath) {
|
if (projectPath) {
|
||||||
const normalizedProjectPath = normalizeForCompare(projectPath, isWindows);
|
const normalizedProjectPath = normalizeForCompare(projectPath, isWindows);
|
||||||
|
|
@ -137,7 +145,7 @@ export function isPathWithinAllowedDirectories(
|
||||||
* Security checks performed:
|
* Security checks performed:
|
||||||
* 1. Path must be absolute
|
* 1. Path must be absolute
|
||||||
* 2. Path traversal prevention (no ..)
|
* 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
|
* 4. Must not match sensitive file patterns
|
||||||
*
|
*
|
||||||
* @param filePath - The file path to validate
|
* @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="col-span-2 flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<ProviderBrandLogo
|
<ProviderBrandLogo
|
||||||
providerId={provider.providerId}
|
providerId={provider.providerId}
|
||||||
className="size-4 shrink-0"
|
className="size-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-xs font-medium"
|
className="truncate whitespace-nowrap text-xs font-medium"
|
||||||
style={{ color: 'var(--color-text)' }}
|
style={{ color: 'var(--color-text)' }}
|
||||||
>
|
>
|
||||||
{provider.providerId === 'opencode'
|
{provider.providerId === 'opencode'
|
||||||
|
|
@ -1053,7 +1053,7 @@ const InstalledBanner = ({
|
||||||
{openCodeDashboardChips.map((chip) => (
|
{openCodeDashboardChips.map((chip) => (
|
||||||
<span
|
<span
|
||||||
key={chip.label}
|
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}
|
title={chip.title}
|
||||||
>
|
>
|
||||||
{chip.label}
|
{chip.label}
|
||||||
|
|
@ -1061,7 +1061,7 @@ const InstalledBanner = ({
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-xs"
|
className="whitespace-nowrap text-xs"
|
||||||
style={{
|
style={{
|
||||||
color: getProviderStatusColor(statusText, provider.authenticated),
|
color: getProviderStatusColor(statusText, provider.authenticated),
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { LaunchTeamDialogLoadingFallback } from '../team/dialogs/LaunchTeamDialogLoadingFallback';
|
||||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||||
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
||||||
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
||||||
|
|
@ -588,7 +589,16 @@ export const SchedulesView = (): React.JSX.Element => {
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
{dialogOpen && (
|
{dialogOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LaunchTeamDialogLoadingFallback
|
||||||
|
mode="schedule"
|
||||||
|
teamName={editingSchedule?.teamName}
|
||||||
|
isEditingSchedule={Boolean(editingSchedule)}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
mode="schedule"
|
mode="schedule"
|
||||||
open={dialogOpen}
|
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="col-span-2 flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<ProviderBrandLogo
|
<ProviderBrandLogo
|
||||||
providerId={provider.providerId}
|
providerId={provider.providerId}
|
||||||
className="size-4 shrink-0"
|
className="size-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="font-medium"
|
className="truncate whitespace-nowrap font-medium"
|
||||||
style={{ color: 'var(--color-text-secondary)' }}
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
>
|
>
|
||||||
{provider.displayName}
|
{provider.displayName}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
className="whitespace-nowrap"
|
||||||
style={{
|
style={{
|
||||||
color: getProviderStatusColor(
|
color: getProviderStatusColor(
|
||||||
statusText,
|
statusText,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||||
|
import { LaunchTeamDialogLoadingFallback } from './dialogs/LaunchTeamDialogLoadingFallback';
|
||||||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||||||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||||
|
|
@ -2580,7 +2581,15 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{launchDialogOpen && (
|
{launchDialogOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LaunchTeamDialogLoadingFallback
|
||||||
|
mode={launchDialogState.mode}
|
||||||
|
teamName={teamName}
|
||||||
|
onClose={closeLaunchDialog}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
mode={launchDialogState.mode}
|
mode={launchDialogState.mode}
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
|
|
@ -3418,7 +3427,15 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{launchDialogOpen && (
|
{launchDialogOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LaunchTeamDialogLoadingFallback
|
||||||
|
mode={launchDialogState.mode}
|
||||||
|
teamName={teamName}
|
||||||
|
onClose={closeLaunchDialog}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
mode={launchDialogState.mode}
|
mode={launchDialogState.mode}
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { LaunchTeamDialogLoadingFallback } from './dialogs/LaunchTeamDialogLoadingFallback';
|
||||||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||||
import { TeamEmptyState } from './TeamEmptyState';
|
import { TeamEmptyState } from './TeamEmptyState';
|
||||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||||
|
|
@ -1094,7 +1095,15 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
||||||
);
|
);
|
||||||
|
|
||||||
const launchDialogElement = launchDialogOpen && (
|
const launchDialogElement = launchDialogOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LaunchTeamDialogLoadingFallback
|
||||||
|
mode={launchDialogMode}
|
||||||
|
teamName={launchDialogTeamName}
|
||||||
|
onClose={() => setLaunchDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
{launchDialogMode === 'relaunch' ? (
|
{launchDialogMode === 'relaunch' ? (
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
mode="relaunch"
|
mode="relaunch"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useAppTranslation } from '@features/localization/renderer';
|
import { useAppTranslation } from '@features/localization/renderer';
|
||||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import { isImageMime } from '@renderer/utils/attachmentUtils';
|
import { isImageMime } from '@renderer/utils/attachmentUtils';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ export const AttachmentDisplay = ({
|
||||||
attachments,
|
attachments,
|
||||||
}: AttachmentDisplayProps): React.JSX.Element | null => {
|
}: AttachmentDisplayProps): React.JSX.Element | null => {
|
||||||
const { t } = useAppTranslation('team');
|
const { t } = useAppTranslation('team');
|
||||||
|
const revealFileInEditor = useStore((s) => s.revealFileInEditor);
|
||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
loaded: AttachmentFileData[];
|
loaded: AttachmentFileData[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
@ -74,10 +76,16 @@ export const AttachmentDisplay = ({
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined,
|
dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined,
|
||||||
|
filePath: data.filePath ?? meta.filePath,
|
||||||
isImage,
|
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;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
|
@ -107,10 +115,29 @@ export const AttachmentDisplay = ({
|
||||||
: undefined
|
: 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
|
<div
|
||||||
key={item.meta.id}
|
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)]"
|
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" />
|
<FileIcon fileName={item.meta.filename} className="size-5" />
|
||||||
<span className="max-w-[72px] truncate text-[9px] text-[var(--color-text-muted)]">
|
<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';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { LaunchTeamDialogLoadingFallback } from '../dialogs/LaunchTeamDialogLoadingFallback';
|
||||||
|
|
||||||
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
||||||
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
||||||
import { ScheduleRunRow } from './ScheduleRunRow';
|
import { ScheduleRunRow } from './ScheduleRunRow';
|
||||||
|
|
@ -311,7 +313,16 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
{dialogOpen && (
|
{dialogOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LaunchTeamDialogLoadingFallback
|
||||||
|
mode="schedule"
|
||||||
|
teamName={teamName}
|
||||||
|
isEditingSchedule={Boolean(editingSchedule)}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
mode="schedule"
|
mode="schedule"
|
||||||
open={dialogOpen}
|
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_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
|
||||||
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
|
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
|
||||||
const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_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']> =
|
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
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 cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let deferredProviderStatusCleanup: (() => void) | null = null;
|
let deferredProviderStatusCleanup: (() => void) | null = null;
|
||||||
|
let deferredGlobalTasksCleanup: (() => void) | null = null;
|
||||||
|
let disposed = false;
|
||||||
useStore.getState().subscribeProvisioningProgress();
|
useStore.getState().subscribeProvisioningProgress();
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
useStore.getState().unsubscribeProvisioningProgress();
|
useStore.getState().unsubscribeProvisioningProgress();
|
||||||
|
|
@ -286,18 +290,33 @@ export function initializeNotificationListeners(): () => void {
|
||||||
runtimeStatusTimer = null;
|
runtimeStatusTimer = null;
|
||||||
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS);
|
}, 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([
|
await Promise.all([
|
||||||
useStore.getState().fetchAllTasks(),
|
|
||||||
useStore.getState().fetchTeams(),
|
useStore.getState().fetchTeams(),
|
||||||
useStore.getState().fetchNotifications(),
|
useStore.getState().fetchNotifications(),
|
||||||
useStore.getState().fetchSchedules(),
|
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(() => {
|
cleanupFns.push(() => {
|
||||||
|
disposed = true;
|
||||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||||
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
|
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
|
||||||
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
|
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
|
||||||
|
if (deferredGlobalTasksCleanup) deferredGlobalTasksCleanup();
|
||||||
});
|
});
|
||||||
// TODO(task-change-presence): re-enable this only after the board uses a bounded
|
// 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
|
// 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 {
|
function canReceiveAnyAttachment(capability: AgentAttachmentCapability): boolean {
|
||||||
return capability.supportsImages || capability.supportsFiles;
|
return capability.supportsImages || capability.supportsFiles;
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +72,7 @@ export function getAttachmentInputAcceptForMember(
|
||||||
}
|
}
|
||||||
const { capability } = resolveMemberAttachmentCapability(member);
|
const { capability } = resolveMemberAttachmentCapability(member);
|
||||||
if (capability.supportsImages && !capability.supportsFiles) {
|
if (capability.supportsImages && !capability.supportsFiles) {
|
||||||
return 'image/png,image/jpeg,image/webp';
|
return capability.supportedImageMimeTypes.join(',');
|
||||||
}
|
}
|
||||||
return '*/*';
|
return '*/*';
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +103,10 @@ export function validateAttachmentFilesForMember(input: {
|
||||||
if (!capability.supportsImages) {
|
if (!capability.supportsImages) {
|
||||||
return capability.displayText;
|
return capability.displayText;
|
||||||
}
|
}
|
||||||
|
const mimeType = getEffectiveMimeType(file);
|
||||||
|
if (!isSupportedImageMime(mimeType, capability.supportedImageMimeTypes)) {
|
||||||
|
return 'This image type is not supported by the selected model.';
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!capability.supportsFiles) {
|
if (!capability.supportsFiles) {
|
||||||
|
|
@ -136,6 +144,9 @@ export function validateAttachmentPayloadsForMember(input: {
|
||||||
if (!capability.supportsImages) {
|
if (!capability.supportsImages) {
|
||||||
return capability.displayText;
|
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) {
|
if (attachment.size > capability.maxBytesPerImage) {
|
||||||
return 'Image is too large for the selected model.';
|
return 'Image is too large for the selected model.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,7 @@ export interface AppStartupStatus {
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
steps?: AppStartupStep[];
|
steps?: AppStartupStep[];
|
||||||
|
memory?: AppStartupMemorySnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppStartupStep {
|
export interface AppStartupStep {
|
||||||
|
|
@ -346,6 +347,8 @@ export interface AppStartupStep {
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
finishedAt?: number;
|
finishedAt?: number;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
memoryAtStart?: AppStartupMemorySnapshot;
|
||||||
|
memoryAtEnd?: AppStartupMemorySnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppStartupAPI {
|
export interface AppStartupAPI {
|
||||||
|
|
@ -353,6 +356,14 @@ export interface AppStartupAPI {
|
||||||
onProgress: (callback: (status: AppStartupStatus) => void) => () => void;
|
onProgress: (callback: (status: AppStartupStatus) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppStartupMemorySnapshot {
|
||||||
|
rssBytes: number;
|
||||||
|
heapUsedBytes: number;
|
||||||
|
heapTotalBytes: number;
|
||||||
|
externalBytes: number;
|
||||||
|
arrayBuffersBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Context API
|
// Context API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ export interface CliInstallationStatus {
|
||||||
showVersionDetails: boolean;
|
showVersionDetails: boolean;
|
||||||
/** Whether binary path should be shown in the UI */
|
/** Whether binary path should be shown in the UI */
|
||||||
showBinaryPath: boolean;
|
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: boolean;
|
||||||
/** Installed version string (e.g. "2.1.59"), null if unavailable or not installed */
|
/** Installed version string (e.g. "2.1.59"), null if unavailable or not installed */
|
||||||
installedVersion: string | null;
|
installedVersion: string | null;
|
||||||
|
|
|
||||||
|
|
@ -591,6 +591,8 @@ export interface AttachmentFileData {
|
||||||
id: string;
|
id: string;
|
||||||
data: string;
|
data: string;
|
||||||
mimeType: AttachmentMediaType;
|
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). */
|
/** Lightweight metadata for a single tool call (for UI display in tooltips). */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ClaudePtyWorkspaceTrustStrategy,
|
ClaudePtyWorkspaceTrustStrategy,
|
||||||
DefaultWorkspaceTrustCoordinator,
|
DefaultWorkspaceTrustCoordinator,
|
||||||
|
|
@ -9,9 +7,11 @@ import {
|
||||||
} from '@features/workspace-trust/core/application';
|
} from '@features/workspace-trust/core/application';
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTrustPathCandidates,
|
buildWorkspaceTrustPathCandidates,
|
||||||
|
readCodexWorkspaceTrustConfigOverridesFromSettings,
|
||||||
type WorkspaceTrustDiagnosticStrategyResult,
|
type WorkspaceTrustDiagnosticStrategyResult,
|
||||||
type WorkspaceTrustWorkspace,
|
type WorkspaceTrustWorkspace,
|
||||||
} from '@features/workspace-trust/core/domain';
|
} from '@features/workspace-trust/core/domain';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const featureFlags = {
|
const featureFlags = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -33,6 +33,18 @@ function workspace(): WorkspaceTrustWorkspace {
|
||||||
})[0];
|
})[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 {
|
class RecordingClaudeStrategy extends ClaudePtyWorkspaceTrustStrategy {
|
||||||
active = 0;
|
active = 0;
|
||||||
maxActive = 0;
|
maxActive = 0;
|
||||||
|
|
@ -83,6 +95,31 @@ describe('WorkspaceTrustCoordinator', () => {
|
||||||
expect(plan.launchArgPatches[0].args.join(' ')).toContain('agent_teams_workspace_trust');
|
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 () => {
|
it('does not emit Codex settings patches for Anthropic-only launches', async () => {
|
||||||
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
|
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
|
||||||
const plan = await coordinator.planArgsOnly({
|
const plan = await coordinator.planArgsOnly({
|
||||||
|
|
@ -94,6 +131,23 @@ describe('WorkspaceTrustCoordinator', () => {
|
||||||
expect(plan.launchArgPatches).toEqual([]);
|
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 () => {
|
it('limits Codex settings patches to requested target surfaces', async () => {
|
||||||
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
|
const coordinator = new DefaultWorkspaceTrustCoordinator(new ClaudePtyWorkspaceTrustStrategy());
|
||||||
const plan = await coordinator.planArgsOnly({
|
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
|
// Mock pathDecoder
|
||||||
vi.mock('@main/utils/pathDecoder', () => ({
|
vi.mock('@main/utils/pathDecoder', () => ({
|
||||||
|
getAppDataPath: () => path.join(os.homedir(), '.agent-teams-ai', 'data'),
|
||||||
getClaudeBasePath: () => path.join(os.homedir(), '.claude'),
|
getClaudeBasePath: () => path.join(os.homedir(), '.claude'),
|
||||||
|
getHomeDir: () => os.homedir(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import * as fs from 'fs/promises';
|
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 () => {
|
it('rejects delegate mode when recipient is not the team lead', async () => {
|
||||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||||
expect(sendHandler).toBeDefined();
|
expect(sendHandler).toBeDefined();
|
||||||
|
|
|
||||||
|
|
@ -356,7 +356,6 @@ describe('CliInstallerService', () => {
|
||||||
showBinaryPath: false,
|
showBinaryPath: false,
|
||||||
});
|
});
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/agent_teams_orchestrator');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/agent_teams_orchestrator');
|
||||||
vi.mocked(execCli).mockResolvedValueOnce({ stdout: '0.0.46', stderr: '' });
|
|
||||||
const getProviderStatusesSpy = vi
|
const getProviderStatusesSpy = vi
|
||||||
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses')
|
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses')
|
||||||
.mockResolvedValue([
|
.mockResolvedValue([
|
||||||
|
|
@ -377,6 +376,8 @@ describe('CliInstallerService', () => {
|
||||||
.filter((event) => event.type === 'status');
|
.filter((event) => event.type === 'status');
|
||||||
|
|
||||||
expect(status.installed).toBe(true);
|
expect(status.installed).toBe(true);
|
||||||
|
expect(status.binaryPath).toBe('/mock/agent_teams_orchestrator');
|
||||||
|
expect(status.installedVersion).toBeNull();
|
||||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||||
expect(status.authStatusChecking).toBe(false);
|
expect(status.authStatusChecking).toBe(false);
|
||||||
expect(status.authLoggedIn).toBe(false);
|
expect(status.authLoggedIn).toBe(false);
|
||||||
|
|
@ -403,11 +404,14 @@ describe('CliInstallerService', () => {
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(getProviderStatusesSpy).not.toHaveBeenCalled();
|
expect(getProviderStatusesSpy).not.toHaveBeenCalled();
|
||||||
expect(execCli).toHaveBeenCalledTimes(1);
|
expect(execCli).not.toHaveBeenCalled();
|
||||||
expect(execCli).toHaveBeenCalledWith(
|
await vi.waitFor(() =>
|
||||||
'/mock/agent_teams_orchestrator',
|
expect(appendCliAuthDiag).toHaveBeenCalledWith(
|
||||||
['--version'],
|
expect.objectContaining({
|
||||||
expect.objectContaining({ timeout: expect.any(Number) })
|
shellEnvMs: 0,
|
||||||
|
versionProbeMs: 0,
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -422,6 +426,32 @@ describe('CliInstallerService', () => {
|
||||||
expect(status.installedVersion).toBeNull();
|
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 () => {
|
it('retries the version probe once before marking the runtime unhealthy', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||||
|
|
|
||||||
|
|
@ -595,4 +595,126 @@ describe('CrossPlatformFileChangeSource', () => {
|
||||||
source.stop();
|
source.stop();
|
||||||
active = false;
|
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();
|
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 () => {
|
it('treats SSH not-found subagent directories as empty during project polling', async () => {
|
||||||
const projectsDir = '/remote/projects';
|
const projectsDir = '/remote/projects';
|
||||||
const todosDir = '/remote/todos';
|
const todosDir = '/remote/todos';
|
||||||
|
|
@ -2542,6 +2606,56 @@ describe('FileWatcher', () => {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
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 () => {
|
it('detects errors immediately for files created after watcher startup', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
useRealExistsSync();
|
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 () => {
|
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
|
||||||
const firstReadiness = readiness({
|
const firstReadiness = readiness({
|
||||||
state: 'mcp_unavailable',
|
state: 'mcp_unavailable',
|
||||||
|
|
|
||||||
|
|
@ -10117,7 +10117,10 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
message: { content: Array<Record<string, unknown>> };
|
message: { content: Array<Record<string, unknown>> };
|
||||||
};
|
};
|
||||||
expect(payload.message.content).toMatchObject([
|
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',
|
type: 'document',
|
||||||
source: { type: 'text', media_type: 'text/plain', data: 'line one\nline two' },
|
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=' },
|
source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0xLjQ=' },
|
||||||
title: 'brief.pdf',
|
title: 'brief.pdf',
|
||||||
},
|
},
|
||||||
{
|
{ type: 'text', text: 'review the attached files' },
|
||||||
type: 'image',
|
|
||||||
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' },
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
expect(svc.isTeamAlive(firstTeamName)).toBe(true);
|
expect(svc.isTeamAlive(firstTeamName)).toBe(true);
|
||||||
expect(svc.isTeamAlive(secondTeamName)).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 () => {
|
it('routes messages to the current pure Anthropic run after same-team relaunch', async () => {
|
||||||
const teamName = 'pure-anthropic-message-current-run-safe-e2e';
|
const teamName = 'pure-anthropic-message-current-run-safe-e2e';
|
||||||
await writePureAnthropicTeamConfig({ teamName, projectPath });
|
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 { EventEmitter } from 'events';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { promises as fsPromises } from 'fs';
|
import { promises as fsPromises } from 'fs';
|
||||||
|
|
@ -23095,6 +23098,42 @@ describe('TeamProvisioningService', () => {
|
||||||
).toEqual(['claude', 'codex', 'gemini', 'opencode']);
|
).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 () => {
|
it('degrades workspace trust planning failures without blocking launch preparation', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const workspaces = buildWorkspaceTrustPathCandidates({
|
const workspaces = buildWorkspaceTrustPathCandidates({
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { TeamAttachmentStore } from '../../../src/main/services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../../../src/main/services/team/TeamAttachmentStore';
|
||||||
import { TeamTaskAttachmentStore } from '../../../src/main/services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../../../src/main/services/team/TeamTaskAttachmentStore';
|
||||||
|
import {
|
||||||
|
type ElectronUserDataMigrationApp,
|
||||||
|
getLegacyElectronUserDataCandidates,
|
||||||
|
migrateElectronUserDataDirectory,
|
||||||
|
shouldCopyElectronUserDataEntry,
|
||||||
|
} from '../../../src/main/utils/electronUserDataMigration';
|
||||||
import {
|
import {
|
||||||
getAppDataPath,
|
getAppDataPath,
|
||||||
getBackupsBasePath,
|
getBackupsBasePath,
|
||||||
|
|
@ -13,12 +18,6 @@ import {
|
||||||
getMcpServerBasePath,
|
getMcpServerBasePath,
|
||||||
setAppDataBasePath,
|
setAppDataBasePath,
|
||||||
} from '../../../src/main/utils/pathDecoder';
|
} from '../../../src/main/utils/pathDecoder';
|
||||||
import {
|
|
||||||
getLegacyElectronUserDataCandidates,
|
|
||||||
migrateElectronUserDataDirectory,
|
|
||||||
shouldCopyElectronUserDataEntry,
|
|
||||||
type ElectronUserDataMigrationApp,
|
|
||||||
} from '../../../src/main/utils/electronUserDataMigration';
|
|
||||||
|
|
||||||
class FakeElectronApp implements ElectronUserDataMigrationApp {
|
class FakeElectronApp implements ElectronUserDataMigrationApp {
|
||||||
setPathCalls: { name: string; value: string }[] = [];
|
setPathCalls: { name: string; value: string }[] = [];
|
||||||
|
|
@ -407,6 +406,7 @@ describe('electron userData migration', () => {
|
||||||
id: 'att-1',
|
id: 'att-1',
|
||||||
data: Buffer.from('message attachment').toString('base64'),
|
data: Buffer.from('message attachment').toString('base64'),
|
||||||
mimeType: 'text/plain',
|
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 {
|
import {
|
||||||
analyzeSessionFileMetadata,
|
analyzeSessionFileMetadata,
|
||||||
calculateMetrics,
|
calculateMetrics,
|
||||||
|
countJsonlFileWithStats,
|
||||||
parseJsonlFile,
|
parseJsonlFile,
|
||||||
|
parseJsonlFileWithStats,
|
||||||
parseJsonlLine,
|
parseJsonlLine,
|
||||||
} from '../../../src/main/utils/jsonl';
|
} from '../../../src/main/utils/jsonl';
|
||||||
|
|
||||||
import type { ParsedMessage } from '../../../src/main/types';
|
import type { ParsedMessage } from '../../../src/main/types';
|
||||||
|
|
||||||
// Helper to create a minimal ParsedMessage
|
// Helper to create a minimal ParsedMessage
|
||||||
|
|
@ -190,6 +193,105 @@ describe('jsonl', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('tolerant parsing', () => {
|
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 () => {
|
it('skips non-JSON garbage and ignores a partial trailing object', async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tolerant-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tolerant-'));
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isPathWithinRoot,
|
getHomeDir,
|
||||||
|
setAppDataBasePath,
|
||||||
|
setClaudeBasePathOverride,
|
||||||
|
} from '../../../src/main/utils/pathDecoder';
|
||||||
|
import {
|
||||||
isPathWithinAllowedDirectories,
|
isPathWithinAllowedDirectories,
|
||||||
|
isPathWithinRoot,
|
||||||
isWindowsReservedFileName,
|
isWindowsReservedFileName,
|
||||||
validateFileName,
|
validateFileName,
|
||||||
validateFilePath,
|
validateFilePath,
|
||||||
|
|
@ -22,14 +25,18 @@ import {
|
||||||
describe('pathValidation', () => {
|
describe('pathValidation', () => {
|
||||||
const homeDir = getHomeDir();
|
const homeDir = getHomeDir();
|
||||||
const claudeDir = path.join(homeDir, '.claude');
|
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');
|
const testProjectPath = path.resolve('/home/user/my-project');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setClaudeBasePathOverride(claudeDir);
|
setClaudeBasePathOverride(claudeDir);
|
||||||
|
setAppDataBasePath(appDataBasePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setClaudeBasePathOverride(null);
|
setClaudeBasePathOverride(null);
|
||||||
|
setAppDataBasePath(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isPathWithinAllowedDirectories', () => {
|
describe('isPathWithinAllowedDirectories', () => {
|
||||||
|
|
@ -48,6 +55,15 @@ describe('pathValidation', () => {
|
||||||
).toBe(true);
|
).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', () => {
|
it('should reject paths outside allowed directories', () => {
|
||||||
expect(isPathWithinAllowedDirectories('/etc/passwd', testProjectPath)).toBe(false);
|
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();
|
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 () => {
|
it('allows next refresh after throttle window passes', async () => {
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,9 @@ describe('attachmentRecipientCapabilities', () => {
|
||||||
expect(getMemberAttachmentUnavailableReason(bob)).toBe(
|
expect(getMemberAttachmentUnavailableReason(bob)).toBe(
|
||||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
'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.'
|
'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(
|
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBe(
|
||||||
|
|
@ -62,8 +64,56 @@ describe('attachmentRecipientCapabilities', () => {
|
||||||
|
|
||||||
expect(getMemberAttachmentUnavailableReason(bob)).toBeNull();
|
expect(getMemberAttachmentUnavailableReason(bob)).toBeNull();
|
||||||
expect(getAttachmentInputAcceptForMember(bob)).toBe('image/png,image/jpeg,image/webp');
|
expect(getAttachmentInputAcceptForMember(bob)).toBe('image/png,image/jpeg,image/webp');
|
||||||
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBeNull();
|
expect(
|
||||||
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBeNull();
|
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', () => {
|
it('blocks non-image files for image-only providers', () => {
|
||||||
|
|
@ -74,7 +124,12 @@ describe('attachmentRecipientCapabilities', () => {
|
||||||
model: 'gpt-5.5',
|
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.'
|
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -95,7 +150,12 @@ describe('attachmentRecipientCapabilities', () => {
|
||||||
model: 'claude-opus-4-6',
|
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(
|
expect(
|
||||||
validateAttachmentPayloadsForMember({
|
validateAttachmentPayloadsForMember({
|
||||||
member: anthropicLead,
|
member: anthropicLead,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue