Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/view/AgentGUIConversationRailSectionHeader.tsx
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

528 lines
15 KiB
TypeScript

import { forwardRef, memo, useCallback, useState } from "react";
import { ChevronRight } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger
} from "@tutti-os/ui-system";
import {
BareIconButton,
type BareIconButtonProps
} from "@tutti-os/ui-system/components";
import {
CreateChatIcon,
FolderIcon,
FolderOpenLinedIcon,
MoreHorizontalIcon
} from "@tutti-os/ui-system/icons";
import type { ConversationSection } from "../agentGuiNodeViewConversation";
import styles from "../AgentGUINode.styles";
import type { AgentGUIConversationRailLabels } from "./agentGUIConversationRailLabels";
import {
useAgentGUIConversationRailSectionBatchDeletionDisabled,
useAgentGUIConversationRailSectionProjectActionLocked,
useAgentGUIConversationRailSectionProjectDragDisabled
} from "./agentGUIConversationRailSectionPresentationContext";
interface AgentGUIConversationRailSectionHeaderProps {
canCreateConversation: boolean;
createConversationDisabled: boolean;
createConversationLabel: string;
hasProjectId: boolean;
hasProjectPath: boolean;
isSectionCollapsed: boolean;
kind: ConversationSection["kind"];
labels: AgentGUIConversationRailLabels;
onCreateConversation: () => void;
onOpenProjectFiles?: (() => void) | null;
onProjectDragEnd: () => void;
onProjectDragStart: (event: React.DragEvent<HTMLElement>) => void;
onProjectMenuOpenChange: (open: boolean) => void;
onRemoveProject: () => void;
onRequestBatchDeletion: () => void;
onToggleCollapsed: () => void;
onToggleProjectPinned: () => void;
projectPinned: boolean;
projectId: string | null;
sectionLabel: string;
}
interface HeaderFrameProps {
children: React.ReactNode;
isProjectSection: boolean;
onProjectDragEnd: () => void;
onProjectDragStart: (event: React.DragEvent<HTMLElement>) => void;
}
function HeaderFrame({
children,
isProjectSection,
onProjectDragEnd,
onProjectDragStart
}: HeaderFrameProps): React.JSX.Element {
const projectDragDisabled =
useAgentGUIConversationRailSectionProjectDragDisabled();
return (
<div
className={styles.conversationSectionHeader}
draggable={isProjectSection && !projectDragDisabled}
onDragStart={onProjectDragStart}
onDragEnd={onProjectDragEnd}
>
{children}
</div>
);
}
interface HeaderIdentityProps {
isProjectSection: boolean;
isSectionCollapsed: boolean;
labels: AgentGUIConversationRailLabels;
onToggleCollapsed: () => void;
projectPinned: boolean;
sectionLabel: string;
}
const HeaderIdentity = memo(function HeaderIdentity({
isProjectSection,
isSectionCollapsed,
labels,
onToggleCollapsed,
projectPinned,
sectionLabel
}: HeaderIdentityProps): React.JSX.Element {
"use memo";
if (!isProjectSection) {
return (
<div className={styles.conversationSectionToggle}>
<span className={styles.conversationSectionLabel}>
<span>{sectionLabel}</span>
</span>
</div>
);
}
return (
<button
type="button"
className={styles.conversationSectionToggle}
aria-expanded={!isSectionCollapsed}
aria-label={
projectPinned
? labels.pinnedProjectAccessibleName(sectionLabel)
: sectionLabel
}
onClick={onToggleCollapsed}
>
<ChevronRight
aria-hidden="true"
className={styles.conversationSectionChevron}
/>
<span className={styles.conversationSectionLabel}>
{isSectionCollapsed ? (
<FolderIcon
aria-hidden="true"
className={styles.conversationSectionLabelIcon}
data-project-drag-icon="true"
/>
) : (
<FolderOpenLinedIcon
aria-hidden="true"
className={styles.conversationSectionLabelIcon}
data-project-drag-icon="true"
/>
)}
<span>{sectionLabel}</span>
</span>
</button>
);
});
interface CreateActionProps {
createConversationDisabled: boolean;
createConversationLabel: string;
onCreateConversation: () => void;
projectId: string | null;
}
const CreateAction = memo(function CreateAction({
createConversationDisabled,
createConversationLabel,
onCreateConversation,
projectId
}: CreateActionProps): React.JSX.Element {
"use memo";
const button = (
<BareIconButton
className={styles.conversationSectionMoreButton}
aria-label={createConversationLabel}
data-testid={
projectId ? `agent-gui-project-${projectId}-new-session` : undefined
}
size="sm"
title=""
disabled={createConversationDisabled}
onClick={onCreateConversation}
>
<CreateChatIcon aria-hidden="true" />
</BareIconButton>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={styles.conversationSectionActionTooltipWrap}>
{button}
</span>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={6}
className={styles.conversationSectionActionTooltip}
>
{createConversationLabel}
</TooltipContent>
</Tooltip>
);
});
interface ProjectMenuTriggerProps {
labels: AgentGUIConversationRailLabels;
}
type ProjectMenuButtonProps = Omit<
BareIconButtonProps,
"aria-label" | "children" | "size"
> & {
accessibleName: string;
};
const ProjectMenuButton = forwardRef<HTMLButtonElement, ProjectMenuButtonProps>(
function ProjectMenuButton(
{ accessibleName, disabled, ...props },
ref
): React.JSX.Element {
const projectActionLocked =
useAgentGUIConversationRailSectionProjectActionLocked();
return (
<BareIconButton
{...props}
ref={ref}
className={styles.conversationSectionMoreButton}
aria-label={accessibleName}
size="sm"
title=""
disabled={projectActionLocked || disabled}
>
<MoreHorizontalIcon aria-hidden="true" />
</BareIconButton>
);
}
);
ProjectMenuButton.displayName = "ProjectMenuButton";
function ProjectMenuTrigger({
labels
}: ProjectMenuTriggerProps): React.JSX.Element {
const accessibleName = labels.projectSectionMoreActions;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={styles.conversationSectionActionTooltipWrap}>
<DropdownMenuTrigger asChild>
<ProjectMenuButton accessibleName={accessibleName} />
</DropdownMenuTrigger>
</span>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={6}
className={styles.conversationSectionActionTooltip}
>
{accessibleName}
</TooltipContent>
</Tooltip>
);
}
interface ProjectMenuContentProps {
hasProjectId: boolean;
labels: AgentGUIConversationRailLabels;
onOpenProjectFiles?: (() => void) | null;
onRemoveProject: () => void;
onRequestBatchDeletion: () => void;
onToggleProjectPinned: () => void;
projectPinned: boolean;
}
function ProjectMenuContent({
hasProjectId,
labels,
onOpenProjectFiles,
onRemoveProject,
onRequestBatchDeletion,
onToggleProjectPinned,
projectPinned
}: ProjectMenuContentProps): React.JSX.Element {
const batchDeletionDisabled =
useAgentGUIConversationRailSectionBatchDeletionDisabled();
const projectActionLocked =
useAgentGUIConversationRailSectionProjectActionLocked();
return (
<DropdownMenuContent
align="end"
className={`${styles.composerMenuContent} nodrag [-webkit-app-region:no-drag]`}
sideOffset={6}
>
<DropdownMenuItem
className={`${styles.composerMenuItem} nodrag [-webkit-app-region:no-drag]`}
disabled={!onOpenProjectFiles}
onSelect={onOpenProjectFiles ?? undefined}
>
<span>{labels.projectSectionViewFiles}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={`${styles.composerMenuItem} nodrag [-webkit-app-region:no-drag]`}
disabled={!hasProjectId || projectActionLocked}
onSelect={onToggleProjectPinned}
>
<span>{projectPinned ? labels.unpinProject : labels.pinProject}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={`${styles.composerMenuItem} nodrag [-webkit-app-region:no-drag]`}
disabled={batchDeletionDisabled}
onSelect={onRequestBatchDeletion}
>
<span>{labels.batchDeleteProjectSessions}</span>
</DropdownMenuItem>
<DropdownMenuItem
className={`${styles.composerMenuItem} nodrag [-webkit-app-region:no-drag]`}
disabled={projectActionLocked}
onSelect={onRemoveProject}
>
<span>{labels.removeProject}</span>
</DropdownMenuItem>
</DropdownMenuContent>
);
}
interface ProjectMenuProps extends ProjectMenuContentProps {
onOpenChange: (open: boolean) => void;
open: boolean;
}
const ProjectMenu = memo(function ProjectMenu({
hasProjectId,
labels,
onOpenChange,
onOpenProjectFiles,
onRemoveProject,
onRequestBatchDeletion,
onToggleProjectPinned,
open,
projectPinned
}: ProjectMenuProps): React.JSX.Element {
"use memo";
return (
<DropdownMenu onOpenChange={onOpenChange}>
<ProjectMenuTrigger labels={labels} />
{open ? (
<ProjectMenuContent
hasProjectId={hasProjectId}
labels={labels}
onOpenProjectFiles={onOpenProjectFiles}
onRemoveProject={onRemoveProject}
onRequestBatchDeletion={onRequestBatchDeletion}
onToggleProjectPinned={onToggleProjectPinned}
projectPinned={projectPinned}
/>
) : null}
</DropdownMenu>
);
});
interface ConversationMenuTriggerProps {
labels: AgentGUIConversationRailLabels;
}
function ConversationMenuTrigger({
labels
}: ConversationMenuTriggerProps): React.JSX.Element {
const accessibleName = labels.conversationsSectionMoreActions;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={styles.conversationSectionActionTooltipWrap}>
<DropdownMenuTrigger asChild>
<BareIconButton
className={styles.conversationSectionMoreButton}
aria-label={accessibleName}
size="sm"
title=""
>
<MoreHorizontalIcon aria-hidden="true" />
</BareIconButton>
</DropdownMenuTrigger>
</span>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={6}
className={styles.conversationSectionActionTooltip}
>
{accessibleName}
</TooltipContent>
</Tooltip>
);
}
interface ConversationMenuContentProps {
labels: AgentGUIConversationRailLabels;
onRequestBatchDeletion: () => void;
}
function ConversationMenuContent({
labels,
onRequestBatchDeletion
}: ConversationMenuContentProps): React.JSX.Element {
const batchDeletionDisabled =
useAgentGUIConversationRailSectionBatchDeletionDisabled();
return (
<DropdownMenuContent
align="end"
className={`${styles.composerMenuContent} nodrag [-webkit-app-region:no-drag]`}
sideOffset={6}
>
<DropdownMenuItem
className={`${styles.composerMenuItem} nodrag [-webkit-app-region:no-drag]`}
disabled={batchDeletionDisabled}
onSelect={onRequestBatchDeletion}
>
<span>{labels.batchDeleteConversations}</span>
</DropdownMenuItem>
</DropdownMenuContent>
);
}
interface ConversationMenuProps extends ConversationMenuContentProps {
onOpenChange: (open: boolean) => void;
open: boolean;
}
function ConversationMenu({
labels,
onOpenChange,
onRequestBatchDeletion,
open
}: ConversationMenuProps): React.JSX.Element {
return (
<DropdownMenu onOpenChange={onOpenChange}>
<ConversationMenuTrigger labels={labels} />
{open ? (
<ConversationMenuContent
labels={labels}
onRequestBatchDeletion={onRequestBatchDeletion}
/>
) : null}
</DropdownMenu>
);
}
export const AgentGUIConversationRailSectionHeader = memo(
function AgentGUIConversationRailSectionHeader({
canCreateConversation,
createConversationDisabled,
createConversationLabel,
hasProjectId,
hasProjectPath,
isSectionCollapsed,
kind,
labels,
onCreateConversation,
onOpenProjectFiles,
onProjectDragEnd,
onProjectDragStart,
onProjectMenuOpenChange,
onRemoveProject,
onRequestBatchDeletion,
onToggleCollapsed,
onToggleProjectPinned,
projectPinned,
projectId,
sectionLabel
}: AgentGUIConversationRailSectionHeaderProps): React.JSX.Element {
"use memo";
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [conversationMenuOpen, setConversationMenuOpen] = useState(false);
const handleProjectMenuOpenChange = useCallback(
(open: boolean) => {
setProjectMenuOpen(open);
onProjectMenuOpenChange(open);
},
[onProjectMenuOpenChange]
);
const isProjectSection = kind === "project";
return (
<HeaderFrame
isProjectSection={isProjectSection}
onProjectDragEnd={onProjectDragEnd}
onProjectDragStart={onProjectDragStart}
>
<HeaderIdentity
isProjectSection={isProjectSection}
isSectionCollapsed={isSectionCollapsed}
labels={labels}
onToggleCollapsed={onToggleCollapsed}
projectPinned={projectPinned}
sectionLabel={sectionLabel}
/>
{canCreateConversation ? (
<div
className={styles.conversationSectionActions}
data-project-drag-block="true"
>
<CreateAction
createConversationDisabled={createConversationDisabled}
createConversationLabel={createConversationLabel}
onCreateConversation={onCreateConversation}
projectId={projectId}
/>
{hasProjectPath ? (
<ProjectMenu
hasProjectId={hasProjectId}
labels={labels}
onOpenChange={handleProjectMenuOpenChange}
onOpenProjectFiles={onOpenProjectFiles}
onRemoveProject={onRemoveProject}
onRequestBatchDeletion={onRequestBatchDeletion}
onToggleProjectPinned={onToggleProjectPinned}
open={projectMenuOpen}
projectPinned={projectPinned}
/>
) : null}
{!hasProjectPath && kind === "conversations" ? (
<ConversationMenu
labels={labels}
onOpenChange={setConversationMenuOpen}
onRequestBatchDeletion={onRequestBatchDeletion}
open={conversationMenuOpen}
/>
) : null}
</div>
) : null}
</HeaderFrame>
);
}
);