Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

382 lines
11 KiB
TypeScript

import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type RefObject
} from "react";
import { createPortal } from "react-dom";
import { cn } from "../../../app/renderer/lib/utils";
import { DESKTOP_WINDOW_TOP_MARGIN } from "../../workspaceDesktop/constants";
import { resolveComposerPortalTarget } from "../composer/composerPortalTarget";
const COMPOSER_MENU_GAP_PX = 8;
const COMPOSER_MENU_VIEWPORT_PADDING_PX = 8;
// Keep the menu clear of the workspace window's own header, where the macOS
// traffic lights live (see `workspaceWindowHeaderHeightPx` in
// apps/desktop/src/main/windows/workspaceWindow.ts and the matching
// `DESKTOP_WINDOW_TOP_MARGIN` reserve used to place node windows below that
// header). Anchors near the top of the canvas otherwise collapse this menu's
// top offset down to the bare viewport padding, landing it on top of the
// traffic lights and drag region.
const COMPOSER_MENU_TOP_SAFE_AREA_PX = DESKTOP_WINDOW_TOP_MARGIN;
const COMPOSER_MENU_MIN_HEIGHT_PX = 280;
export interface ComposerAnchoredMenuFrame {
bottom: number;
height: number;
left: number;
minHeight: number;
portalTarget: Element;
top: number;
width: number;
zIndex: number | string;
}
export interface UseComposerAnchoredMenuFrameOptions {
anchorRef: RefObject<HTMLElement | null>;
maxHeight: number;
open: boolean;
}
export type ComposerFloatingMenuPlacement = "fixed-height" | "dynamic-above";
export interface ComposerFloatingMenuSurfaceProps {
anchorRef: RefObject<HTMLElement | null>;
children: React.ReactNode;
className?: string;
contentClassName?: string;
dismissBoundaryRef?: RefObject<HTMLElement | null>;
maxHeight: number;
onDismiss?: () => void;
open: boolean;
placement: ComposerFloatingMenuPlacement;
surfaceRef?: RefObject<HTMLDivElement | null>;
testId: string;
}
const COMPOSER_MENU_WINDOW_FRAME_SELECTOR =
"[data-workbench-window-id], [data-workspace-node-window-root='true']";
function resolveComposerMenuWindowFrame(anchor: HTMLElement): Element | null {
return anchor.closest(COMPOSER_MENU_WINDOW_FRAME_SELECTOR);
}
function resolveComposerMenuZIndex(anchor: HTMLElement): number | string {
let current: HTMLElement | null = anchor;
while (current) {
if (current.matches(COMPOSER_MENU_WINDOW_FRAME_SELECTOR)) {
const windowZIndex = Number.parseInt(
window.getComputedStyle(current).zIndex,
10
);
if (Number.isFinite(windowZIndex)) {
return windowZIndex + 1;
}
}
current = current.parentElement;
}
return "var(--z-popover)";
}
// Mirrors the real workspace window's `COMPOSER_MENU_TOP_SAFE_AREA_PX` floor,
// but scoped to a virtual node window on the workspace canvas
// (`WorkspaceNodeWindow.tsx`). Each node draws its own decorative header with
// fake traffic-light buttons (`data-workspace-node-window-header="true"`);
// clamping only against the real viewport's 52px chrome ignores that a
// compact node's header can sit well below (or, rarer, above) that mark.
// Falls back to the enclosing frame's own top edge when no explicit header
// is found, so the menu never renders above the window/node it lives in.
function resolveComposerMenuTopSafeArea(anchor: HTMLElement): number {
const windowFrame = resolveComposerMenuWindowFrame(anchor);
if (!windowFrame) {
return COMPOSER_MENU_TOP_SAFE_AREA_PX;
}
const header = windowFrame.querySelector(
'[data-workspace-node-window-header="true"]'
);
const localFloor = (header ?? windowFrame).getBoundingClientRect()[
header ? "bottom" : "top"
];
return Math.max(COMPOSER_MENU_TOP_SAFE_AREA_PX, localFloor);
}
function computeComposerAnchoredMenuFrame(
anchor: HTMLElement,
maxHeight: number
): ComposerAnchoredMenuFrame {
const rect = anchor.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.max(
0,
Math.min(rect.width, viewportWidth - COMPOSER_MENU_VIEWPORT_PADDING_PX * 2)
);
const left = Math.max(
COMPOSER_MENU_VIEWPORT_PADDING_PX,
Math.min(
rect.left,
viewportWidth - COMPOSER_MENU_VIEWPORT_PADDING_PX - width
)
);
const topSafeArea = resolveComposerMenuTopSafeArea(anchor);
const availableAbove = rect.top - COMPOSER_MENU_GAP_PX - topSafeArea;
// Never demand more minimum height than the enclosing window/node actually
// has room for above its own safe area — otherwise a compact node (as
// short as `DESKTOP_WINDOW_MIN_HEIGHT`) is forced to render the menu past
// its own header, regardless of the safe-area clamp below.
const minHeight = Math.max(
0,
Math.min(COMPOSER_MENU_MIN_HEIGHT_PX, availableAbove)
);
const height =
availableAbove >= maxHeight
? maxHeight
: Math.max(minHeight, Math.min(maxHeight, availableAbove));
return {
bottom: Math.max(
COMPOSER_MENU_VIEWPORT_PADDING_PX,
viewportHeight - rect.top + COMPOSER_MENU_GAP_PX
),
height,
left,
minHeight,
portalTarget: resolveComposerPortalTarget(anchor),
top: Math.max(
topSafeArea,
Math.min(
rect.top - COMPOSER_MENU_GAP_PX - height,
viewportHeight - COMPOSER_MENU_VIEWPORT_PADDING_PX - height
)
),
width,
zIndex: resolveComposerMenuZIndex(anchor)
};
}
function sameComposerAnchoredMenuFrame(
left: ComposerAnchoredMenuFrame | null,
right: ComposerAnchoredMenuFrame
): boolean {
return (
left !== null &&
left.portalTarget === right.portalTarget &&
left.zIndex === right.zIndex &&
Math.abs(left.left - right.left) < 0.5 &&
Math.abs(left.top - right.top) < 0.5 &&
Math.abs(left.bottom - right.bottom) < 0.5 &&
Math.abs(left.width - right.width) < 0.5 &&
Math.abs(left.height - right.height) < 0.5 &&
Math.abs(left.minHeight - right.minHeight) < 0.5
);
}
export function useComposerAnchoredMenuFrame({
anchorRef,
maxHeight,
open
}: UseComposerAnchoredMenuFrameOptions): ComposerAnchoredMenuFrame | null {
const [frame, setFrame] = useState<ComposerAnchoredMenuFrame | null>(null);
const syncFrame = useCallback((): void => {
const anchor = anchorRef.current;
if (!anchor || typeof window === "undefined") {
setFrame(null);
return;
}
const nextFrame = computeComposerAnchoredMenuFrame(anchor, maxHeight);
setFrame((previous) =>
sameComposerAnchoredMenuFrame(previous, nextFrame) ? previous : nextFrame
);
}, [anchorRef, maxHeight]);
useLayoutEffect(() => {
if (!open) {
setFrame(null);
return;
}
syncFrame();
const anchor = anchorRef.current;
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(syncFrame);
if (anchor) {
resizeObserver?.observe(anchor);
}
window.addEventListener("resize", syncFrame);
window.addEventListener("scroll", syncFrame, {
capture: true,
passive: true
});
return () => {
resizeObserver?.disconnect();
window.removeEventListener("resize", syncFrame);
window.removeEventListener("scroll", syncFrame, true);
};
}, [anchorRef, open, syncFrame]);
return frame;
}
function assignRef<T>(ref: RefObject<T | null> | undefined, value: T | null) {
if (ref) {
ref.current = value;
}
}
export const ComposerFloatingMenuSurface = forwardRef<
HTMLDivElement,
ComposerFloatingMenuSurfaceProps
>(function ComposerFloatingMenuSurface(
{
anchorRef,
children,
className,
contentClassName,
dismissBoundaryRef,
maxHeight,
onDismiss,
open,
placement,
surfaceRef,
testId
},
forwardedRef
): React.JSX.Element | null {
const localSurfaceRef = useRef<HTMLDivElement | null>(null);
const frame = useComposerAnchoredMenuFrame({
anchorRef,
maxHeight,
open
});
const setSurfaceRef = useCallback(
(node: HTMLDivElement | null): void => {
localSurfaceRef.current = node;
assignRef(surfaceRef, node);
if (typeof forwardedRef === "function") {
forwardedRef(node);
} else if (forwardedRef) {
forwardedRef.current = node;
}
},
[forwardedRef, surfaceRef]
);
useEffect(() => {
if (!open || !onDismiss) {
return;
}
const isInsideMenu = (target: EventTarget | null): boolean => {
if (!(target instanceof Node)) {
return false;
}
const boundaryRef = dismissBoundaryRef ?? anchorRef;
return Boolean(
boundaryRef.current?.contains(target) ||
localSurfaceRef.current?.contains(target)
);
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || event.defaultPrevented) {
return;
}
event.preventDefault();
onDismiss();
};
const handlePointerDown = (event: PointerEvent): void => {
if (isInsideMenu(event.target)) {
return;
}
onDismiss();
};
const handleFocusIn = (event: FocusEvent): void => {
if (isInsideMenu(event.target)) {
return;
}
onDismiss();
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("focusin", handleFocusIn, true);
window.addEventListener("blur", onDismiss);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("focusin", handleFocusIn, true);
window.removeEventListener("blur", onDismiss);
};
}, [anchorRef, dismissBoundaryRef, onDismiss, open]);
const style = useMemo<CSSProperties>(() => {
const baseStyle: CSSProperties = {
position: "fixed",
left: `${frame?.left ?? 0}px`,
width: `${frame?.width ?? 0}px`,
maxWidth: `${frame?.width ?? 0}px`,
overflow: "hidden",
zIndex: frame?.zIndex ?? "var(--z-popover)"
};
if (placement === "fixed-height") {
return {
...baseStyle,
top: `${frame?.top ?? 0}px`,
minHeight: `${frame?.minHeight ?? COMPOSER_MENU_MIN_HEIGHT_PX}px`,
height: `${frame?.height ?? maxHeight}px`,
maxHeight: `${maxHeight}px`
};
}
return {
...baseStyle,
bottom: `${frame?.bottom ?? 0}px`,
maxHeight: `${frame?.height ?? maxHeight}px`
};
}, [frame, maxHeight, placement]);
if (!open || typeof document === "undefined" || !document.body) {
return null;
}
const portalTarget =
frame?.portalTarget ??
(anchorRef.current
? resolveComposerPortalTarget(anchorRef.current)
: document.body);
return createPortal(
<div
data-testid={testId}
ref={setSurfaceRef}
className={cn(
"nodrag isolate rounded-[12px] border border-hairline bg-background-fronted p-[4px] text-foreground shadow-[var(--tsh-shell-shadow)] [-webkit-app-region:no-drag]",
"overflow-hidden",
className
)}
style={style}
>
{contentClassName ? (
<div className={contentClassName}>{children}</div>
) : (
children
)}
</div>,
portalTarget
);
});