chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import {
SharedMessageEditor,
type UploadedMessageEditorImage,
} from "@/components/chat/shared-message-editor"
import {
sendOperatorPresence,
useAgentConversationsStore,
} from "@/hooks/use-agent-conversation-realtime"
import { useI18n } from "@/i18n/provider"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
import { suggestAgentReply } from "@/lib/api/agent"
type AgentMessageEditorProps = {
disabled?: boolean
uploadingAsset?: boolean
onSend: (html: string) => Promise<void>
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
onSendAttachment: (file: File) => Promise<void>
}
/**
* Agent message editor for dashboard conversations.
* Supports quick replies picker and slash command (/) autocomplete.
*/
export function AgentMessageEditor({
disabled = false,
uploadingAsset = false,
onSend,
onUploadImage,
onSendAttachment,
}: AgentMessageEditorProps) {
const t = useI18n()
const selectedConversationId = useAgentConversationsStore(
(state) => state.selectedConversationId
)
const lastTypingSentAtRef = useRef<number>(0)
const [quickReplies, setQuickReplies] = useState<AdminQuickReply[]>([])
const [loadingQuickReplies, setLoadingQuickReplies] = useState(true)
const [quickReplyPickerOpen, setQuickReplyPickerOpen] = useState(false)
const handleTyping = () => {
if (!selectedConversationId) return
const now = Date.now()
if (now - lastTypingSentAtRef.current >= 3000) {
lastTypingSentAtRef.current = now
sendOperatorPresence(selectedConversationId, "typing")
}
}
useEffect(() => {
let cancelled = false
void fetchQuickReplyListAll()
.then((list) => {
if (!cancelled) {
setQuickReplies(list)
}
})
.catch((error) => {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : t("conversation.loadQuickRepliesFailed"))
}
})
.finally(() => {
if (!cancelled) {
setLoadingQuickReplies(false)
}
})
return () => {
cancelled = true
}
}, [t])
return (
<SharedMessageEditor
variant="agent"
conversationId={selectedConversationId}
disabled={disabled}
uploadingAsset={uploadingAsset}
quickReplies={{
open: quickReplyPickerOpen,
loading: loadingQuickReplies,
items: quickReplies,
onOpenChange: setQuickReplyPickerOpen,
}}
aiCopilot={
selectedConversationId
? {
onSuggest: async () => {
try {
const text = await suggestAgentReply(selectedConversationId)
if (!text) {
toast.info(t("aiCopilot.errorGenerating"))
return null
}
return text
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("aiCopilot.errorGenerating")
)
return null
}
},
}
: undefined
}
onSend={onSend}
onUploadImage={onUploadImage}
onSendAttachment={onSendAttachment}
onTyping={handleTyping}
/>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
"use client"
import { UserIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { IMConversationStatus } from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { formatDateTime } from "@/lib/utils";
import { useI18n } from "@/i18n/provider";
type ConversationListProps = {
onAfterSelect?: () => void
}
export function ConversationList({ onAfterSelect }: ConversationListProps) {
const t = useI18n()
const conversations = useAgentConversationsStore((state) => state.conversations)
const loading = useAgentConversationsStore((state) => state.conversationsLoading)
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
const selectConversation = useAgentConversationsStore((state) => state.selectConversation)
return (
<ScrollArea className="overflow-auto">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("conversation.loading")}
</div>
) : conversations.length > 0 ? (
conversations.map((conversation) => {
const isSelected = selectedId === conversation.id
return (
<div
key={conversation.id}
className={`cursor-pointer border-b border-border/80 px-3 py-2 transition-colors hover:bg-muted/40 ${
isSelected ? "bg-accent/70" : ""
}`}
onClick={() => {
void selectConversation(conversation.id).then(
() => {
onAfterSelect?.()
},
() => {},
)
}}
>
<div className="overflow-hidden">
<div className="flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/10 text-primary">
<UserIcon className="size-3.5 text-primary" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-sm leading-4">
{conversation.customerName ||
t("conversation.customerFallback", {
id: conversation.customerId || conversation.id,
})}
</span>
{conversation.agentUnreadCount > 0 ? (
<div className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{conversation.agentUnreadCount > 99
? "99+"
: conversation.agentUnreadCount}
</div>
) : null}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{conversation.lastMessageAt
? formatDateTime(conversation.lastMessageAt)
: t("conversation.noTime")}
</div>
</div>
</div>
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
{conversation.lastMessageSummary || t("conversation.noLatestMessage")}
</div>
{conversation.status === IMConversationStatus.Pending &&
conversation.currentTeamName ? (
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="rounded-md bg-muted px-1.5 py-0.5">
{t("conversation.teamOnDuty", {
name: conversation.currentTeamName,
})}
</span>
</div>
) : null}
</div>
</div>
)
})
) : (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("conversation.empty")}
</div>
)}
</ScrollArea>
)
}

