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

393 lines
13 KiB
TypeScript

import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
type CSSProperties,
type Dispatch,
type RefObject,
type SetStateAction
} from "react";
import type {
AgentComposerDraftImage,
AgentComposerDraftLargeText,
AgentGUIComposerSettingsVM
} from "../model/agentGuiNodeTypes";
const DOCK_COMPOSER_INPUT_MIN_HEIGHT = 56;
const DOCK_COMPOSER_TEXT_LINE_HEIGHT = 24;
const DOCK_COMPOSER_MAX_VISIBLE_TEXT_LINES = 3.5;
// The editor owns the 12px top inset so transformed rich-text nodes cannot be
// clipped by its scroll viewport. Only the bottom inset and borders remain
// outside editor.scrollHeight.
const DOCK_COMPOSER_INPUT_TEXT_MEASUREMENT_CHROME_HEIGHT = 14;
const DOCK_COMPOSER_INPUT_TEXT_MAX_HEIGHT_CHROME = 26;
const DOCK_COMPOSER_TEXT_VIEWPORT_MAX_HEIGHT =
DOCK_COMPOSER_TEXT_LINE_HEIGHT * DOCK_COMPOSER_MAX_VISIBLE_TEXT_LINES;
const DOCK_COMPOSER_INPUT_MAX_HEIGHT =
DOCK_COMPOSER_INPUT_TEXT_MAX_HEIGHT_CHROME +
DOCK_COMPOSER_TEXT_VIEWPORT_MAX_HEIGHT;
const DOCK_COMPOSER_INPUT_PADDING_BLOCK_HEIGHT = 24;
const PROMPT_TIP_CYCLE_STEP_MS = 5_200;
const COMPOSER_PALETTE_Z_INDEX = "var(--z-popover)";
const COMPOSER_ATTACHMENT_SELECTOR =
'[data-testid="agent-gui-composer-attachment-drafts"]';
export interface DockComposerMetrics {
attachmentHeight: number;
inputHeight: number;
inputMaxHeight: number;
textHeight: number;
}
export const INITIAL_DOCK_COMPOSER_METRICS: DockComposerMetrics = {
attachmentHeight: 0,
inputHeight: DOCK_COMPOSER_INPUT_MIN_HEIGHT,
inputMaxHeight: DOCK_COMPOSER_INPUT_MAX_HEIGHT,
textHeight: DOCK_COMPOSER_INPUT_MIN_HEIGHT
};
function hasInlineOverflow(element: HTMLElement | null): boolean {
return Boolean(element && element.scrollWidth > element.clientWidth);
}
interface UseComposerLayoutInput {
isActive: boolean;
isDockLayout: boolean;
isHeroLayout: boolean;
inputDisabled: boolean;
projectMissingProbeEnabled: boolean;
showFileMentionPalette: boolean;
showFloatingCommandMenu: boolean;
promptTips: readonly { id: string; label: string; prompt: string }[];
promptTipsPrefix: string;
composerSettings: AgentGUIComposerSettingsVM;
selectedProjectPath: string;
promptTipRef: RefObject<HTMLSpanElement | null>;
promptInputAreaRef: RefObject<HTMLDivElement | null>;
setIsPromptTipOverflowing: Dispatch<SetStateAction<boolean>>;
dockComposerMetrics: DockComposerMetrics;
setDockComposerMetrics: Dispatch<SetStateAction<DockComposerMetrics>>;
draftImages: AgentComposerDraftImage[];
draftLargeTexts: AgentComposerDraftLargeText[];
draftQuoteCount?: number;
}
export function useComposerLayout({
isActive,
isHeroLayout,
isDockLayout,
inputDisabled,
projectMissingProbeEnabled,
showFileMentionPalette,
showFloatingCommandMenu,
promptTips,
promptTipsPrefix,
composerSettings,
selectedProjectPath,
promptTipRef,
promptInputAreaRef,
setIsPromptTipOverflowing,
dockComposerMetrics,
setDockComposerMetrics,
draftImages,
draftLargeTexts,
draftQuoteCount = 0
}: UseComposerLayoutInput) {
const composerMeasurementFrameRef = useRef<number | null>(null);
const labels = { promptTipsPrefix };
const showEdgeGlow = isActive && isHeroLayout && !inputDisabled;
const showPromptTips = isHeroLayout && promptTips.length > 0;
const activePromptTip = showPromptTips ? (promptTips[0] ?? null) : null;
const showHeroProjectSelector = isHeroLayout;
const showProjectRow = isHeroLayout;
const showProjectMissingProbe =
projectMissingProbeEnabled &&
!showProjectRow &&
Boolean(composerSettings.projectLocked) &&
selectedProjectPath !== "" &&
// Remote runtimes (shared/cloud sandbox) run their cwd off the local
// filesystem, so the local existence check would always false-positive.
!composerSettings.projectPathIsRemote;
const activePromptTipId = activePromptTip?.id ?? null;
const activePromptTipText = activePromptTip
? `${labels.promptTipsPrefix}${activePromptTip.label} · ${activePromptTip.prompt}`
: "";
const shouldRotatePromptTips = isActive && promptTips.length > 1;
const rotatingPromptTips =
activePromptTip && shouldRotatePromptTips
? [...promptTips, activePromptTip]
: activePromptTip
? [activePromptTip]
: [];
const promptTipStyle = shouldRotatePromptTips
? ({
"--agent-gui-prompt-tip-count": promptTips.length,
"--agent-gui-prompt-tip-cycle-duration": `${
promptTips.length * PROMPT_TIP_CYCLE_STEP_MS
}ms`
} as CSSProperties)
: undefined;
useLayoutEffect(() => {
if (!activePromptTipId) {
setIsPromptTipOverflowing(false);
return;
}
const element = promptTipRef.current;
if (!element) {
setIsPromptTipOverflowing(false);
return;
}
const measure = (): void => {
setIsPromptTipOverflowing(hasInlineOverflow(element));
};
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(measure);
resizeObserver?.observe(element);
if (element.parentElement) {
resizeObserver?.observe(element.parentElement);
}
return () => {
resizeObserver?.disconnect();
};
}, [activePromptTipId, activePromptTipText]);
const measureDockComposer = useCallback((): void => {
composerMeasurementFrameRef.current = null;
if (!isDockLayout) {
setDockComposerMetrics((currentMetrics) =>
areDockComposerMetricsEqual(
currentMetrics,
INITIAL_DOCK_COMPOSER_METRICS
)
? currentMetrics
: INITIAL_DOCK_COMPOSER_METRICS
);
return;
}
const inputArea = promptInputAreaRef.current;
const editor = inputArea?.querySelector(
".agent-gui-node__composer-textarea"
);
if (!inputArea || !(editor instanceof HTMLElement)) {
setDockComposerMetrics((currentMetrics) =>
currentMetrics.inputHeight === DOCK_COMPOSER_INPUT_MIN_HEIGHT
? currentMetrics
: {
...currentMetrics,
inputHeight: DOCK_COMPOSER_INPUT_MIN_HEIGHT
}
);
return;
}
// Both attachment rows contribute to the composer height: images live in
// one container and files/pasted-text chips in another. Measuring only the
// image row clipped the taller pasted-text chip ("展示不全").
const attachmentAreas = inputArea.querySelectorAll(
COMPOSER_ATTACHMENT_SELECTOR
);
let attachmentHeight = 0;
attachmentAreas.forEach((area) => {
if (area instanceof HTMLElement) {
attachmentHeight += area.scrollHeight;
}
});
const editorContentHeight = readEditorIntrinsicContentHeight(editor);
const textHeight = Math.min(
DOCK_COMPOSER_INPUT_MAX_HEIGHT,
Math.max(
DOCK_COMPOSER_INPUT_MIN_HEIGHT,
editorContentHeight + DOCK_COMPOSER_INPUT_TEXT_MEASUREMENT_CHROME_HEIGHT
)
);
const attachmentChromeHeight =
attachmentHeight > 0 ? DOCK_COMPOSER_INPUT_PADDING_BLOCK_HEIGHT : 0;
const inputMaxHeight =
DOCK_COMPOSER_INPUT_MAX_HEIGHT +
Math.max(0, attachmentHeight) +
attachmentChromeHeight;
const measuredHeight =
attachmentHeight + textHeight + attachmentChromeHeight;
const inputHeight = Math.min(
inputMaxHeight,
Math.max(DOCK_COMPOSER_INPUT_MIN_HEIGHT, measuredHeight)
);
const nextMetrics: DockComposerMetrics = {
attachmentHeight,
inputHeight,
inputMaxHeight,
textHeight
};
setDockComposerMetrics((currentMetrics) =>
areDockComposerMetricsEqual(currentMetrics, nextMetrics)
? currentMetrics
: nextMetrics
);
}, [isDockLayout, promptInputAreaRef, setDockComposerMetrics]);
const invalidateComposerMeasurement = useCallback((): void => {
if (composerMeasurementFrameRef.current !== null) {
return;
}
if (typeof window.requestAnimationFrame !== "function") {
measureDockComposer();
return;
}
composerMeasurementFrameRef.current =
window.requestAnimationFrame(measureDockComposer);
}, [measureDockComposer]);
useLayoutEffect(() => {
if (!isDockLayout) {
invalidateComposerMeasurement();
return () => {
if (composerMeasurementFrameRef.current !== null) {
window.cancelAnimationFrame(composerMeasurementFrameRef.current);
composerMeasurementFrameRef.current = null;
}
};
}
const inputArea = promptInputAreaRef.current;
const widthHost = inputArea?.parentElement ?? null;
let observedWidth: number | null = null;
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver((entries) => {
let shouldInvalidate = false;
for (const entry of entries) {
if (entry.target === widthHost) {
const nextWidth = entry.contentRect.width;
if (nextWidth !== observedWidth) {
observedWidth = nextWidth;
shouldInvalidate = true;
}
} else {
shouldInvalidate = true;
}
}
if (shouldInvalidate) {
invalidateComposerMeasurement();
}
});
if (widthHost) {
resizeObserver?.observe(widthHost);
}
for (const attachmentArea of Array.from(
inputArea?.querySelectorAll(COMPOSER_ATTACHMENT_SELECTOR) ?? []
)) {
resizeObserver?.observe(attachmentArea);
}
invalidateComposerMeasurement();
return () => {
if (composerMeasurementFrameRef.current !== null) {
window.cancelAnimationFrame(composerMeasurementFrameRef.current);
composerMeasurementFrameRef.current = null;
}
resizeObserver?.disconnect();
};
}, [
draftImages.length,
draftLargeTexts.length,
draftQuoteCount,
invalidateComposerMeasurement,
isDockLayout,
promptInputAreaRef
]);
const composerStyle = useMemo<CSSProperties | undefined>(
() =>
!isDockLayout
? undefined
: ({
// The dock keeps only the collapsed 56px input row in flow; a
// growing draft overhangs upward past the composer's top edge.
// Floating panels anchored to that edge (queued prompts, the
// interactive prompt) read this var to stay above the grown input
// instead of being covered by it.
"--agent-gui-composer-input-overflow": `${Math.max(
0,
dockComposerMetrics.inputHeight - DOCK_COMPOSER_INPUT_MIN_HEIGHT
)}px`
} as CSSProperties),
[dockComposerMetrics.inputHeight, isDockLayout]
);
const inputShellStyle = useMemo<CSSProperties | undefined>(
() =>
showFileMentionPalette || showFloatingCommandMenu
? { zIndex: COMPOSER_PALETTE_Z_INDEX }
: undefined,
[showFileMentionPalette, showFloatingCommandMenu]
);
const promptInputAreaStyle = useMemo<CSSProperties | undefined>(
() =>
!isDockLayout
? undefined
: ({
"--agent-gui-composer-attachment-height": `${dockComposerMetrics.attachmentHeight}px`,
"--agent-gui-composer-input-height": `${dockComposerMetrics.inputHeight}px`,
"--agent-gui-composer-input-max-height": `${dockComposerMetrics.inputMaxHeight}px`,
"--agent-gui-composer-text-height": `${dockComposerMetrics.textHeight}px`,
"--agent-gui-composer-text-line-height": `${DOCK_COMPOSER_TEXT_LINE_HEIGHT}px`,
"--agent-gui-composer-text-max-visible-lines": `${DOCK_COMPOSER_MAX_VISIBLE_TEXT_LINES}`,
"--agent-gui-composer-text-viewport-height": `${DOCK_COMPOSER_TEXT_VIEWPORT_MAX_HEIGHT}px`
} as CSSProperties),
[dockComposerMetrics, isDockLayout]
);
return {
activePromptTip,
activePromptTipText,
composerStyle,
inputShellStyle,
promptInputAreaStyle,
promptTipStyle,
rotatingPromptTips,
invalidateComposerMeasurement,
showEdgeGlow,
showHeroProjectSelector,
showProjectMissingProbe,
showProjectRow
};
}
function readEditorIntrinsicContentHeight(editor: HTMLElement): number {
const firstBlock = editor.firstElementChild;
const lastBlock = editor.lastElementChild;
if (firstBlock instanceof HTMLElement && lastBlock instanceof HTMLElement) {
const firstRect = firstBlock.getBoundingClientRect();
const lastRect = lastBlock.getBoundingClientRect();
const contentHeight = lastRect.bottom - firstRect.top;
if (contentHeight > 0) {
const style = window.getComputedStyle(editor);
return (
contentHeight +
parseCssPixelValue(style.paddingTop) +
parseCssPixelValue(style.paddingBottom)
);
}
}
return editor.scrollHeight;
}
function parseCssPixelValue(value: string): number {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function areDockComposerMetricsEqual(
left: DockComposerMetrics,
right: DockComposerMetrics
): boolean {
return (
left.attachmentHeight === right.attachmentHeight &&
left.inputHeight === right.inputHeight &&
left.inputMaxHeight === right.inputMaxHeight &&
left.textHeight === right.textHeight
);
}