feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,4 +1,4 @@
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
// Modified for hardening and realtime recovery; see docs/hardening/README.md and docs/fixes/chat-realtime-recovery.md.
"use client"
import {
@@ -20,6 +20,7 @@ import { useShallow } from "zustand/react/shallow"
import { SupportChatErrorBoundary } from "@/components/support-chat/chat-error-boundary"
import { SupportChatConnectionStatus } from "@/components/support-chat/connection-status"
import { PublicSupportAvailability } from "@/components/support-availability/public-status"
import { SupportChatConversationSwitcher } from "@/components/support-chat/conversation-switcher"
import { SupportChatNewRequestDialog } from "@/components/support-chat/new-request-dialog"
import { getStandaloneClosedUrl } from "@/components/support-chat/close-navigation"
@@ -114,12 +115,14 @@ function SupportChatPanelContent() {
useSupportChatSystemTheme()
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
const mountedRef = useRef(false)
const [isEmbedded, setIsEmbedded] = useState(false)
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
const [isClosingConversation, setIsClosingConversation] = useState(false)
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
const {
identityScope,
title,
subtitle,
themeColor,
@@ -130,6 +133,8 @@ function SupportChatPanelContent() {
loadOlderMessages,
status,
error,
sending,
uploadingAsset,
isOpen,
isVisible,
setIsOpen,
@@ -146,6 +151,7 @@ function SupportChatPanelContent() {
setConversationSatisfaction,
} = useSupportChatStore(
useShallow((state) => ({
identityScope: state.identityScope,
title: state.title,
subtitle: state.subtitle,
themeColor: state.themeColor,
@@ -156,6 +162,8 @@ function SupportChatPanelContent() {
loadOlderMessages: state.loadOlderMessages,
status: state.status,
error: state.error,
sending: state.sending,
uploadingAsset: state.uploadingAsset,
isOpen: state.isOpen,
isVisible: state.isVisible,
setIsOpen: state.setIsOpen,
@@ -185,17 +193,18 @@ function SupportChatPanelContent() {
if (document.visibilityState !== "visible") {
return
}
if (useSupportChatStore.getState().conversation?.id !== conversation.id) return
void markConversationRead().catch((readError) => {
console.error("Failed to mark support chat conversation read", readError)
})
}, [conversation?.id, isVisible, markConversationRead])
}, [conversation, isVisible, markConversationRead])
useEffect(() => {
return bindSupportHostBridge({
onInit: () => {
if (useSupportChatStore.getState().isOpen) {
bootstrap()
}
// Clear a previous visitor's private state even while the host keeps
// this frame hidden; visibility must never race identity replacement.
void bootstrap()
},
onOpen: () => {
setIsOpen(true)
@@ -208,10 +217,16 @@ function SupportChatPanelContent() {
}, [bootstrap, setIsOpen, setIsVisible])
useEffect(() => {
bootstrap()
void bootstrap()
}, [isOpen, bootstrap])
useEffect(() => () => disconnectSocket(), [disconnectSocket])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
disconnectSocket()
}
}, [disconnectSocket])
useEffect(() => {
maybeMarkConversationRead()
@@ -239,9 +254,21 @@ function SupportChatPanelContent() {
}
}, [maybeMarkConversationRead, recoverConnection])
function requireDisplayedConversation() {
const current = useSupportChatStore.getState()
if (!conversation?.id || current.identityScope !== identityScope || current.conversation?.id !== conversation.id) {
throw new Error(t("supportChat.sendUnavailable"))
}
}
async function handleSend(content: string) {
requireDisplayedConversation()
const submittedConversationId = conversation?.id
await handleSendMessage(content)
messageListRef.current?.scrollToBottom()
const current = useSupportChatStore.getState()
if (current.identityScope === identityScope && current.conversation?.id === submittedConversationId) {
messageListRef.current?.scrollToBottom()
}
}
function handleCloseWindow() {
@@ -249,7 +276,8 @@ function SupportChatPanelContent() {
if (isEmbedded) {
requestSupportHostMinimize()
} else {
window.location.replace(getStandaloneClosedUrl())
setIsOpen(false)
window.location.replace(getStandaloneClosedUrl(window.location.search))
}
}
@@ -260,6 +288,7 @@ function SupportChatPanelContent() {
setIsClosingConversation(true)
try {
if (conversation?.id) {
requireDisplayedConversation()
await closeConversation()
}
setIsCloseDialogOpen(false)
@@ -335,6 +364,8 @@ function SupportChatPanelContent() {
</WindowActionButton>
</div>
</div>
<div className="mt-2"><SupportChatConnectionStatus status={status} /></div>
<PublicSupportAvailability identityScope={identityScope} />
</header>
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30">
@@ -354,15 +385,29 @@ function SupportChatPanelContent() {
conversationId={conversation.id}
customerToken={getCustomerSessionToken()}
initialSatisfaction={conversation.satisfaction}
onSubmitted={setConversationSatisfaction}
onSubmitted={(satisfaction) => {
if (mountedRef.current && useSupportChatStore.getState().identityScope === identityScope) {
setConversationSatisfaction(satisfaction)
}
}}
/>
</div>
) : null}
<CustomerMessageEditor
conversationId={conversation?.id}
draftScope={identityScope}
sending={sending}
uploadingAsset={uploadingAsset}
disabled={!conversation || conversation.status === 4}
onSend={handleSend}
onUploadImage={uploadMessageImage}
onSendAttachment={sendAttachment}
onUploadImage={async (file) => {
requireDisplayedConversation()
return uploadMessageImage(file)
}}
onSendAttachment={async (file) => {
requireDisplayedConversation()
await sendAttachment(file)
}}
/>
</div>
</div>