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

610 lines
20 KiB
TypeScript

import { useId, useRef, useState } from "react";
import {
Button,
ConfirmationDialog,
DialogFooter,
Input,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Spinner,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@tutti-os/ui-system";
import {
AddIcon,
ArrowLeftIcon,
ArrowRightIcon,
CheckIcon,
GripVerticalIcon,
MessageSquareTextIcon,
SearchIcon
} from "@tutti-os/ui-system/icons";
import { cn } from "../../../../app/renderer/lib/utils";
import styles from "../../AgentGUINode.styles";
import { AgentQuickPromptEditorDialog } from "./AgentQuickPromptEditorDialog";
import { AgentQuickPromptList } from "./AgentQuickPromptList";
import type { AgentQuickPromptTemplate } from "./agentQuickPromptLabels";
import type { AgentQuickPromptLibraryController } from "./useAgentQuickPromptLibrary";
import {
type PrimaryPointerAction,
usePrimaryPointerAction
} from "./usePrimaryPointerAction";
export function AgentQuickPromptPopover({
controller,
disabled
}: {
controller: AgentQuickPromptLibraryController;
disabled: boolean;
}): React.JSX.Element | null {
const searchRef = useRef<HTMLInputElement | null>(null);
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
const firstTemplateRef = useRef<HTMLButtonElement | null>(null);
const preserveExternalFocusRef = useRef(false);
const [view, setView] = useState<"prompts" | "templates">("prompts");
let activeView = view;
if (controller.insertionError && activeView !== "prompts") {
activeView = "prompts";
setView("prompts");
}
const [sortingState, setSortingState] = useState({
isPopoverOpen: controller.isPopoverOpen,
isSorting: false
});
let isSorting = sortingState.isSorting;
if (sortingState.isPopoverOpen !== controller.isPopoverOpen) {
isSorting = false;
setSortingState({
isPopoverOpen: controller.isPopoverOpen,
isSorting: false
});
}
const setIsSorting = (next: boolean): void => {
setSortingState({
isPopoverOpen: controller.isPopoverOpen,
isSorting: next
});
};
const titleId = useId();
const { labels, snapshot } = controller;
const templateEntryAction = usePrimaryPointerAction(() =>
setView("templates")
);
const returnToPromptsAction = usePrimaryPointerAction(() =>
setView("prompts")
);
const createAction = usePrimaryPointerAction(() => {
if (controller.isInteractionLocked) return;
preserveExternalFocusRef.current = true;
controller.openCreate();
});
const deleteCancelAction = usePrimaryPointerAction(controller.closeDialog);
const deleteConfirmAction = usePrimaryPointerAction(() => {
void controller.submitDelete();
});
const startSortingAction = usePrimaryPointerAction(() => setIsSorting(true));
const finishSortingAction = usePrimaryPointerAction(() =>
setIsSorting(false)
);
if (!controller.capabilityAvailable) {
return null;
}
const focusRow = (index: number): void => {
const prompt = controller.filteredPrompts[index];
if (prompt) rowRefs.current.get(prompt.id)?.focus();
};
const errorLabel =
controller.mutationError === "conflict"
? labels.conflict
: controller.mutationError === "generic"
? labels.mutationError
: null;
const requestTemplate = (template: AgentQuickPromptTemplate): void => {
preserveExternalFocusRef.current = true;
controller.openCreate(
{ title: template.title, content: template.content },
{
insertIntoComposerAfterSave: true,
usagePromptType: "recommended_template"
}
);
};
const isTemplateView = activeView === "templates";
const reorderDisabledMessage = resolveReorderDisabledMessage(controller);
const startSortingButton = (
<Button
disabled={reorderDisabledMessage !== null}
size="sm"
type="button"
variant="ghost"
{...startSortingAction}
>
<GripVerticalIcon data-icon="inline-start" />
{labels.startSorting}
</Button>
);
return (
<>
<Popover
modal={false}
open={controller.isPopoverOpen}
onOpenChange={(open) => {
if (!open) {
setView("prompts");
setIsSorting(false);
}
controller.setPopoverOpen(open);
}}
>
<TooltipProvider delayDuration={120}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<PopoverTrigger asChild>
<Button
aria-label={labels.triggerTooltip}
className={cn(
styles.composerMenuTrigger,
"w-auto max-w-[180px] !gap-1.5 focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[var(--border-focus)]"
)}
disabled={disabled}
size="sm"
type="button"
variant="chrome"
>
<MessageSquareTextIcon
className="size-4"
data-icon="inline-start"
/>
<span className="hidden min-[900px]:inline">
{labels.trigger}
</span>
</Button>
</PopoverTrigger>
</span>
</TooltipTrigger>
<TooltipContent side="top">{labels.triggerTooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent
aria-labelledby={titleId}
align="start"
className="h-[420px] max-h-[var(--radix-popover-content-available-height)] w-[400px] gap-0 overflow-hidden p-0"
side="top"
sideOffset={8}
onOpenAutoFocus={(event) => {
event.preventDefault();
window.requestAnimationFrame(() => {
if (isTemplateView) {
firstTemplateRef.current?.focus();
return;
}
const selectedId = controller.selectedPrompt?.id;
const selectedRow = selectedId
? rowRefs.current.get(selectedId)
: null;
(selectedRow ?? searchRef.current)?.focus();
});
}}
onCloseAutoFocus={(event) => {
if (preserveExternalFocusRef.current) event.preventDefault();
preserveExternalFocusRef.current = false;
}}
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--border-1)] px-3 py-2.5">
<h2
id={titleId}
className="text-[14px] font-medium text-[var(--text-primary)]"
>
{isTemplateView ? labels.recommendedTemplatesTitle : labels.title}
</h2>
{isTemplateView ? (
<Button
size="sm"
type="button"
variant="ghost"
{...returnToPromptsAction}
>
<ArrowLeftIcon data-icon="inline-start" />
{labels.returnToPrompts}
</Button>
) : (
<div className="flex items-center gap-1">
{isSorting ? (
<Button
disabled={controller.isInteractionLocked}
size="sm"
type="button"
variant="ghost"
{...finishSortingAction}
>
<CheckIcon data-icon="inline-start" />
{labels.finishSorting}
</Button>
) : (
<>
{reorderDisabledMessage ? (
<TooltipProvider delayDuration={120}>
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={reorderDisabledMessage}
className="inline-flex"
tabIndex={0}
>
{startSortingButton}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{reorderDisabledMessage}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
startSortingButton
)}
<Button
{...createAction}
disabled={controller.isInteractionLocked}
size="sm"
type="button"
variant="ghost"
>
<AddIcon data-icon="inline-start" />
{labels.add}
</Button>
</>
)}
</div>
)}
</div>
{!isTemplateView && !isSorting ? (
<div className="relative shrink-0 px-3 py-2.5">
<SearchIcon
aria-hidden
className="pointer-events-none absolute top-1/2 left-5 size-3.5 -translate-y-1/2 text-[var(--text-tertiary)]"
/>
<Input
ref={searchRef}
aria-label={labels.searchPlaceholder}
className="pl-8"
disabled={controller.isInteractionLocked}
placeholder={labels.searchPlaceholder}
value={controller.searchQuery}
onChange={(event) =>
controller.setSearchQuery(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
focusRow(0);
}
}}
/>
</div>
) : null}
{!isTemplateView &&
snapshot.status === "error" &&
snapshot.prompts.length > 0 ? (
<div
className="flex shrink-0 items-center justify-between gap-2 px-3 pb-2 text-[12px] text-[var(--state-danger)]"
role="alert"
>
<span>{labels.loadError}</span>
<Button
size="xs"
type="button"
variant="ghost"
onClick={controller.retry}
>
{labels.retry}
</Button>
</div>
) : null}
{!isTemplateView && controller.reorderError ? (
<div
className="flex shrink-0 items-center justify-between gap-2 px-3 pb-2 text-[12px] text-[var(--state-danger)]"
role="alert"
>
<span>
{controller.reorderError === "conflict"
? labels.reorderConflict
: labels.reorderError}
</span>
<Button
size="xs"
type="button"
variant="ghost"
onClick={controller.retry}
>
{labels.retry}
</Button>
</div>
) : null}
{!isTemplateView && controller.insertionError ? (
<div
className="shrink-0 px-3 pb-2 text-[12px] text-[var(--state-danger)]"
role="alert"
>
{labels.insertionError}
</div>
) : null}
<ScrollArea
className="min-h-0 flex-1"
viewportClassName={cn("px-2 pb-2", isSorting && "pt-2")}
viewportTestId="agent-quick-prompt-scroll-viewport"
>
{isTemplateView ? (
<RecommendedTemplateList
disabled={controller.isInteractionLocked}
firstTemplateRef={firstTemplateRef}
labels={labels}
onSelect={requestTemplate}
/>
) : snapshot.status === "loading" &&
snapshot.prompts.length === 0 ? (
<PromptState
icon={<Spinner size={16} />}
label={labels.loading}
/>
) : snapshot.status === "error" && snapshot.prompts.length === 0 ? (
<PromptState
label={labels.loadError}
action={
<Button
disabled={controller.isInteractionLocked}
size="sm"
type="button"
variant="ghost"
onClick={controller.retry}
>
{labels.retry}
</Button>
}
/>
) : snapshot.prompts.length === 0 &&
!controller.searchQuery.trim() ? (
<RecommendedTemplateList
disabled={controller.isInteractionLocked}
firstTemplateRef={firstTemplateRef}
labels={labels}
onSelect={requestTemplate}
/>
) : controller.filteredPrompts.length === 0 ? (
<PromptState label={labels.noResults} />
) : (
<div className="flex flex-col gap-0.5">
<AgentQuickPromptList
controller={controller}
isSorting={isSorting}
rowRefs={rowRefs}
onFocusRow={focusRow}
onDelete={(prompt) => {
preserveExternalFocusRef.current = true;
controller.deletePrompt(prompt);
}}
onEdit={(prompt) => {
preserveExternalFocusRef.current = true;
controller.openEdit(prompt);
}}
onSelect={(prompt) => {
if (isSorting) return;
preserveExternalFocusRef.current = true;
controller.selectPrompt(prompt);
}}
/>
{!isSorting ? (
<TemplateEntry
label={labels.createFromTemplate}
action={templateEntryAction}
disabled={controller.isInteractionLocked}
/>
) : null}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{controller.isEditorOpen ? (
<AgentQuickPromptEditorDialog controller={controller} />
) : null}
<ConfirmationDialog
cancelLabel={labels.cancel}
confirmBusy={controller.isDeleting}
confirmLabel={
controller.isDeleting ? labels.deleting : labels.deleteConfirm
}
description={
controller.promptToDelete
? labels.deleteDescription(controller.promptToDelete.title)
: undefined
}
footer={
<DialogFooter>
<Button
{...deleteCancelAction}
disabled={controller.isDeleting}
size="dialog"
type="button"
variant="ghost"
>
{labels.cancel}
</Button>
<Button
{...deleteConfirmAction}
disabled={controller.isDeleting}
size="dialog"
type="button"
variant="destructive"
>
{controller.isDeleting ? labels.deleting : labels.deleteConfirm}
</Button>
</DialogFooter>
}
onOpenChange={(open) => {
if (!open && !controller.isDeleting) controller.closeDialog();
}}
open={controller.mode === "delete"}
title={labels.deleteTitle}
tone="destructive"
>
{errorLabel ? (
<p className="text-[12px] text-[var(--state-danger)]" role="alert">
{errorLabel}
</p>
) : null}
</ConfirmationDialog>
</>
);
}
function resolveReorderDisabledMessage(
controller: AgentQuickPromptLibraryController
): string | null {
const { labels, snapshot } = controller;
if (!controller.reorderCapabilityAvailable) {
return labels.reorderDisabledUnsupported;
}
if (snapshot.status === "idle" || snapshot.status === "loading") {
return labels.loading;
}
if (snapshot.status === "error" && snapshot.prompts.length === 0) {
return labels.loadError;
}
if (controller.isInteractionLocked) {
return labels.reorderDisabledPending;
}
if (controller.searchQuery.trim()) {
return labels.reorderDisabledSearch;
}
if (controller.filteredPrompts.length < 2) {
return labels.reorderDisabledMinimum;
}
return null;
}
function RecommendedTemplateList({
disabled,
firstTemplateRef,
labels,
onSelect
}: {
disabled: boolean;
firstTemplateRef: React.RefObject<HTMLButtonElement | null>;
labels: AgentQuickPromptLibraryController["labels"];
onSelect: (template: AgentQuickPromptTemplate) => void;
}): React.JSX.Element {
return (
<section className="flex flex-col gap-1 px-1 pt-2">
<div className="px-2 pb-1">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
{labels.recommendedTemplatesTitle}
</h3>
<p className="pt-0.5 text-[12px] leading-[1.35] text-[var(--text-secondary)]">
{labels.recommendedTemplatesDescription}
</p>
</div>
{labels.recommendedTemplates.map((template, index) => (
<RecommendedTemplateButton
key={template.id}
disabled={disabled}
labels={labels}
selectionRef={index === 0 ? firstTemplateRef : undefined}
template={template}
onSelect={onSelect}
/>
))}
</section>
);
}
function RecommendedTemplateButton({
disabled,
labels,
onSelect,
selectionRef,
template
}: {
disabled: boolean;
labels: AgentQuickPromptLibraryController["labels"];
onSelect: (template: AgentQuickPromptTemplate) => void;
selectionRef?: React.RefObject<HTMLButtonElement | null>;
template: AgentQuickPromptTemplate;
}): React.JSX.Element {
const selectionAction = usePrimaryPointerAction(() => {
if (!disabled) onSelect(template);
});
return (
<Button
{...selectionAction}
ref={selectionRef}
className="h-auto w-full justify-between px-2 py-2 text-left whitespace-normal"
disabled={disabled}
type="button"
variant="ghost"
>
<span className="flex min-w-0 flex-col items-start gap-0.5">
<span className="w-full truncate font-medium text-[var(--text-primary)]">
{template.title}
</span>
<span className="line-clamp-2 w-full text-[12px] leading-[1.35] text-[var(--text-secondary)]">
{template.description}
</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-[12px] text-[var(--text-secondary)]">
{labels.useTemplate}
<ArrowRightIcon data-icon="inline-end" />
</span>
</Button>
);
}
function TemplateEntry({
action,
disabled,
label
}: {
action: PrimaryPointerAction;
disabled: boolean;
label: string;
}): React.JSX.Element {
return (
<Button
{...action}
className="mt-1 h-auto w-full justify-between px-2 py-2 text-left whitespace-normal"
disabled={disabled}
type="button"
variant="ghost"
>
<span className="font-medium">{label}</span>
<ArrowRightIcon data-icon="inline-end" />
</Button>
);
}
function PromptState({
action,
icon,
label
}: {
action?: React.ReactNode;
icon?: React.ReactNode;
label: string;
}): React.JSX.Element {
return (
<div className="flex min-h-[180px] flex-col items-center justify-center gap-2 px-4 text-center text-[13px] text-[var(--text-secondary)]">
{icon}
<span>{label}</span>
{action}
</div>
);
}