Files
kandev/apps/web/components/task/chat/entity-reference-menu.tsx
T
João Salavisa d3058276d7 feat(i18n): localize task chat surface (#2303)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-08-06 03:01:51 +02:00

159 lines
5.2 KiB
TypeScript

"use client";
import { IconBug, IconClipboardList, IconGitPullRequest, IconLink } from "@tabler/icons-react";
import type { EntityReferenceSearchError } from "@/hooks/use-entity-reference-search";
import type {
EntityReference,
EntityReferenceGroupStatus,
EntityReferenceSearchGroup,
} from "@/lib/types/entity-reference";
import {
selectableEntityReferences,
visibleEntityReferenceGroups,
} from "./entity-reference-groups";
import { PopupMenu, PopupMenuItem, useMenuItemRefs } from "./popup-menu";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
export {
selectableEntityReferences,
visibleEntityReferenceGroups,
} from "./entity-reference-groups";
type EntityReferenceMenuProps = {
isOpen: boolean;
clientRect?: (() => DOMRect | null) | null;
groups: EntityReferenceSearchGroup[];
query: string;
selectedIndex: number;
isSearching: boolean;
error: EntityReferenceSearchError | null;
onRetry: () => void;
onSelect: (reference: EntityReference) => void;
onClose: () => void;
setSelectedIndex: (index: number) => void;
};
// Catalog keys, not copy: this table is built at module load, so a `t()` here
// would freeze at the boot locale. Keys resolve at render.
const GROUP_STATUS_LABEL_KEYS: Partial<Record<EntityReferenceGroupStatus, string>> = {
unauthorized: "task:groupStatusUnauthorized",
rate_limited: "task:groupStatusRateLimited",
timeout: "task:groupStatusTimeout",
upstream_error: "task:groupStatusUpstreamError",
unsupported_scope: "task:groupStatusUnsupportedScope",
};
function groupStatusLabel(
translate: (key: string) => string,
status: EntityReferenceGroupStatus,
): string {
const key = GROUP_STATUS_LABEL_KEYS[status];
return key ? translate(key) : translate("task:providerUnavailable");
}
function entityReferenceIcon(kind: string) {
if (kind === "task") return <IconClipboardList className="h-4 w-4" />;
if (kind === "issue" || kind === "work_item") return <IconBug className="h-4 w-4" />;
if (kind === "pull_request" || kind === "merge_request") {
return <IconGitPullRequest className="h-4 w-4" />;
}
return <IconLink className="h-4 w-4" data-testid="entity-reference-generic-icon" />;
}
function entityReferenceEmptyState(
query: string,
isSearching: boolean,
error: EntityReferenceSearchError | null,
onRetry: () => void,
) {
if (!query.trim()) return t("task:typeToSearchWorkItems");
if (isSearching) return t("task:searchingWorkItems");
if (error) {
return (
<div className="flex min-h-11 items-center justify-between gap-3 px-3 text-xs">
<span className="text-muted-foreground">{error.message}</span>
<button
type="button"
className="min-h-11 shrink-0 cursor-pointer px-2 font-medium text-primary"
onClick={onRetry}
>
{t("task:retry")}
</button>
</div>
);
}
return t("task:noWorkItemsFound");
}
export function EntityReferenceMenu({
isOpen,
clientRect,
groups,
query,
selectedIndex,
isSearching,
error,
onRetry,
onSelect,
onClose,
setSelectedIndex,
}: EntityReferenceMenuProps) {
const { t } = useTranslation();
const { setItemRef } = useMenuItemRefs(selectedIndex);
const selectable = selectableEntityReferences(groups);
let itemIndex = 0;
const visibleGroups = visibleEntityReferenceGroups(groups);
const hasContent = visibleGroups.length > 0;
return (
<PopupMenu
isOpen={isOpen}
testId="entity-reference-menu"
position={null}
clientRect={clientRect}
title={t("task:referenceWorkItems")}
selectedIndex={selectedIndex}
onClose={onClose}
hasItems={hasContent}
emptyState={
<div className="px-3 py-2 text-xs text-muted-foreground">
{entityReferenceEmptyState(query, isSearching, error, onRetry)}
</div>
}
>
{visibleGroups.map((group) => (
<div key={group.source} data-reference-group={group.source}>
<div className="flex items-center justify-between gap-2 px-3 py-1 text-[11px] text-muted-foreground">
<span className="truncate font-medium">{group.display_name}</span>
<span className="shrink-0">{group.kind_label}</span>
</div>
{group.status === "ok" ? (
group.results.map((reference) => {
const index = itemIndex++;
return (
<PopupMenuItem
key={reference.ref}
icon={entityReferenceIcon(group.kind)}
label={`#${reference.key || reference.title}`}
description={reference.title}
isSelected={selectedIndex === index}
onClick={() => onSelect(reference)}
onMouseEnter={() => setSelectedIndex(index)}
itemRef={setItemRef(index)}
/>
);
})
) : (
<div className="min-h-11 px-3 py-2 text-xs text-muted-foreground">
{groupStatusLabel(t, group.status)}
</div>
)}
</div>
))}
{isSearching && selectable.length > 0 && (
<div className="px-3 py-1 text-[11px] text-muted-foreground">{t("task:updating")}</div>
)}
</PopupMenu>
);
}