Files
tutti/packages/agent/gui/workbench/tool-sidebar/useAgentToolSidebarController.ts
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

428 lines
13 KiB
TypeScript

import {
useCallback,
useReducer,
useRef,
useState,
type TransitionEvent
} from "react";
import {
agentToolEmptySidebarWidth,
createAgentToolSidebarState,
reduceAgentToolSidebarState,
type AgentToolPanelDefinition,
type AgentToolPanelId,
type AgentToolTab
} from "./model.ts";
import { useAgentToolSidebarLayout } from "./useAgentToolSidebarLayout.ts";
const collapsedToolActionsWidthPx = 132;
export function useAgentToolSidebarController({
containerWidth,
mainContentMinWidthPx,
panels,
resizeContainerContentWidth,
onActivePanelChange,
onLayoutWidthChange,
onPanelOpen,
onTabsChange,
onTabClose
}: {
containerWidth: number;
mainContentMinWidthPx?: number;
panels: readonly AgentToolPanelDefinition[];
resizeContainerContentWidth(
width: number,
animate?: boolean
): Promise<{ width: number }>;
onActivePanelChange?: (panel: AgentToolPanelId | null) => void;
onLayoutWidthChange?: (width: number) => void;
onPanelOpen?: (panel: AgentToolPanelId, resourceId?: string) => void;
onTabsChange?: (tabs: readonly AgentToolTab[]) => void;
onTabClose?: (tab: AgentToolTab) => void;
}) {
const panelIds = new Set(panels.map((panel) => panel.id));
const [state, dispatch] = useReducer(
reduceAgentToolSidebarState,
undefined,
createAgentToolSidebarState
);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isEmptySidebarClosing, setIsEmptySidebarClosing] = useState(false);
const [contentReadyTabIds, setContentReadyTabIds] = useState<string[]>([]);
const [toolActionsWidth, setToolActionsWidth] = useState(
collapsedToolActionsWidthPx
);
const activePanel = state.activePanel;
const activeTabId = state.activeTabId;
const fallbackPanel = panels[0]?.id ?? null;
const isEmptySidebar = isSidebarOpen && state.mountedTabs.length === 0;
const isEmptySidebarSurface =
(isSidebarOpen || isEmptySidebarClosing) && state.mountedTabs.length === 0;
const layoutPanel =
activePanel ?? (isEmptySidebarSurface ? fallbackPanel : null);
const isActivePanelContentReady =
activeTabId !== null && contentReadyTabIds.includes(activeTabId);
const shouldAnimateSidebarLayout =
state.mountedTabs.length === 0 || isActivePanelContentReady;
const shouldAnimateSidebarWidth = isEmptySidebarSurface;
const layout = useAgentToolSidebarLayout({
activePanel: layoutPanel,
activePanelPreferredWidth: isEmptySidebarSurface
? agentToolEmptySidebarWidth
: undefined,
containerWidth,
mainContentMinWidthPx,
resizeContainerContentWidth
});
const resizeAnimationFrameRef = useRef<number | null>(null);
const contentAnimationFrameIdsRef = useRef(new Set<number>());
const toolActionsResizeObserverRef = useRef<ResizeObserver | null>(null);
const scheduleResizeForPanel = useCallback(
(
panel: AgentToolPanelId | null,
preferredWidth?: number,
options?: { animateContainer?: boolean; preserveBaseline?: boolean }
) => {
if (resizeAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(resizeAnimationFrameRef.current);
}
resizeAnimationFrameRef.current = window.requestAnimationFrame(() => {
resizeAnimationFrameRef.current = null;
void layout.resizeForPanel(panel, preferredWidth, options);
});
},
[layout.resizeForPanel]
);
const showSidebar = useCallback(() => {
setIsEmptySidebarClosing(false);
setIsSidebarOpen(true);
}, []);
const collapseSidebarForContainerConstraint = useCallback(() => {
setIsEmptySidebarClosing(false);
setIsSidebarOpen(false);
dispatch({ type: "close" });
onActivePanelChange?.(null);
layout.resetPanelExpansion(null);
layout.resetContainerResizeBaseline();
}, [
layout.resetContainerResizeBaseline,
layout.resetPanelExpansion,
onActivePanelChange
]);
const markContentReady = useCallback((tabId: string) => {
if (prefersReducedMotion()) {
setContentReadyTabIds((current) =>
current.includes(tabId) ? current : [...current, tabId]
);
return;
}
let completedSynchronously = false;
let frameId = 0;
frameId = window.requestAnimationFrame(() => {
completedSynchronously = true;
contentAnimationFrameIdsRef.current.delete(frameId);
setContentReadyTabIds((current) =>
current.includes(tabId) ? current : [...current, tabId]
);
});
if (!completedSynchronously) {
contentAnimationFrameIdsRef.current.add(frameId);
}
}, []);
const closePanel = useCallback(() => {
setIsSidebarOpen(false);
dispatch({ type: "close" });
onActivePanelChange?.(null);
if (state.mountedTabs.length > 0 || prefersReducedMotion()) {
setIsEmptySidebarClosing(false);
scheduleResizeForPanel(null);
return;
}
setIsEmptySidebarClosing(true);
scheduleResizeForPanel(null, undefined, {
animateContainer: true,
preserveBaseline: true
});
}, [onActivePanelChange, scheduleResizeForPanel, state.mountedTabs.length]);
const openPanel = useCallback(
(panel: AgentToolPanelId, resourceId?: string) => {
if (!panelIds.has(panel)) return null;
showSidebar();
onPanelOpen?.(panel, resourceId);
const tabId = resolveToolTabId(state.mountedTabs, panel, resourceId);
const action = { panel, resourceId, tabId, type: "open-panel" } as const;
const nextState = reduceAgentToolSidebarState(state, action);
dispatch(action);
onActivePanelChange?.(panel);
if (nextState.mountedTabs !== state.mountedTabs) {
onTabsChange?.(nextState.mountedTabs);
}
markContentReady(tabId);
scheduleResizeForPanel(panel);
return tabId;
},
[
markContentReady,
onActivePanelChange,
onPanelOpen,
onTabsChange,
panelIds,
scheduleResizeForPanel,
showSidebar,
state
]
);
const addPanel = useCallback(
(panel: AgentToolPanelId, resourceId?: string) => {
if (!panelIds.has(panel)) return null;
showSidebar();
onPanelOpen?.(panel, resourceId);
const tabId = createToolTabId(panel);
const action = { panel, resourceId, tabId, type: "add-panel" } as const;
const nextState = reduceAgentToolSidebarState(state, action);
dispatch(action);
onActivePanelChange?.(panel);
onTabsChange?.(nextState.mountedTabs);
markContentReady(tabId);
scheduleResizeForPanel(panel);
return tabId;
},
[
markContentReady,
onActivePanelChange,
onPanelOpen,
onTabsChange,
panelIds,
scheduleResizeForPanel,
showSidebar,
state
]
);
const ensurePanel = useCallback(
(panel: AgentToolPanelId, resourceId?: string) => {
if (!panelIds.has(panel)) return null;
const existing = state.mountedTabs.find(
(tab) =>
tab.panel === panel && (tab.resourceId ?? undefined) === resourceId
);
if (existing) return existing.id;
const tabId = createToolTabId(panel);
const action = {
panel,
resourceId,
tabId,
type: "ensure-panel"
} as const;
const nextState = reduceAgentToolSidebarState(state, action);
dispatch(action);
onTabsChange?.(nextState.mountedTabs);
markContentReady(tabId);
return tabId;
},
[markContentReady, onTabsChange, panelIds, state]
);
const closePanelTab = useCallback(
(tabId: string) => {
const closingIndex = state.mountedTabs.findIndex(
(tab) => tab.id === tabId
);
const closingTab = state.mountedTabs[closingIndex];
if (!closingTab) return;
const action = { tabId, type: "close-tab" } as const;
const nextState = reduceAgentToolSidebarState(state, action);
setIsSidebarOpen(true);
dispatch(action);
onTabClose?.(closingTab);
onActivePanelChange?.(nextState.activePanel);
onTabsChange?.(nextState.mountedTabs);
if (nextState.activePanel === null) {
if (fallbackPanel) {
scheduleResizeForPanel(fallbackPanel, agentToolEmptySidebarWidth, {
animateContainer: !prefersReducedMotion()
});
}
return;
}
scheduleResizeForPanel(nextState.activePanel);
},
[
fallbackPanel,
onActivePanelChange,
onTabClose,
onTabsChange,
scheduleResizeForPanel,
state
]
);
const activatePanelTab = useCallback(
(tab: AgentToolTab) => {
onPanelOpen?.(tab.panel, tab.resourceId);
dispatch({ tabId: tab.id, type: "activate-tab" });
onActivePanelChange?.(tab.panel);
markContentReady(tab.id);
scheduleResizeForPanel(tab.panel);
},
[markContentReady, onActivePanelChange, onPanelOpen, scheduleResizeForPanel]
);
const toggleSidebar = useCallback(() => {
const nextPanel = activePanel ?? fallbackPanel;
if (isSidebarOpen) {
closePanel();
return;
}
if (!nextPanel) return;
showSidebar();
if (state.mountedTabs.length === 0) {
scheduleResizeForPanel(nextPanel, agentToolEmptySidebarWidth, {
animateContainer: !prefersReducedMotion()
});
return;
}
const tabId = resolveToolTabId(state.mountedTabs, nextPanel);
dispatch({ panel: nextPanel, tabId, type: "open-panel" });
onActivePanelChange?.(nextPanel);
markContentReady(tabId);
scheduleResizeForPanel(nextPanel);
}, [
activePanel,
closePanel,
fallbackPanel,
isSidebarOpen,
markContentReady,
onActivePanelChange,
scheduleResizeForPanel,
showSidebar,
state.mountedTabs
]);
const handleSidebarTransitionEnd = useCallback(
(event: TransitionEvent<HTMLElement>) => {
if (
event.currentTarget !== event.target ||
event.propertyName !== "width" ||
isSidebarOpen ||
!isEmptySidebarClosing
) {
return;
}
setIsEmptySidebarClosing(false);
layout.resetContainerResizeBaseline();
},
[isEmptySidebarClosing, isSidebarOpen, layout.resetContainerResizeBaseline]
);
const bindLayoutWidthProjection = useCallback(
(node: HTMLElement | null) => {
if (node) {
onLayoutWidthChange?.(
isSidebarOpen ? layout.activePanelLayoutWidth : 0
);
}
},
[isSidebarOpen, layout.activePanelLayoutWidth, onLayoutWidthChange]
);
const bindLifecycle = useCallback(
(node: HTMLDivElement | null) => {
if (node) return;
if (resizeAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(resizeAnimationFrameRef.current);
resizeAnimationFrameRef.current = null;
}
for (const frameId of contentAnimationFrameIdsRef.current) {
window.cancelAnimationFrame(frameId);
}
contentAnimationFrameIdsRef.current.clear();
toolActionsResizeObserverRef.current?.disconnect();
toolActionsResizeObserverRef.current = null;
layout.bindLayoutRoot(null);
},
[layout.bindLayoutRoot]
);
const measureToolActions = useCallback((node: HTMLDivElement | null) => {
toolActionsResizeObserverRef.current?.disconnect();
toolActionsResizeObserverRef.current = null;
if (!node) return;
const measure = (): void => {
const width = Math.ceil(node.getBoundingClientRect().width);
if (width > 0) setToolActionsWidth(width);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(node);
toolActionsResizeObserverRef.current = observer;
}, []);
return {
...layout,
activePanel,
activeTabId,
activatePanelTab,
addPanel,
bindLayoutWidthProjection,
bindLifecycle,
closePanel,
collapseSidebarForContainerConstraint,
closePanelTab,
contentReadyTabIds,
ensurePanel,
handleSidebarTransitionEnd,
isEmptySidebar,
isEmptySidebarClosing,
isSidebarOpen,
markContentReady,
measureToolActions,
mountedTabs: state.mountedTabs,
openPanel,
shouldAnimateSidebarLayout,
shouldAnimateSidebarWidth,
toolActionsWidth,
toggleSidebar
};
}
function resolveToolTabId(
tabs: readonly AgentToolTab[],
panel: AgentToolPanelId,
resourceId?: string
): string {
for (let index = tabs.length - 1; index >= 0; index -= 1) {
const tab = tabs[index];
if (
tab?.panel === panel &&
(resourceId === undefined || tab.resourceId === resourceId)
) {
return tab.id;
}
}
return createToolTabId(panel);
}
function createToolTabId(panel: AgentToolPanelId): string {
const instanceId =
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return `${panel}:${instanceId}`;
}
function prefersReducedMotion(): boolean {
return (
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
);
}