View File

@@ -0,0 +1,147 @@
"use client"
import { useEffect, useState } from "react"
import { CopyIcon, Loader2Icon, SparklesIcon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useI18n } from "@/i18n/provider"
import { summarizeAgentConversation } from "@/lib/api/agent"
export type ConversationSummaryDialogProps = {
open: boolean
conversationId: number | null
onOpenChange: (open: boolean) => void
}
export function ConversationSummaryDialog({
open,
conversationId,
onOpenChange,
}: ConversationSummaryDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<ConversationSummaryDialogBody
key={conversationId ? `summary-${conversationId}` : "summary"}
conversationId={conversationId}
onOpenChange={onOpenChange}
/>
) : null}
</Dialog>
)
}
type ConversationSummaryDialogBodyProps = {
conversationId: number | null
onOpenChange: (open: boolean) => void
}
function ConversationSummaryDialogBody({
conversationId,
onOpenChange,
}: ConversationSummaryDialogBodyProps) {
const t = useI18n()
const [loading, setLoading] = useState(false)
const [summary, setSummary] = useState("")
const [copying, setCopying] = useState(false)
useEffect(() => {
if (!conversationId) {
setSummary("")
setLoading(false)
return
}
let active = true
setLoading(true)
summarizeAgentConversation(conversationId)
.then((res) => {
if (!active) return
setSummary(res || "")
})
.catch((error) => {
if (!active) return
toast.error(error instanceof Error ? error.message : t("aiCopilot.errorGenerating"))
})
.finally(() => {
if (!active) return
setLoading(false)
})
return () => {
active = false
}
}, [conversationId, t])
async function handleCopy() {
if (!summary || copying) {
return
}
setCopying(true)
try {
await navigator.clipboard.writeText(summary)
toast.success(t("aiCopilot.copied"))
} catch {
toast.error(t("aiCopilot.errorGenerating"))
} finally {
setCopying(false)
}
}
return (
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-4 text-primary" />
{t("aiCopilot.summaryTitle")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("aiCopilot.summaryTitle")}
</DialogDescription>
</DialogHeader>
<div className="min-h-[160px] max-h-[60vh] overflow-y-auto rounded-md border bg-muted/30 p-4">
{loading ? (
<div className="flex h-36 flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2Icon className="size-5 animate-spin text-primary" />
<span className="text-sm">{t("aiCopilot.summarizing")}</span>
</div>
) : summary ? (
<div className="whitespace-pre-wrap text-sm leading-6 select-text text-foreground">
{summary}
</div>
) : (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
{t("aiCopilot.noSummary")}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => void handleCopy()}
disabled={loading || !summary || copying}
>
<CopyIcon className="size-4" />
{copying ? t("aiCopilot.copied") : t("aiCopilot.copy")}
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
)
}

View File

@@ -0,0 +1,125 @@
"use client"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { TagBadges, TagSelector } from "@/components/tag-selector"
import {
addConversationTag,
removeConversationTag,
type AgentConversation,
type AgentConversationTag,
} from "@/lib/api/agent"
import { type TagTree } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
type ConversationTagPickerProps = {
conversation: AgentConversation
availableTags: TagTree[]
loading?: boolean
onTagsChange: (tags: AgentConversationTag[]) => void
}
export function ConversationTagPicker({
conversation,
availableTags,
loading = false,
onTagsChange,
}: ConversationTagPickerProps) {
const t = useI18n()
const [pendingTagId, setPendingTagId] = useState<number | null>(null)
const selectedValues = useMemo(
() => (conversation.tags ?? []).map((item) => item.id),
[conversation.tags]
)
const selectedTagIds = useMemo(
() => new Set(selectedValues),
[selectedValues]
)
async function handleChange(nextTagIds: number[]) {
if (pendingTagId !== null) {
return
}
const tagId =
nextTagIds.find((id) => !selectedTagIds.has(id)) ??
selectedValues.find((id) => !nextTagIds.includes(id))
if (!tagId) {
return
}
const exists = selectedTagIds.has(tagId)
const currentTags = conversation.tags ?? []
const nextTags = exists
? currentTags.filter((item) => item.id !== tagId)
: [...currentTags, { id: tagId, name: "" }]
setPendingTagId(tagId)
try {
if (exists) {
await removeConversationTag({
conversationId: conversation.id,
tagId,
})
} else {
await addConversationTag({
conversationId: conversation.id,
tagId,
})
}
onTagsChange(nextTags)
toast.success(exists ? t("conversation.tagRemoved") : t("conversation.tagAdded"))
} catch (error) {
toast.error(error instanceof Error ? error.message : t("conversation.tagUpdateFailed"))
} finally {
setPendingTagId(null)
}
}
return (
<TagSelector
mode="multiple"
value={selectedValues}
onChange={(value) => void handleChange(value)}
tags={availableTags}
loading={loading}
pendingTagId={pendingTagId}
placeholder={t("conversation.edit")}
triggerText={t("conversation.edit")}
searchPlaceholder={t("conversation.searchTags")}
loadingText={t("conversation.loadingTags")}
emptyText={t("conversation.emptyTags")}
align="end"
showSelectedBadges={false}
triggerVariant="ghost"
triggerSize="sm"
triggerClassName="h-7 w-auto shrink-0 justify-start gap-1 px-2 text-xs"
contentClassName="w-72"
/>
)
}
type ConversationTagBadgesProps = {
tags?: AgentConversationTag[]
availableTags?: TagTree[]
}
export function ConversationTagBadges({
tags,
availableTags = [],
}: ConversationTagBadgesProps) {
if (!tags || tags.length === 0) {
return null
}
return (
<TagBadges
ids={tags.map((tag) => tag.id)}
tags={availableTags}
fallbackTags={tags}
/>
)
}

View File

@@ -0,0 +1,561 @@
"use client";
import {
ArrowRightLeftIcon,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleUserRoundIcon,
CircleXIcon,
FilePlus2Icon,
Menu,
MoreHorizontalIcon,
SparklesIcon,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { ConversationSummaryDialog } from "./conversation-summary-dialog";
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useI18n } from "@/i18n/provider";
import {
agentConversationFilterOptions,
agentConversationSelectors,
type AgentConversationFilterKey,
useAgentConversationsStore,
} from "@/lib/stores/agent-conversations";
import { CreateTicketFromConversationDialog } from "../../tickets/_components/create-ticket-from-conversation-dialog";
import { ChatPanel } from "./chat-panel";
import { ConversationInfoPanel } from "./conversation-info-panel";
import { ConversationList } from "./conversation-list";
const workbenchIconButtonClassName =
"size-8 text-muted-foreground hover:bg-muted hover:text-foreground";
function getCustomerOnlineClassName(online?: boolean) {
return online
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300"
: "border-border bg-muted text-muted-foreground";
}
function getCustomerOnlineDotClassName(online?: boolean) {
return online ? "bg-emerald-500" : "bg-muted-foreground/70";
}
export function ConversationWorkbench() {
const t = useI18n();
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
const conversationFilter = useAgentConversationsStore(
(state) => state.conversationFilter,
);
const setConversationFilter = useAgentConversationsStore(
(state) => state.setConversationFilter,
);
const loadConversations = useAgentConversationsStore(
(state) => state.loadConversations,
);
const loadMessages = useAgentConversationsStore(
(state) => state.loadMessages,
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [infoPanelCollapsed, setInfoPanelCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileCustomerSheetOpen, setMobileCustomerSheetOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [closeOpen, setCloseOpen] = useState(false);
const [createTicketOpen, setCreateTicketOpen] = useState(false);
const [summaryOpen, setSummaryOpen] = useState(false);
const sidebarPanelRef = useRef<PanelImperativeHandle | null>(null);
const infoPanelRef = useRef<PanelImperativeHandle | null>(null);
const filterContainerRef = useRef<HTMLDivElement | null>(null);
const filterMeasureRef = useRef<HTMLDivElement | null>(null);
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
useEffect(() => {
const container = filterContainerRef.current;
const measure = filterMeasureRef.current;
if (!container || !measure) {
return;
}
const updateFilterMode = () => {
setShowFilterDropdown(measure.scrollWidth > container.clientWidth);
};
updateFilterMode();
const observer = new ResizeObserver(() => {
updateFilterMode();
});
observer.observe(container);
observer.observe(measure);
return () => {
observer.disconnect();
};
}, []);
const currentFilterOption =
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
agentConversationFilterOptions[0];
const getFilterLabel = (labelKey: string) => t(labelKey);
useEffect(() => {
void loadConversations().catch((error) => {
toast.error(error instanceof Error ? error.message : t("conversation.loadListFailed"));
});
}, [loadConversations, conversationFilter, t]);
async function handleConversationChanged(conversationId: number) {
await loadConversations();
await loadMessages(conversationId, {
forceLoading: false,
reset: false,
});
}
const handleSidebarToggle = () => {
const panel = sidebarPanelRef.current;
if (!panel) {
setSidebarCollapsed((current) => !current);
return;
}
if (panel.isCollapsed()) {
panel.expand();
setSidebarCollapsed(false);
return;
}
panel.collapse();
setSidebarCollapsed(true);
};
const handleInfoPanelToggle = () => {
const panel = infoPanelRef.current;
if (!panel) {
setInfoPanelCollapsed((current) => !current);
return;
}
if (panel.isCollapsed()) {
panel.expand();
setInfoPanelCollapsed(false);
return;
}
panel.collapse();
setInfoPanelCollapsed(true);
};
const renderConversationSidebar = (opts?: { onListAfterSelect?: () => void }) => (
<div className="flex h-full min-h-0 flex-1 flex-col bg-inherit">
<div className="flex h-12.5 shrink-0 items-start justify-between gap-2 border-b border-border/80 bg-card px-2 py-2">
<div ref={filterContainerRef} className="relative min-w-0 flex-1">
{showFilterDropdown ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
className="h-8.5 w-full min-w-0 justify-between gap-2 px-3 text-xs sm:text-sm"
/>
}
>
<span className="truncate">
{currentFilterOption
? getFilterLabel(currentFilterOption.labelKey)
: t("conversation.filterPlaceholder")}
</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44 min-w-44">
<DropdownMenuRadioGroup
value={conversationFilter}
onValueChange={(value) =>
setConversationFilter(value as AgentConversationFilterKey)
}
>
{agentConversationFilterOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{getFilterLabel(opt.labelKey)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tabs
value={conversationFilter}
onValueChange={(value) =>
setConversationFilter(value as AgentConversationFilterKey)
}
className="min-w-0 flex-1 gap-0"
>
<TabsList
className="w-full min-w-0 justify-start"
>
{agentConversationFilterOptions.map((opt) => (
<TabsTrigger
key={opt.value}
value={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{getFilterLabel(opt.labelKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<div
ref={filterMeasureRef}
className="pointer-events-none absolute whitespace-nowrap opacity-0"
aria-hidden="true"
>
<div className="inline-flex">
{agentConversationFilterOptions.map((opt) => (
<span
key={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{getFilterLabel(opt.labelKey)}
</span>
))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className={`${workbenchIconButtonClassName} mt-0.5 shrink-0 lg:hidden`}
onClick={() => setMobileMenuOpen(false)}
>
<X className="size-4" />
</Button>
</div>
<ConversationList onAfterSelect={opts?.onListAfterSelect} />
</div>
);
const workspaceContent = (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-card text-card-foreground">
<div className="flex h-12.5 shrink-0 items-center justify-between gap-3 border-b border-border/80 bg-card px-3 py-1">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="icon"
className={`${workbenchIconButtonClassName} lg:hidden`}
onClick={() => setMobileMenuOpen(true)}
>
<Menu className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={`${workbenchIconButtonClassName} hidden lg:flex`}
onClick={handleSidebarToggle}
>
{sidebarCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button>
{conversation ? (
<>
<Avatar className="size-8 shrink-0 lg:size-9">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/10 text-sm text-primary">
{t("conversation.customerAvatar")}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="min-w-0 truncate text-sm font-medium leading-tight">
{conversation.customerName ||
t("conversation.customerFallback", {
id: conversation.customerId || conversation.id,
})}
</p>
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] leading-none ${getCustomerOnlineClassName(
conversation.customerOnline,
)}`}
>
<span
className={`size-1.5 rounded-full ${getCustomerOnlineDotClassName(
conversation.customerOnline,
)}`}
/>
{conversation.customerOnline
? t("conversation.customerOnline")
: t("conversation.customerOffline")}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
<span>{t("conversation.channelNumber", { id: conversation.channelId || "-" })}</span>
{conversation.customerId ? (
<>
<span className="text-muted-foreground/60"> / </span>
<span>{t("conversation.linkedCustomer")}</span>
</>
) : null}
</p>
</div>
</>
) : (
<div className="min-w-0">
<p className="truncate font-medium text-[14px] leading-tight">
{t("conversation.workbenchTitle")}
</p>
<p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden">
{t("conversation.openMenuSelectConversation")}
</p>
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
{t("conversation.selectConversationFromSidebar")}
</p>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
<Button
variant="ghost"
size="icon"
className={`${workbenchIconButtonClassName} lg:hidden`}
disabled={!conversation}
aria-label={t("conversation.conversationInfo")}
onClick={() => setMobileCustomerSheetOpen(true)}
>
<CircleUserRoundIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={workbenchIconButtonClassName}
disabled={!conversation}
title={t("aiCopilot.summarize")}
aria-label={t("aiCopilot.summarize")}
onClick={() => setSummaryOpen(true)}
>
<SparklesIcon className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className={workbenchIconButtonClassName}
disabled={!conversation}
/>
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 min-w-44">
<DropdownMenuItem
onClick={() => setSummaryOpen(true)}
disabled={!conversation}
>
<SparklesIcon className="size-4" />
{t("aiCopilot.summarize")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setCreateTicketOpen(true)}
disabled={!conversation}
>
<FilePlus2Icon />
{t("conversation.createTicket")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTransferOpen(true)}
disabled={!conversation || conversation.status !== 3}
>
<ArrowRightLeftIcon />
{t("conversation.transferConversation")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setCloseOpen(true)}
disabled={!conversation || conversation.status === 4}
>
<CircleXIcon />
{t("conversation.closeConversation")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className={`${workbenchIconButtonClassName} hidden lg:flex`}
onClick={handleInfoPanelToggle}
aria-label={
infoPanelCollapsed
? t("conversation.expandConversationInfo")
: t("conversation.collapseConversationInfo")
}
>
{infoPanelCollapsed ? (
<ChevronLeft className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
</div>
</div>
<div className="flex min-h-0 w-full flex-1 overflow-hidden">
<ChatPanel />
</div>
</div>
);
return (
<div className="flex h-[calc(100dvh-var(--header-height))] min-h-0 w-full min-w-0 flex-col overflow-hidden lg:h-full">
{mobileMenuOpen && (
<button
type="button"
aria-label={t("conversation.closeConversationList")}
className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
)}
<div
className={`fixed top-12 bottom-0 left-0 z-40 flex w-[min(22rem,calc(100vw-0.75rem))] max-w-[min(22rem,calc(100vw-0.75rem))] flex-col overflow-hidden border-r border-border/80 bg-card text-card-foreground shadow-xl transition-transform duration-300 ease-out will-change-transform touch-manipulation overscroll-contain supports-[padding:max(0px)]:pb-[env(safe-area-inset-bottom)] lg:hidden ${
mobileMenuOpen ? "translate-x-0" : "-translate-x-full pointer-events-none"
}`}
aria-hidden={!mobileMenuOpen}
>
{renderConversationSidebar({
onListAfterSelect: () => setMobileMenuOpen(false),
})}
</div>
<div className="flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden lg:hidden">
{workspaceContent}
</div>
<div className="hidden min-h-0 w-full flex-1 overflow-hidden lg:flex">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
panelRef={sidebarPanelRef}
defaultSize="20%"
minSize="10%"
maxSize="40%"
collapsedSize="0%"
collapsible
onResize={(panelSize: { asPercentage: number }) => {
setSidebarCollapsed(panelSize.asPercentage <= 1);
}}
className="min-h-0 bg-card"
>
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
{renderConversationSidebar()}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50%" minSize="32%" className="min-h-0 bg-card">
<div className="flex h-full min-h-0 flex-col overflow-hidden">
{workspaceContent}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
panelRef={infoPanelRef}
defaultSize="500px"
minSize="20%"
maxSize="40%"
collapsedSize="0%"
collapsible
onResize={(panelSize: { asPercentage: number }) => {
setInfoPanelCollapsed(panelSize.asPercentage <= 1);
}}
className="min-h-0 bg-card"
>
<ConversationInfoPanel conversation={conversation} className="h-full" />
</ResizablePanel>
</ResizablePanelGroup>
</div>
<ConversationTransferDialog
open={transferOpen}
mode="transfer"
conversationId={conversation?.id ?? null}
onOpenChange={setTransferOpen}
onSuccess={async () => {
setTransferOpen(false);
if (conversation?.id) {
await handleConversationChanged(conversation.id);
}
}}
/>
<ConversationCloseDialog
open={closeOpen}
conversationId={conversation?.id ?? null}
onOpenChange={setCloseOpen}
onSuccess={async () => {
setCloseOpen(false);
if (conversation?.id) {
await handleConversationChanged(conversation.id);
}
}}
/>
<CreateTicketFromConversationDialog
open={createTicketOpen}
onOpenChange={setCreateTicketOpen}
conversation={
conversation
? {
id: conversation.id,
customerName: conversation.customerName,
customerId: conversation.customerId ?? 0,
lastMessageSummary: conversation.lastMessageSummary,
currentAssigneeId: conversation.currentAssigneeId,
}
: null
}
onSuccess={() => {
setCreateTicketOpen(false);
}}
/>
<ConversationSummaryDialog
open={summaryOpen}
conversationId={conversation?.id ?? null}
onOpenChange={setSummaryOpen}
/>
<Sheet open={mobileCustomerSheetOpen} onOpenChange={setMobileCustomerSheetOpen}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
showCloseButton
>
<ConversationInfoPanel
conversation={conversation}
variant="embedded"
className="min-h-0 flex-1"
/>
</SheetContent>
</Sheet>
</div>
);
}