Compare commits

...
Author SHA1 Message Date
AndreyandClaude Opus 5.5 cc091dbd03 🔖 release: 1.15.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:29:24 +03:00
AndreyandClaude Opus 5.5 8f3b1e43a4 🐛 fix(web-chat): на телефоне окно чата на весь экран
На экранах до 480px лоадер раскрывает панель на весь экран без
скруглений и прячет круглую кнопку, пока панель открыта: иначе она
висит поверх поля ввода. Кнопку «Развернуть окно» панель не показывает —
разворачивать некуда. Ширину экрана iframe не видит, поэтому режим
сообщает лоадер (chatballs-chat-layout), а панель после монтирования
сама его запрашивает.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:27:59 +03:00
Andrey Ermolaev d5c9d5c638 Merge pull request #4 from dartdavros/codex/release-1.15.2-cleanup
Подготовка релиза v1.15.2: очистка устаревших терминов
2026-09-24 18:03:41 +03:00
Andrey Ermolaev fc370fafe1 Merge pull request #3 from dartdavros/codex/help-center-navigation-related
Навигация и связанные статьи портала помощи
2026-09-24 14:09:33 +03:00
5 changed files with 83 additions and 16 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.15.0
1.15.3
+48 -11
View File
@@ -90,16 +90,21 @@ LOADER_JS = r"""
frame.title = "Чат";
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
shell.appendChild(frame);
layoutShell();
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
// повторяем его на load.
frame.addEventListener("load", function () { if (open) notifyOpened(); });
frame.addEventListener("load", function () {
notifyLayout();
if (open) notifyOpened();
});
document.body.appendChild(shell);
window.addEventListener("message", function (e) {
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
var d = e.data || {};
if (d.instanceId && d.instanceId !== instanceId) return;
if (d.type === "chatballs-chat-close") setOpen(false);
if (d.type === "chatballs-chat-layout-request") notifyLayout();
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
if (d.type === "chatballs-chat-unread") {
unread = Boolean(d.unread);
@@ -121,11 +126,45 @@ LOADER_JS = r"""
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
var PANEL_HEIGHT = { normal: "min(720px,calc(100vh - 116px))", expanded: "min(820px,calc(100vh - 116px))" };
// На телефоне плавающее окно не помещается: панель занимает весь экран,
// разворачивать её уже некуда, а закрывают её кнопкой в шапке.
var mobile = window.matchMedia("(max-width: 480px)");
function panelRadius() { return mobile.matches ? 0 : 24; }
function layoutShell() {
if (!shell) return;
var full = mobile.matches;
shell.style.top = full ? "0" : "";
shell.style.left = full ? "0" : "";
shell.style.right = full ? "0" : "24px";
shell.style.bottom = full ? "0" : "92px";
shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
shell.style.height = full ? "100%" : expanded ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
frame.style.borderRadius = panelRadius() + "px";
showLauncher();
notifyLayout();
}
// Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
// виджета. Поэтому про полноэкранный режим ей говорит лоадер.
function notifyLayout() {
try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
}
// Пока панель на весь экран, кнопка висела бы поверх поля ввода.
function showLauncher() {
var hidden = open && mobile.matches;
btn.style.opacity = hidden ? "0" : "1";
btn.style.pointerEvents = hidden ? "none" : "";
}
if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
else if (mobile.addListener) mobile.addListener(layoutShell);
function setExpanded(next) {
expanded = next;
if (!shell) return;
shell.style.width = next ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
shell.style.height = next ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
layoutShell();
}
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
@@ -220,13 +259,13 @@ LOADER_JS = r"""
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
frame.style.borderRadius = Math.round(mix(24, icon.half, g.fill)) + "px";
frame.style.borderRadius = Math.round(mix(panelRadius(), icon.half, g.fill)) + "px";
}
function restPanel() {
shell.style.clipPath = "none";
frame.style.transform = "none";
frame.style.borderRadius = "24px";
frame.style.borderRadius = panelRadius() + "px";
}
function playGenie(from, to, duration, panel, icon, done) {
@@ -253,7 +292,7 @@ LOADER_JS = r"""
bottom: box.bottom,
cx: box.left + box.width / 2,
half: box.width / 2,
radius: 24
radius: panelRadius()
};
}
@@ -289,8 +328,7 @@ LOADER_JS = r"""
if (!open) return;
genieSvg.style.display = "none";
restPanel();
btn.style.opacity = "1";
btn.style.pointerEvents = "";
showLauncher();
});
} else {
var box = panelRect();
@@ -299,8 +337,7 @@ LOADER_JS = r"""
genieSvg.style.display = "none";
shell.style.display = "none";
restPanel();
btn.style.opacity = "1";
btn.style.pointerEvents = "";
showLauncher();
});
}
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
+3 -1
View File
@@ -21,6 +21,7 @@ import {
} from "./api";
import { BrandFooter } from "./BrandFooter";
import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView";
import { usePanelFullscreen } from "./usePanelFullscreen";
import { useScrollToLatest } from "./useScrollToLatest";
import { useVoiceRecorder } from "./useVoiceRecorder";
import { useWidgetActivity } from "./widgetActivity";
@@ -65,6 +66,7 @@ export function App() {
const pollingReady = useRef(false);
const bodyRef = useRef<HTMLDivElement>(null);
const scrollToLatest = useScrollToLatest(bodyRef);
const fullscreen = usePanelFullscreen();
const incomingCall = Boolean(call && (call.status === "REQUESTED" || call.status === "RINGING"));
const notifyNewMessage = useWidgetActivity(incomingCall);
const recorder = useVoiceRecorder({
@@ -218,7 +220,7 @@ export function App() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%", background: "#fff", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#1f1f1f", overflow: "hidden" }}>
<ChatHeader accent={accent} title={title} expanded={expanded} onToggleExpand={toggleExpanded} onClose={closePanel} />
<ChatHeader accent={accent} title={title} expanded={expanded} canExpand={!fullscreen} onToggleExpand={toggleExpanded} onClose={closePanel} />
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} title={title} messages={messages} pending={pending} awaiting={awaiting || thinking} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} />
{config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && <CallInviteBanner call={call} accent={accent} onAccept={() => void acceptCallInvite()} onDecline={() => void declineCallInvite()} />}
{config?.available && !accepted && <StartChatFooter accent={accent} starting={starting} onAccept={() => void accept()} />}
+3 -3
View File
@@ -26,16 +26,16 @@ const HEADER_BUTTON: React.CSSProperties = {
color: "#fff",
};
export function ChatHeader({ accent, title, expanded, onToggleExpand, onClose }: { accent: string; title: string; expanded: boolean; onToggleExpand: () => void; onClose: () => void }) {
export function ChatHeader({ accent, title, expanded, canExpand, onToggleExpand, onClose }: { accent: string; title: string; expanded: boolean; canExpand: boolean; onToggleExpand: () => void; onClose: () => void }) {
return (
<div style={{ flex: "none", background: accent, padding: "14px 16px", display: "flex", alignItems: "center", gap: 11 }}>
<span style={{ display: "flex", flex: "none", color: "#fff" }}><BotIcon size={34} /></span>
<div style={{ flex: 1, minWidth: 0, fontSize: 16, fontWeight: 600, color: "#fff", lineHeight: 1.25, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</div>
<button className="wc-icon-button" onClick={onToggleExpand} aria-label={expanded ? t("chat.shrink_panel") : t("chat.expand_panel")} style={HEADER_BUTTON}>
{canExpand && <button className="wc-icon-button" onClick={onToggleExpand} aria-label={expanded ? t("chat.shrink_panel") : t("chat.expand_panel")} style={HEADER_BUTTON}>
{expanded
? <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M9 3v4a2 2 0 0 1-2 2H3" /><path d="M15 3v4a2 2 0 0 0 2 2h4" /><path d="M9 21v-4a2 2 0 0 0-2-2H3" /><path d="M15 21v-4a2 2 0 0 1 2-2h4" /></svg>
: <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></svg>}
</button>
</button>}
<button className="wc-icon-button" onClick={onClose} aria-label={t("chat.collapse")} style={HEADER_BUTTON}>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</button>
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
/**
* Открыта ли панель на весь экран.
*
* Внутри iframe вьюпорт — это само окно виджета, и медиазапрос по ширине не
* отличит телефон от обычной панели в 440px. Режим знает только лоадер: он
* присылает `chatballs-chat-layout` после каждой раскладки. Первое такое
* сообщение может прийти раньше, чем React повесит слушатель, поэтому после
* монтирования панель сама спрашивает режим.
*/
export function usePanelFullscreen(): boolean {
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
function onParentMessage(event: MessageEvent) {
if (event.source !== window.parent) return;
const data = event.data as { type?: string; fullscreen?: unknown } | null;
if (data?.type !== "chatballs-chat-layout") return;
setFullscreen(Boolean(data.fullscreen));
}
window.addEventListener("message", onParentMessage);
window.parent.postMessage({ type: "chatballs-chat-layout-request" }, "*");
return () => window.removeEventListener("message", onParentMessage);
}, []);
return fullscreen;
}