💄 feat(ui): мобильный чат — список ↔ диалог (кадры M1/M2, S1)

На ≤768px сайдбар в чате скрыт: экран списка с шапкой бренда (логотип,
организация, меню профиля), лента открывается выбором диалога, назад —
стрелкой в шапке. Контекст — по кнопке в шапке (drawer S2 на всю ширину).
Пустой список — текст кадра S1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-05 01:27:30 +03:00
1 parent 12107e02aa
commit 09e9819fdd
8 files changed
+154 -8

No files matched your search

@@ -1,5 +1,7 @@
import { useState, type ReactNode } from "react";
import { ChatMobileHeader } from "../../layout/ChatMobileHeader";
import { ConversationWorkspace, type DialogScope } from "../conversations/ConversationWorkspace";
import { DialogControls } from "../conversations/DialogControls";
import type { ApiConversation, ConversationCounters } from "../conversations/model";
@@ -8,7 +10,7 @@ import { ClientContext } from "../sales/dialogs/context/ClientContext";
import { HistoryContext } from "../sales/dialogs/context/HistoryContext";
import { OperatorCards } from "../support/context/OperatorCards";
import { SupportHistory } from "../support/context/SupportHistory";
import type { EmployeeGroup, SessionUser } from "../../types";
import type { EmployeeGroup, RouteKey, SessionUser } from "../../types";
// Единый «Чат» (дизайн-базлайн v2 §8.1): один экран для всех диалогов
// организации. Контекст-панель сама выбирает представление по источнику
@@ -25,6 +27,8 @@ export function ChatPage({
setScope,
counters,
showScopeSwitcher,
setRoute,
onLogout,
}: {
initialConversationId?: number | null;
user: SessionUser;
@@ -34,6 +38,8 @@ export function ChatPage({
setScope: (scope: DialogScope) => void;
counters: ConversationCounters | null;
showScopeSwitcher: boolean;
setRoute: (route: RouteKey) => void;
onLogout: () => void;
}) {
const [rightTab, setRightTab] = useState<ChatRightTab>("client");
return (
@@ -44,6 +50,7 @@ export function ChatPage({
setScope={setScope}
counters={counters}
showScopeSwitcher={showScopeSwitcher}
mobileHeader={<ChatMobileHeader user={user} setRoute={setRoute} onLogout={onLogout} />}
renderContextPanel={({ dialog, detail, applyConversation }) => (
<ChatContextPanel
rightTab={rightTab}
@@ -15,7 +15,7 @@ function fmtTime(value: string): string {
return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive, onToggleContext }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; onToggleContext?: () => void }) {
export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive, onToggleContext, onMobileBack }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void }) {
const timelineRef = useRef<HTMLDivElement>(null);
const messages = detail?.messages ?? [];
const lastMessageId = messages.length ? messages[messages.length - 1].id : 0;
@@ -34,6 +34,8 @@ export function ConversationThread({ controlMode, dialog, detail, isOwner = fals
return (
<>
<div className="sales-conversation-head">
{/* Кадр M2: на мобильном лента — отдельный экран, назад к списку. */}
{onMobileBack && <button className="mobile-back" type="button" aria-label="К списку диалогов" onClick={onMobileBack}><Icon name="arrow" size={19} /></button>}
<div className="sales-conversation-person">
<ContactAvatar avatarUrl={dialog.avatarUrl} initials={dialog.initials} background={dialog.avatarBg} className="sales-conversation-avatar" />
<div>
@@ -45,11 +45,12 @@ import { useIncomingMessageSound } from "./useIncomingMessageSound";
// Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает
// backend по группам (ADR-HUB-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop.
export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceholder, renderContextPanel, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
isOwner?: boolean;
listTitle?: string;
searchPlaceholder?: string;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void }) => ReactNode;
mobileHeader?: ReactNode;
initialConversationId?: number | null;
// Охват (дерево фильтров) живёт в Shell: у сотрудника им управляет сайдбар,
// у менеджера — поповер в заголовке списка.
@@ -61,6 +62,8 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
const [listTab, setListTab] = useState<ListTab>("all");
// Кадр S2: на ≤1024px контекст-панель — выдвижная поверх ленты.
const [ctxOpen, setCtxOpen] = useState(false);
// Кадры M1/M2: на ≤768px список и лента — отдельные экраны.
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const [search, setSearch] = useState("");
const [conversations, setConversations] = useState<ApiConversation[]>([]);
const [listLoaded, setListLoaded] = useState(false);
@@ -174,7 +177,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
};
return (
<div className={`sales-dialogs ${ctxOpen ? "is-ctx-open" : ""}`}>
<div className={`sales-dialogs ${ctxOpen ? "is-ctx-open" : ""} ${mobileDialogOpen ? "is-mobile-dialog" : ""}`}>
<DialogList
scope={scope}
counters={counters}
@@ -190,10 +193,14 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
errorText={listError}
setSearch={setSearch}
setListTab={setListTab}
setSelectedId={setSelectedId}
setSelectedId={(id) => {
setSelectedId(id);
setMobileDialogOpen(true);
}}
mobileHeader={mobileHeader}
/>
<section className="sales-conversation">
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} />
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
{(detailError || actionError) && <div className="sales-conversation-error">{detailError || actionError}</div>}
<CallOverlay
open={callController.open}
@@ -10,13 +10,14 @@ import type { ConversationCounters } from "./model";
import type { ConversationListItem, ListTab } from "./types";
import { SearchInput } from "../../shared/ui-controls";
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", scope, counters, setScope, showScopeSwitcher = true, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", scope, counters, setScope, showScopeSwitcher = true, mobileHeader, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
title?: string;
searchPlaceholder?: string;
scope: DialogScope;
counters: ConversationCounters | null;
setScope: (scope: DialogScope) => void;
showScopeSwitcher?: boolean;
mobileHeader?: ReactNode;
dialogs: ConversationListItem[];
filtered: ConversationListItem[];
listTab: ListTab;
@@ -30,6 +31,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
const waitCount = dialogs.filter((dialog) => dialog.mode === "wait").length;
return (
<section className="sales-dialog-list">
{mobileHeader}
<div className="sales-dialog-list-head">
<div>
{showScopeSwitcher
@@ -46,6 +48,8 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
</div>
<div className="sales-dialog-list-body">
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
{/* Кадр S1: пустой список без призыва к действию. */}
{!errorText && dialogs.length === 0 && <div className="sales-wait-note">Диалоги появятся, когда клиенты напишут вашему агенту</div>}
{filtered.map((dialog) => <DialogListItem dialog={dialog} active={dialog.id === selectedId} setSelectedId={setSelectedId} key={dialog.id} />)}
</div>
</section>
@@ -52,3 +52,94 @@
display: flex;
}
}
/* Кадры M1/M2 (≤768px): список и лента — отдельные экраны; сайдбар скрыт,
шапка бренда живёт в списке. */
@media (max-width: 768px) {
.hub-shell.is-chat-route .hub-sidebar {
display: none;
}
.sales-dialogs .sales-dialog-list {
width: 100%;
border-right: 0;
}
.sales-dialogs .sales-conversation {
display: none;
}
.sales-dialogs.is-mobile-dialog .sales-dialog-list {
display: none;
}
.sales-dialogs.is-mobile-dialog .sales-conversation {
display: flex;
}
.sales-dialogs .sales-context {
width: min(320px, 100%);
}
.sales-conversation-head .mobile-back {
display: flex;
}
}
.sales-conversation-head .mobile-back {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex: none;
border: 0;
border-radius: 8px;
color: var(--n-3);
background: transparent;
}
.sales-conversation-head .mobile-back svg {
transform: rotate(180deg);
}
/* Мобильная шапка списка (кадр M1). */
.chat-mobile-header {
display: none;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px 4px;
}
.chat-mobile-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
border: 0;
padding: 0;
background: transparent;
color: var(--n-1);
font-size: 14.5px;
font-weight: 700;
}
.chat-mobile-brand strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-mobile-avatar {
border: 0;
padding: 0;
flex: none;
background: transparent;
}
@media (max-width: 768px) {
.chat-mobile-header {
display: flex;
}
}
@@ -0,0 +1,33 @@
import { Dropdown } from "antd";
import { useState } from "react";
import type { RouteKey, SessionUser } from "../types";
import { Icon, LogoIcon } from "../shared/icons";
import { Avatar } from "../shared/ui";
import { defaultRoute } from "../auth/access";
// Мобильная шапка экрана списка (дизайн-базлайн v2, кадр M1): логотип +
// организация слева, аватар с меню профиля справа. Видна только на ≤768px —
// сайдбар на мобильном чате скрыт.
export function ChatMobileHeader({ user, setRoute, onLogout }: { user: SessionUser; setRoute: (route: RouteKey) => void; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuItems = [
{ key: "profile", label: <button type="button" onClick={() => setRoute("profile")}><Icon name="user" size={15} />Профиль</button> },
{ key: "settings", label: <button type="button" onClick={() => setRoute("settings")}><Icon name="settings" size={15} />Настройки</button> },
{ type: "divider" as const },
{ key: "logout", label: <button type="button" className="danger" onClick={onLogout}><Icon name="logout" size={15} />Выйти</button> },
];
return (
<div className="chat-mobile-header">
<button className="chat-mobile-brand" type="button" onClick={() => setRoute(defaultRoute(user))}>
<span className={`hub-brand-mark ${user.organizationLogoUrl ? "has-logo" : ""}`}>
{user.organizationLogoUrl ? <img src={user.organizationLogoUrl} alt="" /> : <LogoIcon />}
</span>
<strong>{user.organizationName || "Chatbolls"}</strong>
</button>
<Dropdown menu={{ items: menuItems }} open={open} onOpenChange={setOpen} trigger={["click"]} placement="bottomRight" overlayClassName="app-dropdown is-wide">
<button className="chat-mobile-avatar" type="button" aria-label="Меню пользователя"><Avatar user={user} /></button>
</Dropdown>
</div>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
const isKnowledgeLibrary = route === "aiKnowledge";
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
return (
<div className="hub-shell">
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} />
<div className="hub-main">
{/* У сотрудника верхней панели нет (дизайн-базлайн v2 §4.1). */}
@@ -58,6 +58,8 @@ export function ShellRouteContent({ chatScope, setChatScope, chatCounters, chatS
setScope={setChatScope}
counters={chatCounters}
showScopeSwitcher={chatScopeSwitcher}
setRoute={setRoute}
onLogout={onLogout}
/>
)}
{route === "agents" && <AgentsPage agents={data.agents} groups={data.groups} reload={reload} openAgent={openAgent} />}