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
620 lines
19 KiB
TypeScript
620 lines
19 KiB
TypeScript
import {
|
|
Component,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type KeyboardEvent,
|
|
type PointerEvent
|
|
} from "react";
|
|
import { ChevronRight } from "lucide-react";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
useTextOverflow
|
|
} from "@tutti-os/ui-system";
|
|
import { extractPlainTextFromContent } from "@tutti-os/ui-rich-text";
|
|
import {
|
|
AgentMessageMarkdown,
|
|
type AgentMessageMarkdownWorkspaceAppIcon
|
|
} from "../../shared/AgentMessageMarkdown";
|
|
import type { AgentPromptContentBlock } from "../../shared/contracts/dto/agentSession";
|
|
import type {
|
|
AgentGUIQueueStatus,
|
|
AgentGUIQueuedPromptVM
|
|
} from "./model/agentGuiNodeTypes";
|
|
import {
|
|
agentPromptContentDisplayText,
|
|
agentPromptContentImageBlocks
|
|
} from "./model/agentComposerDraft";
|
|
import { ZoomableImage } from "../../app/renderer/components/ZoomableImage";
|
|
import { CanvasNodeGhostIconButton } from "../shared/CanvasNodeGhostIconButton";
|
|
import {
|
|
CanvasNodeGuideLinedIcon,
|
|
CanvasNodeMoreLinedIcon,
|
|
CanvasNodeTrashLinedIcon
|
|
} from "../shared/canvasNodeChromeIcons";
|
|
import styles from "./AgentGUINode.styles";
|
|
import {
|
|
QueuedPromptImageLoadOwner,
|
|
queuedPromptImageHasSafeRemoteUrl,
|
|
queuedPromptImageLoadRequestIdentity
|
|
} from "./queuedPromptImageLoadOwner";
|
|
import {
|
|
useOptionalAgentGUIRuntime,
|
|
type AgentGUIRuntime
|
|
} from "../../agentActivityRuntime";
|
|
|
|
const EMPTY_WORKSPACE_APP_ICONS: readonly AgentMessageMarkdownWorkspaceAppIcon[] =
|
|
[];
|
|
const QUEUED_PROMPT_OVERFLOW_DESCENDANTS =
|
|
'[data-workspace-agent-markdown="true"], .tsh-agent-object-token__main';
|
|
|
|
type QueuedPromptImageBlock = AgentPromptContentBlock & {
|
|
type: "image";
|
|
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
|
attachmentId?: string;
|
|
data?: string;
|
|
url?: string;
|
|
path?: string;
|
|
};
|
|
|
|
interface AgentQueuedPromptPanelProps {
|
|
queueStatus?: AgentGUIQueueStatus;
|
|
queuedPrompts: readonly AgentGUIQueuedPromptVM[];
|
|
drainingQueuedPromptId: string | null;
|
|
labels: {
|
|
queuedLabel: string;
|
|
queuePausedByUserLabel: string;
|
|
sendQueuedPromptNext: string;
|
|
editQueuedPrompt: string;
|
|
deleteQueuedPrompt: string;
|
|
queuedPromptMoreActions: string;
|
|
};
|
|
onSendQueuedPromptNext: (queuedPromptId: string) => void;
|
|
onRemoveQueuedPrompt: (queuedPromptId: string) => void;
|
|
onEditQueuedPrompt: (queuedPromptId: string) => void;
|
|
agentSessionId?: string | null;
|
|
onLinkClick?: (href: string) => void;
|
|
workspaceId?: string | null;
|
|
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
|
|
}
|
|
|
|
function queuedPromptImages(
|
|
queuedPrompt: AgentGUIQueuedPromptVM
|
|
): QueuedPromptImageBlock[] {
|
|
return agentPromptContentImageBlocks(queuedPrompt.content);
|
|
}
|
|
|
|
function queuedPromptImageDataUrl(
|
|
image: QueuedPromptImageBlock
|
|
): string | null {
|
|
const data = image.data?.trim() ?? "";
|
|
const mimeType = image.mimeType.trim();
|
|
if (!data || !mimeType) {
|
|
return null;
|
|
}
|
|
return data.startsWith("data:") ? data : `data:${mimeType};base64,${data}`;
|
|
}
|
|
|
|
function queuedPromptImageImmediateSource(
|
|
image: QueuedPromptImageBlock
|
|
): string | null {
|
|
return queuedPromptImageDataUrl(image);
|
|
}
|
|
|
|
function queuedPromptImageKey(
|
|
queuedPrompt: AgentGUIQueuedPromptVM,
|
|
image: QueuedPromptImageBlock,
|
|
index: number
|
|
): string {
|
|
return [
|
|
queuedPrompt.id,
|
|
index,
|
|
image.attachmentId?.trim() ?? "",
|
|
image.path?.trim() ?? "",
|
|
image.url?.trim() ?? "",
|
|
image.data?.trim() ?? "",
|
|
image.name?.trim() ?? "",
|
|
image.mimeType
|
|
].join(":");
|
|
}
|
|
|
|
interface AgentQueuedPromptImageProps {
|
|
agentSessionId?: string | null;
|
|
image: QueuedPromptImageBlock;
|
|
imageKey: string;
|
|
runtime: AgentGUIRuntime | null;
|
|
workspaceId?: string | null;
|
|
}
|
|
|
|
interface AgentQueuedPromptImageState {
|
|
requestIdentity: string;
|
|
source: string | null;
|
|
}
|
|
|
|
class AgentQueuedPromptImage extends Component<
|
|
AgentQueuedPromptImageProps,
|
|
AgentQueuedPromptImageState
|
|
> {
|
|
private loadOwner: QueuedPromptImageLoadOwner | null = null;
|
|
|
|
state: AgentQueuedPromptImageState = {
|
|
requestIdentity: "",
|
|
source: null
|
|
};
|
|
|
|
componentDidMount(): void {
|
|
this.syncLoadOwner();
|
|
}
|
|
|
|
componentDidUpdate(previousProps: AgentQueuedPromptImageProps): void {
|
|
if (
|
|
previousProps.runtime !== this.props.runtime ||
|
|
this.requestIdentity(previousProps) !== this.requestIdentity(this.props)
|
|
) {
|
|
this.syncLoadOwner();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount(): void {
|
|
this.loadOwner?.dispose();
|
|
this.loadOwner = null;
|
|
}
|
|
|
|
render(): React.JSX.Element | null {
|
|
const source =
|
|
queuedPromptImageImmediateSource(this.props.image) ??
|
|
(this.state.requestIdentity === this.requestIdentity(this.props)
|
|
? this.state.source
|
|
: null);
|
|
|
|
return source ? (
|
|
<ZoomableImage
|
|
alt={this.props.image.name?.trim() || ""}
|
|
className={styles.composerQueuedPromptImage}
|
|
draggable={false}
|
|
src={source}
|
|
wrapElement="span"
|
|
/>
|
|
) : null;
|
|
}
|
|
|
|
private requestIdentity(props: AgentQueuedPromptImageProps): string {
|
|
return queuedPromptImageLoadRequestIdentity({
|
|
agentSessionId: props.agentSessionId?.trim() ?? "",
|
|
attachmentId: props.image.attachmentId?.trim() ?? "",
|
|
imageKey: props.imageKey,
|
|
mimeType: props.image.mimeType,
|
|
name: props.image.name?.trim() ?? "",
|
|
path: props.image.path?.trim() ?? "",
|
|
remoteUrl: props.image.url?.trim() ?? "",
|
|
workspaceId: props.workspaceId?.trim() ?? ""
|
|
});
|
|
}
|
|
|
|
private syncLoadOwner(): void {
|
|
this.loadOwner?.dispose();
|
|
this.loadOwner = null;
|
|
const { image, runtime } = this.props;
|
|
const workspaceId = this.props.workspaceId?.trim() ?? "";
|
|
const agentSessionId = this.props.agentSessionId?.trim() ?? "";
|
|
const attachmentId = image.attachmentId?.trim() ?? "";
|
|
const path = image.path?.trim() ?? "";
|
|
const remoteUrl = image.url?.trim() ?? "";
|
|
const requestIdentity = this.requestIdentity(this.props);
|
|
if (
|
|
queuedPromptImageImmediateSource(image) ||
|
|
!runtime ||
|
|
!workspaceId ||
|
|
(!attachmentId && !path) ||
|
|
queuedPromptImageHasSafeRemoteUrl(remoteUrl) ||
|
|
(!runtime.readSessionAttachment && !runtime.readPromptAsset)
|
|
) {
|
|
this.setState({ requestIdentity, source: null });
|
|
return;
|
|
}
|
|
this.setState({ requestIdentity, source: null });
|
|
this.loadOwner = new QueuedPromptImageLoadOwner(
|
|
{
|
|
agentSessionId,
|
|
attachmentId,
|
|
imageKey: this.props.imageKey,
|
|
mimeType: image.mimeType,
|
|
name: image.name?.trim() ?? "",
|
|
path,
|
|
remoteUrl,
|
|
runtime,
|
|
workspaceId
|
|
},
|
|
(source) => this.setState({ requestIdentity, source })
|
|
);
|
|
this.loadOwner.start();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Text shown for a queued prompt. Falls back to the display prompt so a
|
|
* pasted-text-only queue entry (whose content is a structured pasted-text file
|
|
* block with no text) still renders its reference instead of appearing blank.
|
|
*/
|
|
function queuedPromptDisplayText(queuedPrompt: AgentGUIQueuedPromptVM): string {
|
|
const displayPrompt = queuedPrompt.displayPrompt?.trim();
|
|
if (displayPrompt) {
|
|
return displayPrompt;
|
|
}
|
|
const prompt = agentPromptContentDisplayText(queuedPrompt.content);
|
|
if (prompt) {
|
|
return prompt;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function queuedPromptTitle(queuedPrompt: AgentGUIQueuedPromptVM): string {
|
|
const prompt = queuedPromptDisplayText(queuedPrompt);
|
|
if (prompt) {
|
|
return extractPlainTextFromContent(prompt);
|
|
}
|
|
return queuedPromptImages(queuedPrompt)
|
|
.map((image) => image.name?.trim() ?? "")
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
}
|
|
|
|
interface AgentQueuedPromptTextProps {
|
|
displayText: string;
|
|
measurementRef?: React.RefObject<HTMLDivElement | null>;
|
|
onLinkClick?: (href: string) => void;
|
|
title: string;
|
|
workspaceAppIcons: readonly AgentMessageMarkdownWorkspaceAppIcon[];
|
|
}
|
|
|
|
function AgentQueuedPromptText({
|
|
displayText,
|
|
measurementRef,
|
|
onLinkClick,
|
|
title,
|
|
workspaceAppIcons
|
|
}: AgentQueuedPromptTextProps): React.JSX.Element {
|
|
const { ref: overflowRef, overflowing } = useTextOverflow<HTMLDivElement>(
|
|
displayText,
|
|
QUEUED_PROMPT_OVERFLOW_DESCENDANTS
|
|
);
|
|
const content = (
|
|
<div
|
|
ref={(element) => {
|
|
overflowRef.current = element;
|
|
if (measurementRef) measurementRef.current = element;
|
|
}}
|
|
className={styles.composerQueuedPromptText}
|
|
data-overflowing={overflowing ? "true" : "false"}
|
|
onClick={(event) => {
|
|
if (event.target instanceof Element && event.target.closest("a")) {
|
|
event.stopPropagation();
|
|
}
|
|
}}
|
|
>
|
|
<AgentMessageMarkdown
|
|
content={displayText}
|
|
className="agent-gui-node__composer-queued-prompt-markdown"
|
|
inline
|
|
onLinkClick={onLinkClick}
|
|
workspaceAppIcons={workspaceAppIcons}
|
|
/>
|
|
</div>
|
|
);
|
|
return (
|
|
<TooltipProvider delayDuration={200}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
|
{overflowing && title ? (
|
|
<TooltipContent className="max-w-[min(520px,calc(100vw-32px))] whitespace-pre-wrap text-left [overflow-wrap:anywhere]">
|
|
{title}
|
|
</TooltipContent>
|
|
) : null}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
export function AgentQueuedPromptPanel({
|
|
queueStatus = "active",
|
|
queuedPrompts,
|
|
drainingQueuedPromptId,
|
|
labels,
|
|
onSendQueuedPromptNext,
|
|
onRemoveQueuedPrompt,
|
|
onEditQueuedPrompt,
|
|
agentSessionId = null,
|
|
onLinkClick,
|
|
workspaceId = null,
|
|
workspaceAppIcons = EMPTY_WORKSPACE_APP_ICONS
|
|
}: AgentQueuedPromptPanelProps): React.JSX.Element {
|
|
"use memo";
|
|
const runtime = useOptionalAgentGUIRuntime();
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const singlePromptTextRef = useRef<HTMLDivElement | null>(null);
|
|
const queuedPromptListRef = useRef<HTMLDivElement | null>(null);
|
|
const pointerHandledEditPromptIdRef = useRef<string | null>(null);
|
|
const [isSinglePromptOverflowing, setIsSinglePromptOverflowing] =
|
|
useState(false);
|
|
const [expandedListMaxHeightPx, setExpandedListMaxHeightPx] = useState(280);
|
|
const singlePromptHasImages =
|
|
queuedPrompts.length === 1 &&
|
|
queuedPromptImages(queuedPrompts[0]!).length > 0;
|
|
const canExpand =
|
|
queuedPrompts.length > 1 ||
|
|
singlePromptHasImages ||
|
|
isSinglePromptOverflowing;
|
|
const panelStyle = {
|
|
"--agent-gui-queued-prompt-expanded-height": `${expandedListMaxHeightPx}px`
|
|
} as CSSProperties &
|
|
Record<"--agent-gui-queued-prompt-expanded-height", string>;
|
|
const toggleExpanded = (): void => {
|
|
if (!canExpand) {
|
|
return;
|
|
}
|
|
setIsExpanded((current) => !current);
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
if (!canExpand) {
|
|
return;
|
|
}
|
|
if (event.key !== "Enter" && event.key !== " ") {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
toggleExpanded();
|
|
};
|
|
const editQueuedPrompt = (queuedPromptId: string): void => {
|
|
pointerHandledEditPromptIdRef.current = null;
|
|
onEditQueuedPrompt(queuedPromptId);
|
|
};
|
|
const handleEditQueuedPromptPointerDown = (
|
|
event: PointerEvent,
|
|
queuedPromptId: string
|
|
): void => {
|
|
if (event.button !== 0 || event.ctrlKey) {
|
|
return;
|
|
}
|
|
pointerHandledEditPromptIdRef.current = queuedPromptId;
|
|
onEditQueuedPrompt(queuedPromptId);
|
|
};
|
|
const handleEditQueuedPromptSelect = (queuedPromptId: string): void => {
|
|
if (pointerHandledEditPromptIdRef.current === queuedPromptId) {
|
|
pointerHandledEditPromptIdRef.current = null;
|
|
return;
|
|
}
|
|
editQueuedPrompt(queuedPromptId);
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
if (queuedPrompts.length !== 1) {
|
|
setIsSinglePromptOverflowing(false);
|
|
return;
|
|
}
|
|
|
|
const element = singlePromptTextRef.current;
|
|
if (!element) {
|
|
setIsSinglePromptOverflowing(false);
|
|
return;
|
|
}
|
|
|
|
const measure = (): void => {
|
|
setIsSinglePromptOverflowing(
|
|
element.scrollWidth > element.clientWidth + 1
|
|
);
|
|
};
|
|
|
|
measure();
|
|
const resizeObserver =
|
|
typeof ResizeObserver === "undefined"
|
|
? null
|
|
: new ResizeObserver(measure);
|
|
resizeObserver?.observe(element);
|
|
return () => {
|
|
resizeObserver?.disconnect();
|
|
};
|
|
}, [queuedPrompts]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!canExpand && isExpanded) {
|
|
setIsExpanded(false);
|
|
}
|
|
}, [canExpand, isExpanded]);
|
|
|
|
useLayoutEffect(() => {
|
|
const element = queuedPromptListRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const measure = (): void => {
|
|
const viewportCap =
|
|
typeof window === "undefined"
|
|
? 280
|
|
: Math.round(window.innerHeight * 0.38);
|
|
setExpandedListMaxHeightPx(Math.max(32, Math.min(280, viewportCap)));
|
|
};
|
|
|
|
measure();
|
|
const resizeObserver =
|
|
typeof ResizeObserver === "undefined"
|
|
? null
|
|
: new ResizeObserver(measure);
|
|
resizeObserver?.observe(element);
|
|
window.addEventListener("resize", measure);
|
|
return () => {
|
|
resizeObserver?.disconnect();
|
|
window.removeEventListener("resize", measure);
|
|
};
|
|
}, [queuedPrompts]);
|
|
|
|
return (
|
|
<div
|
|
className={styles.composerQueuedPromptPanel}
|
|
data-expanded={isExpanded ? "true" : "false"}
|
|
data-expandable={canExpand ? "true" : "false"}
|
|
data-queue-status={queueStatus}
|
|
style={panelStyle}
|
|
tabIndex={canExpand ? 0 : -1}
|
|
onClick={toggleExpanded}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<div className={styles.composerQueuedPromptHeader}>
|
|
<span className={styles.composerQueuedPromptLabel}>
|
|
{queueStatus === "paused_by_user"
|
|
? labels.queuePausedByUserLabel
|
|
: labels.queuedLabel}
|
|
</span>
|
|
<span className={styles.composerQueuedPromptCount}>
|
|
{queuedPrompts.length}
|
|
</span>
|
|
{canExpand ? (
|
|
<ChevronRight
|
|
aria-hidden="true"
|
|
className={styles.composerQueuedPromptExpandCue}
|
|
data-testid="agent-gui-composer-queued-prompt-expand-cue"
|
|
size={16}
|
|
strokeWidth={2}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
ref={queuedPromptListRef}
|
|
className={styles.composerQueuedPromptList}
|
|
>
|
|
{queuedPrompts.map((queuedPrompt) => {
|
|
const isDraining = queuedPrompt.id === drainingQueuedPromptId;
|
|
const images = queuedPromptImages(queuedPrompt);
|
|
const displayText = queuedPromptDisplayText(queuedPrompt);
|
|
const title = queuedPromptTitle(queuedPrompt);
|
|
return (
|
|
<div
|
|
key={queuedPrompt.id}
|
|
className={styles.composerQueuedPromptRow}
|
|
data-testid={`agent-gui-composer-queued-prompt-${queuedPrompt.id}`}
|
|
data-draining={isDraining ? "true" : "false"}
|
|
>
|
|
<div className={styles.composerQueuedPromptMain}>
|
|
<div className={styles.composerQueuedPromptBody}>
|
|
{displayText ? (
|
|
<AgentQueuedPromptText
|
|
displayText={displayText}
|
|
measurementRef={
|
|
queuedPrompts.length === 1
|
|
? singlePromptTextRef
|
|
: undefined
|
|
}
|
|
onLinkClick={onLinkClick}
|
|
title={title}
|
|
workspaceAppIcons={workspaceAppIcons}
|
|
/>
|
|
) : null}
|
|
{images.length > 0 ? (
|
|
<div className={styles.composerQueuedPromptImages}>
|
|
{images.slice(0, 3).map((image, index) => {
|
|
const imageKey = queuedPromptImageKey(
|
|
queuedPrompt,
|
|
image,
|
|
index
|
|
);
|
|
return (
|
|
<AgentQueuedPromptImage
|
|
key={imageKey}
|
|
agentSessionId={agentSessionId}
|
|
image={image}
|
|
imageKey={imageKey}
|
|
runtime={runtime}
|
|
workspaceId={workspaceId}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className={styles.composerQueuedPromptActions}>
|
|
<TooltipProvider delayDuration={200}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<CanvasNodeGhostIconButton
|
|
aria-label={labels.sendQueuedPromptNext}
|
|
disabled={isDraining}
|
|
onClick={() => onSendQueuedPromptNext(queuedPrompt.id)}
|
|
>
|
|
<CanvasNodeGuideLinedIcon aria-hidden="true" />
|
|
</CanvasNodeGhostIconButton>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{labels.sendQueuedPromptNext}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<CanvasNodeGhostIconButton
|
|
aria-label={labels.deleteQueuedPrompt}
|
|
disabled={isDraining}
|
|
onClick={() => onRemoveQueuedPrompt(queuedPrompt.id)}
|
|
>
|
|
<CanvasNodeTrashLinedIcon aria-hidden="true" />
|
|
</CanvasNodeGhostIconButton>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{labels.deleteQueuedPrompt}</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<CanvasNodeGhostIconButton
|
|
aria-label={labels.queuedPromptMoreActions}
|
|
disabled={isDraining}
|
|
stopsEventPropagation={false}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<CanvasNodeMoreLinedIcon aria-hidden="true" />
|
|
</CanvasNodeGhostIconButton>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{labels.queuedPromptMoreActions}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className={styles.composerMenuContent}
|
|
sideOffset={8}
|
|
>
|
|
<DropdownMenuItem
|
|
className={styles.composerMenuItem}
|
|
disabled={isDraining}
|
|
onPointerDown={(event) => {
|
|
handleEditQueuedPromptPointerDown(
|
|
event,
|
|
queuedPrompt.id
|
|
);
|
|
}}
|
|
onSelect={() => {
|
|
handleEditQueuedPromptSelect(queuedPrompt.id);
|
|
}}
|
|
>
|
|
<span>{labels.editQueuedPrompt}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|