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:
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
@@ -14,6 +15,7 @@ async function loadCloseNavigation() {
|
||||
fileName: "close-navigation.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
URLSearchParams,
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
@@ -27,3 +29,10 @@ test("standalone close navigates to the non-bootstrapping closed page", async ()
|
||||
|
||||
assert.equal(getStandaloneClosedUrl(), "/support/chat/closed")
|
||||
})
|
||||
|
||||
test("dismiss and return retain the entry channel without accepting a redirect destination", async () => {
|
||||
const { getStandaloneClosedUrl, getStandaloneChatUrl } = await loadCloseNavigation()
|
||||
const search = "?channelId=help&externalId=visitor&returnUrl=https%3A%2F%2Fexample.org"
|
||||
assert.equal(getStandaloneClosedUrl(search), `/support/chat/closed${search}`)
|
||||
assert.equal(getStandaloneChatUrl(search), `/support/chat${search}`)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
|
||||
|
||||
export function getStandaloneClosedUrl() {
|
||||
return SUPPORT_CHAT_CLOSED_PATH
|
||||
// Keep the entry context when dismissing/returning. These are always local
|
||||
// paths, never a caller-supplied redirect destination.
|
||||
function querySuffix(search: string) {
|
||||
return search ? `?${new URLSearchParams(search).toString()}` : ""
|
||||
}
|
||||
|
||||
export function getStandaloneClosedUrl(search = "") {
|
||||
return `${SUPPORT_CHAT_CLOSED_PATH}${querySuffix(search)}`
|
||||
}
|
||||
|
||||
export function getStandaloneChatUrl(search = "") {
|
||||
return `/support/chat${querySuffix(search)}`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -20,16 +21,16 @@ export function SupportChatConnectionStatus({ status }: SupportChatConnectionSta
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-6 gap-2 px-2.5 text-[11px] font-medium shadow-sm", toneClass)}
|
||||
className={cn("h-6 gap-2 px-2.5 text-[11px] font-medium", toneClass)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
status === "connected"
|
||||
? "bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]"
|
||||
? "bg-emerald-500"
|
||||
: status === "connecting"
|
||||
? "bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.16)]"
|
||||
: "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]"
|
||||
? "bg-amber-500"
|
||||
: "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span>{t(`supportChat.${status}`)}</span>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: forward customer conversation identity and pending-send state.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -6,6 +8,9 @@ import {
|
||||
} from "@/components/chat/shared-message-editor"
|
||||
|
||||
type CustomerMessageEditorProps = {
|
||||
conversationId?: number | null
|
||||
draftScope?: string
|
||||
sending?: boolean
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
onSend: (html: string) => Promise<void>
|
||||
@@ -14,6 +19,9 @@ type CustomerMessageEditorProps = {
|
||||
}
|
||||
|
||||
export function CustomerMessageEditor({
|
||||
conversationId,
|
||||
draftScope,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
onSend,
|
||||
@@ -23,6 +31,9 @@ export function CustomerMessageEditor({
|
||||
return (
|
||||
<SharedMessageEditor
|
||||
variant="customer"
|
||||
conversationId={conversationId}
|
||||
draftScope={draftScope}
|
||||
sending={sending}
|
||||
disabled={disabled}
|
||||
uploadingAsset={uploadingAsset}
|
||||
manageLocalUploading
|
||||
|
||||
Reference in New Issue
Block a user