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

517 lines
16 KiB
TypeScript

import {
useCallback,
useEffect,
useRef,
type Dispatch,
type RefObject,
type SetStateAction
} from "react";
import {
createMentionPaletteStateAdapter,
makeAtPanelKeyDown
} from "@tutti-os/ui-rich-text/at-panel";
import type {
AgentComposerDraft,
AgentGUIComposerSettingsVM
} from "../model/agentGuiNodeTypes";
import type { AgentRichTextEditorHandle } from "../agentRichText/AgentRichTextEditor";
import {
exitAgentFileMentionSuggestion,
type AgentContextMentionItem,
type AgentFileMentionSuggestionState
} from "../agentRichText/agentFileMentionExtension";
import { isAgentRichTextImeComposing } from "../agentRichText/agentRichTextIme";
import { AGENT_MENTION_FILTER_TAB_ORDER } from "../agentMentionSearchHelpers";
import {
agentMentionItemKey,
isAgentMentionItemDisabled
} from "../AgentFileMentionPalette";
import { MENTION_PALETTE_DISMISS_INTERACTION_SELECTOR } from "./AgentComposerChrome";
import { updateAgentComposerDraft } from "../model/agentComposerDraft";
import {
type AgentMentionFilterId,
type AgentMentionGroupId,
type AgentMentionSearchState,
AgentMentionSearchController
} from "../AgentMentionSearchController";
interface Input {
workspaceId: string;
currentUserId?: string | null;
selectedProjectPath: string;
selectedProjectSectionKey: string;
draftContent: AgentComposerDraft;
fileMentionSuggestion: AgentFileMentionSuggestionState | null;
setFileMentionSuggestion: Dispatch<
SetStateAction<AgentFileMentionSuggestionState | null>
>;
mentionControllerRef: RefObject<AgentMentionSearchController | null>;
editorHandleRef: RefObject<AgentRichTextEditorHandle | null>;
draftPromptRef: RefObject<string>;
setPaletteDraftPrompt: Dispatch<SetStateAction<string>>;
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
onDraftContentChange: (draft: AgentComposerDraft) => void;
showFileMentionPalette: boolean;
mentionHighlightedKey: string | null;
mentionSearchState: AgentMentionSearchState;
setMentionHighlightedKey: Dispatch<SetStateAction<string | null>>;
setShouldCenterMentionHighlight: Dispatch<SetStateAction<boolean>>;
setShouldResetMentionHighlightToFilter: Dispatch<SetStateAction<boolean>>;
autoMentionHighlightedKeyRef: RefObject<string | null>;
composerSettings: AgentGUIComposerSettingsVM;
isSendingTurn: boolean;
isSubmittingPrompt: boolean;
showStopButton: boolean;
isActive: boolean;
onSettingsChange: (settings: { planMode?: boolean }) => void;
handleSlashPaletteKeyDown: (event: KeyboardEvent) => boolean;
handleSlashCommandMenuKeyDown: (event: KeyboardEvent) => boolean;
showPalette: boolean;
workspaceReferencePickerOpen: boolean;
composerRef: RefObject<HTMLFormElement | null>;
paletteContentRef: RefObject<HTMLDivElement | null>;
shouldCenterMentionHighlight: boolean;
}
function useStableEventCallback<Args extends unknown[], Result>(
callback: (...args: Args) => Result
): (...args: Args) => Result {
const callbackRef = useRef(callback);
callbackRef.current = callback;
return useCallback((...args: Args) => callbackRef.current(...args), []);
}
export function useComposerMentionActions(input: Input) {
const {
workspaceId,
currentUserId,
selectedProjectPath,
selectedProjectSectionKey,
draftContent,
fileMentionSuggestion,
setFileMentionSuggestion,
mentionControllerRef,
editorHandleRef,
draftPromptRef,
setPaletteDraftPrompt,
setIsPaletteOpen,
onDraftContentChange,
showFileMentionPalette,
mentionHighlightedKey,
mentionSearchState,
setMentionHighlightedKey,
setShouldCenterMentionHighlight,
setShouldResetMentionHighlightToFilter,
autoMentionHighlightedKeyRef,
composerSettings,
isSendingTurn,
isSubmittingPrompt,
showStopButton,
isActive,
onSettingsChange,
handleSlashPaletteKeyDown,
handleSlashCommandMenuKeyDown,
showPalette,
workspaceReferencePickerOpen,
composerRef,
paletteContentRef,
shouldCenterMentionHighlight
} = input;
const replaceActiveFileMentionTrigger = useCallback(
(replacement: string): boolean => {
if (!fileMentionSuggestion) {
return false;
}
const triggerLength = Math.max(
fileMentionSuggestion.range.to - fileMentionSuggestion.range.from,
fileMentionSuggestion.text.length
);
const nextDraft =
triggerLength > 0
? editorHandleRef.current?.replaceTextBeforeSelection(
triggerLength,
replacement
)
: null;
if (nextDraft === null || nextDraft === undefined) {
return false;
}
draftPromptRef.current = nextDraft;
setPaletteDraftPrompt(nextDraft);
onDraftContentChange(
updateAgentComposerDraft(draftContent, { prompt: nextDraft })
);
return true;
},
[draftContent, fileMentionSuggestion, onDraftContentChange]
);
const navigateIntoFileMentionItem = useCallback(
(item: AgentContextMentionItem): boolean => {
if (
item.kind === "file" &&
item.mentionNavigation === "workspace-folder" &&
fileMentionSuggestion?.query
) {
if (!replaceActiveFileMentionTrigger("@")) {
return false;
}
mentionControllerRef.current?.updateQuery({
workspaceId,
currentUserId,
query: "",
sectionKey: selectedProjectSectionKey || null,
sessionCwd: selectedProjectPath || null
});
}
return (
mentionControllerRef.current?.selectFileMentionNavigationItem(item) ??
false
);
},
[
currentUserId,
fileMentionSuggestion,
replaceActiveFileMentionTrigger,
selectedProjectPath,
selectedProjectSectionKey,
workspaceId
]
);
const selectFileMention = useCallback(
(entry: AgentContextMentionItem): void => {
if (entry.kind === "file" && entry.mentionNavigation) {
navigateIntoFileMentionItem(entry);
return;
}
fileMentionSuggestion?.command(entry);
if (fileMentionSuggestion) {
exitAgentFileMentionSuggestion(fileMentionSuggestion.editor);
}
mentionControllerRef.current?.close();
setFileMentionSuggestion(null);
setIsPaletteOpen(false);
},
[fileMentionSuggestion, navigateIntoFileMentionItem]
);
const closeFileMentionPalette = useCallback((): void => {
if (fileMentionSuggestion) {
exitAgentFileMentionSuggestion(fileMentionSuggestion.editor);
}
mentionControllerRef.current?.close();
setFileMentionSuggestion(null);
setIsPaletteOpen(false);
}, [fileMentionSuggestion]);
const clearActiveFileMentionTrigger = useCallback((): void => {
replaceActiveFileMentionTrigger("");
}, [replaceActiveFileMentionTrigger]);
const closeOpenPalette = useCallback((): void => {
if (showFileMentionPalette) {
closeFileMentionPalette();
return;
}
setIsPaletteOpen(false);
}, [closeFileMentionPalette, showFileMentionPalette]);
const createFileMentionPaletteAdapter = useCallback(
(highlightedKey: string | null = mentionHighlightedKey) =>
createMentionPaletteStateAdapter({
state: mentionSearchState,
highlightedKey,
categoryCycleOrder:
mentionSearchState.mode === "browse"
? mentionSearchState.categories
: AGENT_MENTION_FILTER_TAB_ORDER,
getItemKey: agentMentionItemKey,
isItemDisabled: isAgentMentionItemDisabled,
callbacks: {
onHighlightChange: (key) => {
setShouldCenterMentionHighlight(true);
autoMentionHighlightedKeyRef.current = null;
setMentionHighlightedKey(key);
},
onActiveCategoryIdChange: (categoryId) => {
mentionControllerRef.current?.setFilter(
categoryId as AgentMentionFilterId
);
setShouldResetMentionHighlightToFilter(true);
setShouldCenterMentionHighlight(false);
},
onExpandGroup: (groupId) => {
mentionControllerRef.current?.expandGroup(
groupId as AgentMentionGroupId
);
},
onSelectItem: selectFileMention
}
}),
[mentionHighlightedKey, mentionSearchState, selectFileMention]
);
const moveFileMentionSelection = useCallback(
(delta: 1 | -1): void => {
createFileMentionPaletteAdapter().moveSelection(delta);
},
[createFileMentionPaletteAdapter]
);
const handleMentionHighlightChange = useCallback((key: string): void => {
autoMentionHighlightedKeyRef.current = null;
setMentionHighlightedKey(key);
}, []);
const cycleFileMentionFilter = useCallback(
(delta: 1 | -1 = 1): void => {
createFileMentionPaletteAdapter().cycleCategory(delta);
},
[createFileMentionPaletteAdapter]
);
const navigateFileMentionHierarchy = useCallback(
(delta: 1 | -1): boolean => {
if (delta === -1) {
return mentionControllerRef.current?.exitFileMentionBrowse() ?? false;
}
const item = createFileMentionPaletteAdapter().selectedItem;
if (!item || item.kind !== "file") {
return false;
}
if (
item.mentionNavigation !== "agent-generated-folder" &&
item.mentionNavigation !== "workspace-folder"
) {
return false;
}
return navigateIntoFileMentionItem(item);
},
[createFileMentionPaletteAdapter, navigateIntoFileMentionItem]
);
const handleFileMentionKeyDown = useCallback(
(event: KeyboardEvent): boolean => {
if (!showFileMentionPalette) {
return false;
}
return makeAtPanelKeyDown({
close: closeFileMentionPalette,
commitSelection: () => {
// No highlighted/committable entry (e.g. the search has zero
// results): Enter has nothing to select, so treat it as
// dismissing the empty panel instead of a silent no-op. This
// matches Escape's behavior and mirrors the "clear the active
// mention context" contract — a second Enter afterwards then
// falls through to the normal submit handler.
const result = createFileMentionPaletteAdapter().commitHighlighted();
if (result.type === "none") {
closeFileMentionPalette();
}
},
cycleFilter: cycleFileMentionFilter,
moveSelection: moveFileMentionSelection,
navigateHierarchy: navigateFileMentionHierarchy
})(event);
},
[
closeFileMentionPalette,
createFileMentionPaletteAdapter,
cycleFileMentionFilter,
moveFileMentionSelection,
navigateFileMentionHierarchy,
showFileMentionPalette
]
);
// Shift+Tab toggles descriptor-advertised plan mode.
const handlePlanModeToggleKeyDown = useCallback(
(event: KeyboardEvent): boolean => {
if (
event.key !== "Tab" ||
!event.shiftKey ||
event.metaKey ||
event.ctrlKey ||
event.altKey
) {
return false;
}
if (
!composerSettings.supportsPlanMode ||
isSendingTurn ||
isSubmittingPrompt ||
showStopButton ||
composerSettings.isSettingsLoading
) {
return false;
}
event.preventDefault();
onSettingsChange({ planMode: !composerSettings.draftSettings.planMode });
return true;
},
[
composerSettings.draftSettings.planMode,
composerSettings.isSettingsLoading,
composerSettings.supportsPlanMode,
onSettingsChange,
isSendingTurn,
isSubmittingPrompt,
showStopButton
]
);
const handlePaletteKeyDown = useStableEventCallback(
(event: KeyboardEvent): boolean =>
handleFileMentionKeyDown(event) ||
handleSlashPaletteKeyDown(event) ||
handleSlashCommandMenuKeyDown(event) ||
handlePlanModeToggleKeyDown(event)
);
useEffect(() => {
if (!showPalette || workspaceReferencePickerOpen) {
return;
}
const handleDocumentKeyDown = (event: KeyboardEvent): void => {
const eventTarget = event.target;
if (!(eventTarget instanceof Node)) {
return;
}
const isComposerEvent =
composerRef.current?.contains(eventTarget) ?? false;
const isPaletteEvent =
paletteContentRef.current?.contains(eventTarget) ?? false;
if (!isComposerEvent && !isPaletteEvent) {
return;
}
if (isAgentRichTextImeComposing(event)) {
return;
}
if (handlePaletteKeyDown(event)) {
event.stopPropagation();
}
};
document.addEventListener("keydown", handleDocumentKeyDown, {
capture: true
});
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown, {
capture: true
});
};
}, [handlePaletteKeyDown, showPalette, workspaceReferencePickerOpen]);
const handleFileMentionSuggestionChange = useCallback(
(state: AgentFileMentionSuggestionState | null): void => {
setFileMentionSuggestion(state);
if (!state) {
mentionControllerRef.current?.close();
return;
}
setIsPaletteOpen(true);
mentionControllerRef.current?.updateQuery({
workspaceId,
currentUserId,
query: state.query,
sectionKey: selectedProjectSectionKey || null,
sessionCwd: selectedProjectPath || null
});
},
[currentUserId, selectedProjectPath, selectedProjectSectionKey, workspaceId]
);
useEffect(() => {
if (!showFileMentionPalette && shouldCenterMentionHighlight) {
setShouldCenterMentionHighlight(false);
}
}, [shouldCenterMentionHighlight, showFileMentionPalette]);
useEffect(() => {
if (!isActive) {
closeFileMentionPalette();
return;
}
if (!showFileMentionPalette) {
return;
}
const handlePointerDown = (event: PointerEvent): void => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
const isInsideComposer = composerRef.current?.contains(target) ?? false;
const isInsidePalette =
paletteContentRef.current?.contains(target) ?? false;
if (
target instanceof Element &&
target.closest(MENTION_PALETTE_DISMISS_INTERACTION_SELECTOR)
) {
closeOpenPalette();
return;
}
if (!isInsideComposer && !isInsidePalette) {
closeOpenPalette();
}
};
const handleFocusIn = (event: FocusEvent): void => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
const isInsideComposer = composerRef.current?.contains(target) ?? false;
const isInsidePalette =
paletteContentRef.current?.contains(target) ?? false;
if (!isInsideComposer && !isInsidePalette) {
closeOpenPalette();
}
};
const handleWindowResize = (): void => {
closeOpenPalette();
};
const handleWindowBlur = (): void => {
closeOpenPalette();
};
document.addEventListener("pointerdown", handlePointerDown, {
capture: true
});
document.addEventListener("focusin", handleFocusIn, { capture: true });
window.addEventListener("resize", handleWindowResize);
window.addEventListener("blur", handleWindowBlur);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, {
capture: true
});
document.removeEventListener("focusin", handleFocusIn, {
capture: true
});
window.removeEventListener("resize", handleWindowResize);
window.removeEventListener("blur", handleWindowBlur);
};
}, [
closeFileMentionPalette,
closeOpenPalette,
composerRef,
paletteContentRef,
showFileMentionPalette,
isActive
]);
return {
clearActiveFileMentionTrigger,
closeFileMentionPalette,
cycleFileMentionFilter,
handleFileMentionSuggestionChange,
handleFileMentionKeyDown,
handlePaletteKeyDown,
handleMentionHighlightChange,
moveFileMentionSelection,
navigateFileMentionHierarchy,
navigateIntoFileMentionItem,
selectFileMention
};
}