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,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
@@ -8,15 +9,15 @@ const providerSource = await readFile(
|
||||
"utf8",
|
||||
).catch(() => "");
|
||||
const workbenchLayoutSource = await readFile(
|
||||
new URL("../app/workbench/layout.tsx", import.meta.url),
|
||||
new URL("../app/(dashboard)/workbench/layout.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const dashboardConversationsPageSource = await readFile(
|
||||
new URL("../app/dashboard/conversations/page.tsx", import.meta.url),
|
||||
new URL("../app/(dashboard)/dashboard/conversations/page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const conversationWorkbenchSource = await readFile(
|
||||
new URL("../app/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
|
||||
new URL("../app/(dashboard)/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: prevent a late logout from redirecting a newer login.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
|
||||
import { SessionProvider, useSession } from "@/components/session-provider"
|
||||
import { type AuthSession } from "@/lib/auth"
|
||||
import { readSession, type AuthSession } from "@/lib/auth"
|
||||
|
||||
type AuthContextValue = {
|
||||
session: AuthSession | null
|
||||
@@ -46,17 +48,23 @@ function DashboardAuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await endSession()
|
||||
} finally {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
// A different tab or login flow may have established a new session meanwhile.
|
||||
if (!readSession()) {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !session && requiresAuth) {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
// A different tab or login flow may have established a new session meanwhile.
|
||||
if (!readSession()) {
|
||||
startTransition(() => {
|
||||
router.replace("/dashboard/login")
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ready, requiresAuth, router, session])
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: preserve scoped drafts and guard asynchronous editor operations.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react"
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ChangeEvent } from "react"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { EditorContent, useEditor } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
@@ -32,10 +34,14 @@ import {
|
||||
MessageImageExtension,
|
||||
removeEditorImageByTitle,
|
||||
revokeEditorObjectUrl,
|
||||
revokeEditorObjectUrls,
|
||||
setEditorImageUploadingByTitle,
|
||||
type UploadedEditorImage,
|
||||
} from "@/lib/im-editor-image"
|
||||
import {
|
||||
createMessageEditorDraftStore,
|
||||
type MessageEditorDraft,
|
||||
type MessageEditorDraftStore,
|
||||
} from "@/lib/message-editor-drafts"
|
||||
import { generateUUID } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
@@ -60,6 +66,9 @@ export type SharedMessageEditorAICopilot = {
|
||||
export type SharedMessageEditorProps = {
|
||||
variant: SharedMessageEditorVariant
|
||||
conversationId?: number | null
|
||||
draftScope?: string
|
||||
draftStore?: MessageEditorDraftStore
|
||||
sending?: boolean
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
manageLocalUploading?: boolean
|
||||
@@ -79,6 +88,9 @@ export type SharedMessageEditorProps = {
|
||||
export function SharedMessageEditor({
|
||||
variant,
|
||||
conversationId,
|
||||
draftScope = "default",
|
||||
draftStore,
|
||||
sending = false,
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
manageLocalUploading = false,
|
||||
@@ -91,6 +103,18 @@ export function SharedMessageEditor({
|
||||
}: SharedMessageEditorProps) {
|
||||
const t = useI18n()
|
||||
const [localUploading, setLocalUploading] = useState(false)
|
||||
const [localDraftStore] = useState(createMessageEditorDraftStore)
|
||||
const drafts = draftStore ?? localDraftStore
|
||||
const draftSending = useSyncExternalStore(drafts.subscribeSending, drafts.isSending, () => false)
|
||||
const draftKey = String(conversationId ?? 0)
|
||||
const displayedDraftRef = useRef<{ key: string; draft: MessageEditorDraft } | null>(null)
|
||||
const contextRef = useRef({ draftKey, draftScope })
|
||||
contextRef.current = { draftKey, draftScope }
|
||||
const mountedRef = useRef(false)
|
||||
const suggestionRequestRef = useRef(0)
|
||||
const suggestionPendingRef = useRef(false)
|
||||
const uploadingRef = useRef(false)
|
||||
const mutationGenerationRef = useRef(0)
|
||||
const [quickReplySearch, setQuickReplySearch] = useState("")
|
||||
const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
|
||||
@@ -105,11 +129,10 @@ export function SharedMessageEditor({
|
||||
const onSendAttachmentRef = useRef(onSendAttachment)
|
||||
const onTypingRef = useRef(onTyping)
|
||||
const shouldRestoreFocusRef = useRef(false)
|
||||
const objectUrlsRef = useRef<Set<string>>(new Set())
|
||||
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
|
||||
const placeholderRef = useRef(t("conversation.editorPlaceholder"))
|
||||
const isCustomer = variant === "customer"
|
||||
const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
|
||||
const isSending = sending || draftSending
|
||||
|
||||
placeholderRef.current = t("conversation.editorPlaceholder")
|
||||
|
||||
@@ -118,31 +141,21 @@ export function SharedMessageEditor({
|
||||
}, [quickReplies])
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
revokeEditorObjectUrls(objectUrls)
|
||||
mountedRef.current = false
|
||||
suggestionRequestRef.current += 1
|
||||
// A supplied store belongs to the parent and survives editor unmounts.
|
||||
if (!draftStore) localDraftStore.clear()
|
||||
}
|
||||
}, [])
|
||||
}, [draftStore, localDraftStore])
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
onSendRef.current = onSend
|
||||
}, [onSend])
|
||||
|
||||
useEffect(() => {
|
||||
onUploadImageRef.current = onUploadImage
|
||||
}, [onUploadImage])
|
||||
|
||||
useEffect(() => {
|
||||
onSendAttachmentRef.current = onSendAttachment
|
||||
}, [onSendAttachment])
|
||||
|
||||
useEffect(() => {
|
||||
onTypingRef.current = onTyping
|
||||
}, [onTyping])
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestedReply(null)
|
||||
}, [conversationId])
|
||||
}, [onSend, onUploadImage, onSendAttachment, onTyping])
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
@@ -162,6 +175,8 @@ export function SharedMessageEditor({
|
||||
],
|
||||
content: "",
|
||||
onUpdate: ({ editor }) => {
|
||||
const displayed = displayedDraftRef.current
|
||||
if (displayed) drafts.update(displayed.draft, editor.getHTML())
|
||||
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
|
||||
onTypingRef.current?.()
|
||||
}
|
||||
@@ -227,7 +242,7 @@ export function SharedMessageEditor({
|
||||
return false
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (disabled || isUploading) {
|
||||
if (disabled || isUploading || isSending) {
|
||||
return false
|
||||
}
|
||||
const imageFile = getClipboardImageFile(event.clipboardData)
|
||||
@@ -241,6 +256,49 @@ export function SharedMessageEditor({
|
||||
},
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
mutationGenerationRef.current += 1
|
||||
uploadingRef.current = false
|
||||
setLocalUploading(false)
|
||||
drafts.setScope(draftScope)
|
||||
}, [drafts, draftScope])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return
|
||||
const draft = drafts.get(draftKey)
|
||||
displayedDraftRef.current = { key: draftKey, draft }
|
||||
editor.commands.setContent(draft.html, { emitUpdate: false })
|
||||
// Image metadata is retained separately while a placeholder upload settles.
|
||||
for (const [title, uploaded] of draft.uploadedImages) {
|
||||
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
|
||||
}
|
||||
suggestionRequestRef.current += 1
|
||||
suggestionPendingRef.current = false
|
||||
setSuggestedReply(null)
|
||||
setIsGeneratingSuggestion(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
}, [draftKey, draftScope, drafts, editor])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return
|
||||
// The parent-owned draft may finish sending after this editor was remounted.
|
||||
return drafts.subscribe((draft) => {
|
||||
if (editor.isDestroyed || displayedDraftRef.current?.draft !== draft) return
|
||||
if (editor.getHTML() !== draft.html) editor.commands.setContent(draft.html, { emitUpdate: false })
|
||||
for (const [title, uploaded] of draft.uploadedImages) {
|
||||
markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages)
|
||||
}
|
||||
if (!draft.html) {
|
||||
setSuggestedReply(null)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
}
|
||||
})
|
||||
}, [drafts, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -252,42 +310,45 @@ export function SharedMessageEditor({
|
||||
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [disabled, editor, isUploading])
|
||||
|
||||
async function handleSend() {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
const displayed = displayedDraftRef.current
|
||||
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
|
||||
if (displayed.key !== contextRef.current.draftKey) return
|
||||
const rawHTML = editor.getHTML()
|
||||
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) {
|
||||
return
|
||||
}
|
||||
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
|
||||
if (!isMeaningfulHTML(html)) {
|
||||
return
|
||||
}
|
||||
await onSendRef.current(html)
|
||||
editor.commands.clearContent(true)
|
||||
revokeEditorObjectUrls(objectUrlsRef.current)
|
||||
uploadedImagesRef.current.clear()
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setSuggestedReply(null)
|
||||
if (!isCustomer) {
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
if (hasUploadingEditorImages(rawHTML, displayed.draft.uploadedImages)) return
|
||||
const html = buildSendableEditorHTML(rawHTML, displayed.draft.uploadedImages)
|
||||
if (!isMeaningfulHTML(html)) return
|
||||
|
||||
const sent = drafts.beginSend(displayed.key)
|
||||
if (!sent) return
|
||||
const send = onSendRef.current
|
||||
try {
|
||||
await send(html)
|
||||
const cleared = drafts.finishSend(sent, true)
|
||||
if (!cleared || !mountedRef.current || editor.isDestroyed || displayedDraftRef.current?.draft !== sent.draft) return
|
||||
editor.commands.setContent("", { emitUpdate: false })
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setSuggestedReply(null)
|
||||
if (!isCustomer) focusEditorEnd()
|
||||
} catch {
|
||||
// The caller displays the error. Keep the entire draft, including images, for retry.
|
||||
drafts.finishSend(sent, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || !editor || disabled || isUploading) {
|
||||
if (!file || !editor || disabled || isUploading || isSending) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
@@ -295,68 +356,86 @@ export function SharedMessageEditor({
|
||||
}
|
||||
|
||||
async function insertUploadedImage(file: File) {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return
|
||||
}
|
||||
const displayed = displayedDraftRef.current
|
||||
if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
|
||||
const { key, draft } = displayed
|
||||
const upload = onUploadImageRef.current
|
||||
const generation = mutationGenerationRef.current
|
||||
const isVisible = () => mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft
|
||||
|
||||
shouldRestoreFocusRef.current = true
|
||||
uploadingRef.current = true
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrlsRef.current.add(objectUrl)
|
||||
draft.objectUrls.add(objectUrl)
|
||||
const placeholderId = `uploading-${generateUUID()}`
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
})
|
||||
.run()
|
||||
editor.chain().focus().setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
}).run()
|
||||
setEditorImageUploadingByTitle(editor, placeholderId)
|
||||
|
||||
const removePlaceholder = () => {
|
||||
if (!drafts.contains(key, draft)) return
|
||||
if (isVisible()) {
|
||||
removeEditorImageByTitle(editor, placeholderId)
|
||||
} else {
|
||||
const template = document.createElement("template")
|
||||
template.innerHTML = draft.html
|
||||
for (const image of template.content.querySelectorAll("img")) {
|
||||
if (image.getAttribute("title") === placeholderId) image.remove()
|
||||
}
|
||||
drafts.update(draft, template.innerHTML)
|
||||
drafts.notify(draft)
|
||||
}
|
||||
revokeEditorObjectUrl(draft.objectUrls, objectUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
const uploaded = await onUploadImageRef.current(file)
|
||||
const uploaded = await upload(file)
|
||||
if (!drafts.contains(key, draft)) return
|
||||
if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
|
||||
removeEditorImageByTitle(editor, placeholderId)
|
||||
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
|
||||
removePlaceholder()
|
||||
return
|
||||
}
|
||||
markEditorImageUploadedByTitle(
|
||||
editor,
|
||||
placeholderId,
|
||||
uploaded,
|
||||
uploadedImagesRef.current
|
||||
)
|
||||
draft.uploadedImages.set(placeholderId, uploaded)
|
||||
drafts.notify(draft)
|
||||
} catch {
|
||||
removePlaceholder()
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (!disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
if (generation === mutationGenerationRef.current) {
|
||||
uploadingRef.current = false
|
||||
if (mountedRef.current) setLocalUploading(false)
|
||||
}
|
||||
if (isVisible()) restoreFocusIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ""
|
||||
if (!file || disabled || isUploading) {
|
||||
if (!file || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) {
|
||||
restoreFocusIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
const sendAttachment = onSendAttachmentRef.current
|
||||
const generation = mutationGenerationRef.current
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
uploadingRef.current = true
|
||||
try {
|
||||
setLocalUploading(true)
|
||||
await onSendAttachmentRef.current(file)
|
||||
await sendAttachment(file)
|
||||
} catch {
|
||||
// The caller reports failed attachment sends; no text draft is discarded.
|
||||
} finally {
|
||||
setLocalUploading(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (editor && !disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus()
|
||||
}
|
||||
})
|
||||
if (generation === mutationGenerationRef.current) {
|
||||
uploadingRef.current = false
|
||||
if (mountedRef.current) setLocalUploading(false)
|
||||
}
|
||||
if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,15 +475,23 @@ export function SharedMessageEditor({
|
||||
quickRepliesRef.current?.onOpenChange(false)
|
||||
setQuickReplySearch("")
|
||||
isSlashTriggeredRef.current = false
|
||||
focusEditorEnd()
|
||||
}
|
||||
|
||||
function focusEditorEnd() {
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
if (editor && mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) {
|
||||
editor.commands.focus("end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function restoreFocusIfNeeded() {
|
||||
if (editor && shouldRestoreFocusRef.current) {
|
||||
const draft = displayedDraftRef.current?.draft
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus()
|
||||
if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -412,17 +499,23 @@ export function SharedMessageEditor({
|
||||
const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
|
||||
|
||||
async function handleTriggerSuggest() {
|
||||
if (!aiCopilot || disabled || isUploading || isGenerating) {
|
||||
return
|
||||
}
|
||||
if (!aiCopilot || disabled || isUploading || isGenerating || suggestionPendingRef.current) return
|
||||
const requestId = ++suggestionRequestRef.current
|
||||
suggestionPendingRef.current = true
|
||||
const context = contextRef.current
|
||||
setIsGeneratingSuggestion(true)
|
||||
try {
|
||||
const text = await aiCopilot.onSuggest()
|
||||
if (text && text.trim()) {
|
||||
if (mountedRef.current && requestId === suggestionRequestRef.current && context.draftKey === contextRef.current.draftKey && context.draftScope === contextRef.current.draftScope && text?.trim()) {
|
||||
setSuggestedReply(text.trim())
|
||||
}
|
||||
} catch {
|
||||
// The caller displays the suggestion error. Ignore any late failure here.
|
||||
} finally {
|
||||
setIsGeneratingSuggestion(false)
|
||||
if (mountedRef.current && requestId === suggestionRequestRef.current) {
|
||||
suggestionPendingRef.current = false
|
||||
setIsGeneratingSuggestion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,9 +525,7 @@ export function SharedMessageEditor({
|
||||
}
|
||||
editor.chain().focus().insertContent(suggestedReply).run()
|
||||
setSuggestedReply(null)
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus("end")
|
||||
})
|
||||
focusEditorEnd()
|
||||
}
|
||||
|
||||
function handleDismissSuggestedReply() {
|
||||
@@ -520,7 +611,7 @@ export function SharedMessageEditor({
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
imageInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
|
||||
>
|
||||
@@ -536,7 +627,7 @@ export function SharedMessageEditor({
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true
|
||||
attachmentInputRef.current?.click()
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
|
||||
>
|
||||
@@ -640,21 +731,23 @@ export function SharedMessageEditor({
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-busy={isSending}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
aria-label={t("conversation.send")}
|
||||
title={t("conversation.send")}
|
||||
className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
|
||||
>
|
||||
<SendHorizonalIcon />
|
||||
{isSending ? <Loader2Icon className="animate-spin" /> : <SendHorizonalIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-busy={isSending}
|
||||
disabled={disabled || isUploading || isSending}
|
||||
>
|
||||
<SendIcon className="mr-1 size-4" />
|
||||
{isSending ? <Loader2Icon className="mr-1 size-4 animate-spin" /> : <SendIcon className="mr-1 size-4" />}
|
||||
{isUploading ? t("conversation.uploading") : t("conversation.send")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -196,6 +197,9 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(allowedModes.length > 1
|
||||
? [{ key: "separator-mode", type: "separator" } as const]
|
||||
: []),
|
||||
{
|
||||
key: "bold",
|
||||
label: t("editor.bold"),
|
||||
@@ -337,10 +341,6 @@ export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
|
||||
},
|
||||
]
|
||||
|
||||
if (allowedModes.length > 1) {
|
||||
actions.splice(1, 0, { key: "separator-mode", type: "separator" })
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
@@ -65,17 +66,12 @@ export function ContentEditor({
|
||||
const confirm = useConfirm()
|
||||
const editorHeight = normalizeHeight(height)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const effectiveScrollMode = fullscreen ? "editor" : scrollMode
|
||||
const normalizedAllowedModes = allowedModes.length > 0 ? allowedModes : CONTENT_MODE_OPTIONS
|
||||
const activeMode = normalizedAllowedModes.includes(value.mode)
|
||||
? value.mode
|
||||
: normalizedAllowedModes[0]
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) {
|
||||
return
|
||||
@@ -185,7 +181,8 @@ export function ContentEditor({
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullscreen && mounted) {
|
||||
// Fullscreen starts false and can only be enabled by a browser event.
|
||||
if (fullscreen) {
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
@@ -26,9 +27,8 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
assignConversation,
|
||||
transferConversation,
|
||||
fetchAgentProfilesAll,
|
||||
type AdminAgentProfile,
|
||||
} from "@/lib/api/admin"
|
||||
import { fetchAvailabilityAgents, type OperatorAvailability } from "@/lib/api/support-availability"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type ConversationTransferDialogProps = {
|
||||
@@ -87,10 +87,10 @@ function ConversationTransferDialogBody({
|
||||
const t = useI18n()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadingAgents, setLoadingAgents] = useState(false)
|
||||
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
|
||||
const [agents, setAgents] = useState<OperatorAvailability[]>([])
|
||||
const userOptions = agents.map((agent) => ({
|
||||
value: String(agent.userId),
|
||||
label: agent.displayName || agent.nickname || agent.username || t("conversationAction.agentFallback", { id: agent.userId }),
|
||||
label: agent.displayName || t("conversationAction.agentFallback", { id: agent.userId }),
|
||||
}))
|
||||
|
||||
const transferSchema = useMemo(
|
||||
@@ -123,18 +123,25 @@ function ConversationTransferDialogBody({
|
||||
}, [conversationId, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setAgents([])
|
||||
setLoadingAgents(false)
|
||||
return
|
||||
}
|
||||
setLoadingAgents(true)
|
||||
fetchAgentProfilesAll()
|
||||
let active = true
|
||||
fetchAvailabilityAgents(conversationId)
|
||||
.then((data) => {
|
||||
setAgents(data.filter((item) => item.serviceStatus === 0))
|
||||
if (active) setAgents(data.filter((item) => item.eligible))
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("conversationAction.loadAgentsFailed"))
|
||||
if (active) toast.error(error instanceof Error ? error.message : t("conversationAction.loadAgentsFailed"))
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingAgents(false)
|
||||
if (active) setLoadingAgents(false)
|
||||
})
|
||||
}, [t])
|
||||
return () => { active = false }
|
||||
}, [t, conversationId])
|
||||
|
||||
async function onFormSubmit(values: TransferForm) {
|
||||
if (!conversationId) {
|
||||
@@ -194,13 +201,15 @@ function ConversationTransferDialogBody({
|
||||
value={field.value}
|
||||
options={userOptions}
|
||||
placeholder={
|
||||
loadingAgents
|
||||
!conversationId
|
||||
? t("conversationAction.conversationMissing")
|
||||
: loadingAgents
|
||||
? t("conversationAction.loading")
|
||||
: t("conversationAction.selectTargetAgent")
|
||||
}
|
||||
searchPlaceholder={t("conversationAction.searchAgent")}
|
||||
emptyText={t("conversationAction.emptyAgents")}
|
||||
disabled={saving || loadingAgents}
|
||||
disabled={saving || loadingAgents || !conversationId}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
@@ -237,7 +246,7 @@ function ConversationTransferDialogBody({
|
||||
>
|
||||
{t("conversationAction.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Button type="submit" disabled={saving || loadingAgents || !conversationId || agents.length === 0}>
|
||||
<ArrowRightLeftIcon />
|
||||
{saving
|
||||
? isAssign
|
||||
|
||||
164
web/components/lifecycle-regressions.test.mjs
Normal file
164
web/components/lifecycle-regressions.test.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
|
||||
import { deferred, loadTypeScriptModule } from "../lib/test-support/load-typescript-module.mjs"
|
||||
|
||||
const jsx = (type, props) => ({ type, props })
|
||||
const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" }
|
||||
const i18n = { useI18n: () => (key) => key }
|
||||
|
||||
// Minimal deterministic hook driver: render-phase resets rerender before effects,
|
||||
// and dependency changes/unmount run prior effect cleanup. No browser or network.
|
||||
function hooks() {
|
||||
const states = [], effects = []
|
||||
let stateIndex, effectIndex, dirty, pending
|
||||
const react = {
|
||||
useState(initial) {
|
||||
const index = stateIndex++
|
||||
if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial
|
||||
return [states[index], (next) => {
|
||||
const value = typeof next === "function" ? next(states[index]) : next
|
||||
if (!Object.is(value, states[index])) { states[index] = value; dirty = true }
|
||||
}]
|
||||
},
|
||||
useEffect(run, deps) {
|
||||
const index = effectIndex++
|
||||
if (!effects[index] || deps.some((dep, i) => !Object.is(dep, effects[index].deps[i]))) {
|
||||
pending.set(index, { run, deps })
|
||||
}
|
||||
},
|
||||
useMemo: (fn) => fn(),
|
||||
useCallback: (fn) => fn,
|
||||
useRef: (value) => ({ current: value }),
|
||||
createContext: () => ({ Provider: "Provider" }),
|
||||
}
|
||||
return { react,
|
||||
render(component, props) {
|
||||
let result, attempts = 0
|
||||
do {
|
||||
assert.ok(attempts++ < 10, "render-phase state must settle")
|
||||
stateIndex = 0; effectIndex = 0; dirty = false; pending = new Map()
|
||||
result = component(props)
|
||||
} while (dirty)
|
||||
for (const [index, effect] of pending) {
|
||||
effects[index]?.cleanup?.()
|
||||
effects[index] = { deps: effect.deps, cleanup: effect.run() }
|
||||
}
|
||||
return result
|
||||
},
|
||||
unmount() { effects.forEach((effect) => effect.cleanup?.()) },
|
||||
}
|
||||
}
|
||||
function find(node, type) {
|
||||
if (!node || typeof node !== "object") return undefined
|
||||
if (node?.type === type) return node
|
||||
const children = node?.props?.children
|
||||
for (const child of Array.isArray(children) ? children : [children]) {
|
||||
const result = find(child, type)
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
const flush = async () => { await Promise.resolve(); await Promise.resolve() }
|
||||
|
||||
test("bulk move resets selection on reopen/base switch and ignores obsolete directory responses", async () => {
|
||||
const h = hooks(), requests = []
|
||||
const { KnowledgeBulkMoveDialog } = loadTypeScriptModule(new URL("../app/(dashboard)/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx", import.meta.url), {
|
||||
react: h.react, "react/jsx-runtime": jsxRuntime,
|
||||
"@/i18n/provider": i18n,
|
||||
"@/components/option-combobox": { OptionCombobox: "Combobox" },
|
||||
"@/components/project-dialog": { ProjectDialog: "Dialog" },
|
||||
"@/components/ui/button": { Button: "Button" },
|
||||
"@/lib/api/admin": { fetchKnowledgeDirectories: (id) => { const request = deferred(); requests.push({ id, ...request }); return request.promise } },
|
||||
})
|
||||
let props = { open: true, knowledgeBaseId: 1, moving: false, selectedCount: 2, onOpenChange() {}, onSubmit() {} }
|
||||
let tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
find(tree, "Combobox").props.onChange("42")
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.value, "42")
|
||||
props = { ...props, open: false }
|
||||
h.render(KnowledgeBulkMoveDialog, props)
|
||||
props = { ...props, open: true }
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.value, "0")
|
||||
requests[0].resolve([{ id: 99, name: "obsolete" }]); await flush()
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.options.length, 1)
|
||||
requests[1].resolve([{ id: 11, name: "previous base" }]); await flush()
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.options[1].label, "previous base")
|
||||
find(tree, "Combobox").props.onChange("42")
|
||||
props = { ...props, knowledgeBaseId: 2 }
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.value, "0")
|
||||
assert.equal(find(tree, "Combobox").props.options.length, 1)
|
||||
assert.equal(requests.at(-1).id, 2)
|
||||
requests.at(-1).resolve([{ id: 22, name: "current" }]); await flush()
|
||||
tree = h.render(KnowledgeBulkMoveDialog, props)
|
||||
assert.equal(find(tree, "Combobox").props.options[1].label, "current")
|
||||
h.unmount()
|
||||
requests[1].resolve([{ id: 98, name: "old reopening" }]); await flush()
|
||||
})
|
||||
|
||||
test("palette snapshots hydrate consistently and notify same-tab and storage changes", () => {
|
||||
const events = new EventTarget(), values = new Map([["dashboard_palette", "green"]])
|
||||
const document = { documentElement: { dataset: {} } }
|
||||
let store, effect
|
||||
const { PaletteToggle } = loadTypeScriptModule(new URL("./palette-toggle.tsx", import.meta.url), {
|
||||
react: {
|
||||
useSyncExternalStore: (subscribe, getSnapshot, getServerSnapshot) => { store = { subscribe, getSnapshot, getServerSnapshot }; return getSnapshot() },
|
||||
useEffect: (run) => { effect = run },
|
||||
},
|
||||
"react/jsx-runtime": jsxRuntime, "@/i18n/provider": i18n,
|
||||
"lucide-react": { CircleIcon: "Circle", DropletsIcon: "Droplets", PaletteIcon: "Palette" },
|
||||
"@/components/ui/button": { Button: "Button" },
|
||||
"@/components/ui/dropdown-menu": Object.fromEntries(["DropdownMenu", "DropdownMenuContent", "DropdownMenuRadioGroup", "DropdownMenuRadioItem", "DropdownMenuTrigger"].map((name) => [name, name])),
|
||||
}, { window: { addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), dispatchEvent: events.dispatchEvent.bind(events), localStorage: { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) } }, document, Event })
|
||||
const tree = PaletteToggle()
|
||||
assert.equal(store.getServerSnapshot(), "plain")
|
||||
assert.equal(store.getSnapshot(), "green")
|
||||
effect(); assert.equal(document.documentElement.dataset.palette, "green")
|
||||
let changes = 0
|
||||
const unsubscribe = store.subscribe(() => { changes++ })
|
||||
find(tree, "DropdownMenuRadioGroup").props.onValueChange("blue")
|
||||
assert.equal(changes, 1)
|
||||
assert.equal(store.getSnapshot(), "blue")
|
||||
assert.equal(document.documentElement.dataset.palette, "blue")
|
||||
const storageEvent = (key) => { const event = new Event("storage"); Object.defineProperty(event, "key", { value: key }); events.dispatchEvent(event) }
|
||||
storageEvent("unrelated"); assert.equal(changes, 1)
|
||||
values.set("dashboard_palette", "gray"); storageEvent("dashboard_palette")
|
||||
assert.equal(changes, 2); assert.equal(store.getSnapshot(), "gray")
|
||||
unsubscribe(); storageEvent(null); assert.equal(changes, 2)
|
||||
})
|
||||
|
||||
test("initial notification count ignores completion after provider unmount", async () => {
|
||||
const h = hooks(), response = deferred()
|
||||
const { NotificationProvider } = loadTypeScriptModule(new URL("./notification-provider.tsx", import.meta.url), {
|
||||
react: h.react, "react/jsx-runtime": jsxRuntime,
|
||||
"@/i18n/provider": { ...i18n, useAppLocale: () => ({ locale: "en-US" }) },
|
||||
"next/navigation": { useRouter: () => ({ push() {} }) },
|
||||
sonner: { toast() {} },
|
||||
"@/lib/auth": { readSession: () => ({ user: { id: 1 }, accessToken: "isolated-fixture" }) },
|
||||
"@/lib/api/notification": { fetchNotificationUnreadCount: () => response.promise },
|
||||
"@/lib/realtime-connection": { createRealtimeConnectionManager: () => ({ connect() {}, disconnect() {} }) },
|
||||
"@/lib/notification-i18n": { localizeNotificationItem: (item) => item },
|
||||
})
|
||||
let tree = h.render(NotificationProvider, { children: null })
|
||||
assert.equal(tree.props.value.unreadCount, 0)
|
||||
h.unmount(); response.resolve({ unreadCount: 999 }); await flush()
|
||||
tree = h.render(NotificationProvider, { children: null })
|
||||
assert.equal(tree.props.value.unreadCount, 0)
|
||||
})
|
||||
|
||||
test("editor fullscreen and channel origin do not require mount-state effects", async () => {
|
||||
const editor = await readFile(new URL("./content-editor/index.tsx", import.meta.url), "utf8")
|
||||
assert.match(editor, /const \[fullscreen, setFullscreen\] = useState\(false\)/)
|
||||
assert.match(editor, /if \(fullscreen\) \{\s*return createPortal\(content, document.body\)/)
|
||||
assert.doesNotMatch(editor, /setMounted/)
|
||||
const html = await readFile(new URL("./content-editor/html-editor.tsx", import.meta.url), "utf8")
|
||||
assert.match(html, /allowedModes.length > 1\s*\? \[\{ key: "separator-mode", type: "separator" \} as const\]/)
|
||||
assert.doesNotMatch(html, /actions\.splice/)
|
||||
const channel = await readFile(new URL("../app/(dashboard)/dashboard/channels/_components/edit.tsx", import.meta.url), "utf8")
|
||||
assert.match(channel, /useSyncExternalStore\(subscribeToOrigin, readBrowserOrigin, readServerOrigin\)/)
|
||||
assert.match(channel, /const readServerOrigin = \(\) => ""/)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
@@ -5,9 +6,9 @@ import test from "node:test"
|
||||
const source = await readFile(new URL("./nav-main.tsx", import.meta.url), "utf8")
|
||||
|
||||
test("dashboard nav items expose full labels when visual text is truncated", () => {
|
||||
assert.match(source, /<span\s+title=\{title\}>\{title\}<\/span>/)
|
||||
assert.match(source, /<span\b[^>]*\btitle=\{title\}[^>]*>\s*\{title\}\s*<\/span>/)
|
||||
assert.match(source, /tooltip=\{item\.title\}/)
|
||||
assert.match(source, /<span\s+title=\{item\.title\}>\{item\.title\}<\/span>/)
|
||||
assert.match(source, /<span\b[^>]*\btitle=\{item\.title\}[^>]*>\s*\{item\.title\}\s*<\/span>/)
|
||||
})
|
||||
|
||||
test("collapsed dashboard nav sections expose submenu flyouts on hover", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
@@ -73,10 +74,14 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
currentUserIdRef.current = readSession()?.user.id ?? 0
|
||||
void refreshUnreadCount().catch(() => {
|
||||
setUnreadCount(0)
|
||||
let active = true
|
||||
void fetchNotificationUnreadCount().then((result) => {
|
||||
if (active) setUnreadCount(result.unreadCount)
|
||||
}).catch(() => {
|
||||
if (active) setUnreadCount(0)
|
||||
})
|
||||
}, [refreshUnreadCount])
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const realtime = createRealtimeConnectionManager({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
Loader2Icon,
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
SparklesIcon,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -22,9 +21,7 @@ import {
|
||||
} from "@/components/ui/popover"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
applySetupQuickFix,
|
||||
fetchSetupReadiness,
|
||||
type SetupQuickFixAction,
|
||||
type SetupReadinessResponse,
|
||||
} from "@/lib/api/setup-readiness"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -45,7 +42,6 @@ export function OperatorReadinessIndicator({
|
||||
const t = useI18n()
|
||||
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fixing, setFixing] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const loadReadiness = useCallback(async () => {
|
||||
@@ -66,30 +62,14 @@ export function OperatorReadinessIndicator({
|
||||
}
|
||||
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
const interval = window.setInterval(() => {
|
||||
void loadReadiness()
|
||||
}, 45000)
|
||||
if (document.visibilityState === "visible") void loadReadiness()
|
||||
}, 60000)
|
||||
return () => {
|
||||
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [loadReadiness])
|
||||
|
||||
async function handleQuickFix(action: SetupQuickFixAction) {
|
||||
setFixing(true)
|
||||
try {
|
||||
const res = await applySetupQuickFix({ action })
|
||||
setReadiness(res.readiness)
|
||||
notifySetupReadinessUpdated()
|
||||
toast.success(t("setupWizard.quickFixSuccess"))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("common.operationFailed"),
|
||||
)
|
||||
} finally {
|
||||
setFixing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !readiness) {
|
||||
return null
|
||||
}
|
||||
@@ -225,43 +205,10 @@ export function OperatorReadinessIndicator({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{!operatorOk && canEnableMyProfile ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-1.5 rounded-md text-xs"
|
||||
disabled={fixing}
|
||||
onClick={() =>
|
||||
void handleQuickFix(
|
||||
chainOk || !canQuickSetupAll
|
||||
? "enable_my_operator_profile"
|
||||
: "quick_setup_all",
|
||||
)
|
||||
}
|
||||
>
|
||||
{fixing ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{chainOk || !canQuickSetupAll
|
||||
? t("setupWizard.enableMyReceptionBtn")
|
||||
: t("setupWizard.quickSetupAll")}
|
||||
</span>
|
||||
</Button>
|
||||
) : !chainOk && canQuickSetupAll ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-1.5 rounded-md text-xs"
|
||||
disabled={fixing}
|
||||
onClick={() => void handleQuickFix("quick_setup_all")}
|
||||
>
|
||||
{fixing ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3.5" />
|
||||
)}
|
||||
<span>{t("setupWizard.quickSetupAll")}</span>
|
||||
{(!operatorOk && canEnableMyProfile) || (!chainOk && canQuickSetupAll) ? (
|
||||
<Button size="sm" className="w-full gap-1.5 rounded-md text-xs" render={<Link href="/dashboard/setup" />}>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
<span>{t("setupWizard.preview.review")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
@@ -31,6 +32,7 @@ export type ComboboxOption = {
|
||||
}
|
||||
|
||||
type CommonOptionComboboxProps = {
|
||||
ariaLabel?: string
|
||||
options: ComboboxOption[]
|
||||
placeholder: string
|
||||
searchPlaceholder?: string
|
||||
@@ -62,6 +64,7 @@ type OptionComboboxProps = CommonOptionComboboxProps &
|
||||
export function OptionCombobox(props: OptionComboboxProps) {
|
||||
const {
|
||||
options,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
@@ -114,6 +117,7 @@ export function OptionCombobox(props: OptionComboboxProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("m-0 w-full justify-between font-normal", triggerClassName)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import { CircleIcon, DropletsIcon, PaletteIcon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
@@ -17,6 +18,7 @@ type PaletteMode = "plain" | "blue" | "green" | "gray"
|
||||
|
||||
const PALETTE_STORAGE_KEY = "dashboard_palette"
|
||||
const DEFAULT_PALETTE: PaletteMode = "plain"
|
||||
const PALETTE_CHANGE_EVENT = "dashboard-palette-change"
|
||||
|
||||
const paletteOptions: Array<{
|
||||
value: PaletteMode
|
||||
@@ -59,27 +61,39 @@ function readPalette(): PaletteMode {
|
||||
: DEFAULT_PALETTE
|
||||
}
|
||||
|
||||
function subscribeToPalette(onChange: () => void) {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === null || event.key === PALETTE_STORAGE_KEY) onChange()
|
||||
}
|
||||
window.addEventListener("storage", onStorage)
|
||||
window.addEventListener(PALETTE_CHANGE_EVENT, onChange)
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage)
|
||||
window.removeEventListener(PALETTE_CHANGE_EVENT, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
const readServerPalette = () => DEFAULT_PALETTE
|
||||
|
||||
function applyPalette(value: PaletteMode) {
|
||||
document.documentElement.dataset.palette = value
|
||||
window.localStorage.setItem(PALETTE_STORAGE_KEY, value)
|
||||
window.dispatchEvent(new Event(PALETTE_CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export function PaletteToggle() {
|
||||
const t = useI18n()
|
||||
const [palette, setPalette] = useState<PaletteMode>(DEFAULT_PALETTE)
|
||||
const palette = useSyncExternalStore(subscribeToPalette, readPalette, readServerPalette)
|
||||
|
||||
useEffect(() => {
|
||||
const storedPalette = readPalette()
|
||||
setPalette(storedPalette)
|
||||
applyPalette(storedPalette)
|
||||
}, [])
|
||||
document.documentElement.dataset.palette = palette
|
||||
}, [palette])
|
||||
|
||||
function handleChange(value: string) {
|
||||
const nextPalette: PaletteMode =
|
||||
value === "blue" || value === "green" || value === "gray"
|
||||
? value
|
||||
: "plain"
|
||||
setPalette(nextPalette)
|
||||
applyPalette(nextPalette)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: invalidate stale profile/sign-out work and clear private account state.
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { Fragment, createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"
|
||||
|
||||
import { fetchProfile, logout } from "@/lib/api/auth"
|
||||
import {
|
||||
@@ -11,6 +13,8 @@ import {
|
||||
writeSession,
|
||||
type AuthSession,
|
||||
} from "@/lib/auth"
|
||||
import { createSessionRequestGuard } from "@/lib/session-request-guard"
|
||||
import { resetAgentConversations } from "@/lib/stores/agent-conversations"
|
||||
|
||||
type SessionContextValue = {
|
||||
session: AuthSession | null
|
||||
@@ -22,21 +26,41 @@ type SessionContextValue = {
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null)
|
||||
|
||||
function accountKey(session: AuthSession | null) {
|
||||
return session ? `${session.user.userType}:${session.user.id}` : "anonymous"
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<AuthSession | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [signingOut, setSigningOut] = useState(false)
|
||||
const [requestGuard] = useState(() => createSessionRequestGuard(readSession))
|
||||
const identityRef = useRef<string | null>(null)
|
||||
const signOutPendingRef = useRef(false)
|
||||
const signOutGenerationRef = useRef(0)
|
||||
|
||||
const applySession = useCallback((next: AuthSession | null) => {
|
||||
const nextIdentity = accountKey(next)
|
||||
if (identityRef.current !== nextIdentity) {
|
||||
resetAgentConversations()
|
||||
identityRef.current = nextIdentity
|
||||
}
|
||||
setSession(next)
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
const stored = readSession()
|
||||
if (!stored) {
|
||||
setSession(null)
|
||||
setReady(true)
|
||||
requestGuard.invalidate()
|
||||
applySession(null)
|
||||
return
|
||||
}
|
||||
const isCurrent = requestGuard.begin(stored)
|
||||
|
||||
try {
|
||||
const profile = await fetchProfile()
|
||||
if (!isCurrent()) return
|
||||
const nextSession: AuthSession = {
|
||||
...stored,
|
||||
user: profile.user,
|
||||
@@ -45,60 +69,76 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
accessToken: profile.accessToken || stored.accessToken,
|
||||
expiresAt: profile.expiresAt || stored.expiresAt,
|
||||
}
|
||||
applySession(nextSession)
|
||||
writeSession(nextSession)
|
||||
setSession(nextSession)
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return
|
||||
const errorCode = (error as Error & { errorCode?: number }).errorCode
|
||||
if (errorCode === 3000 || errorCode === 3002) {
|
||||
clearSession()
|
||||
setSession(null)
|
||||
applySession(null)
|
||||
} else {
|
||||
setSession(stored)
|
||||
applySession(stored)
|
||||
}
|
||||
} finally {
|
||||
setReady(true)
|
||||
if (isCurrent()) setReady(true)
|
||||
}
|
||||
}, [])
|
||||
}, [applySession, requestGuard])
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
if (signingOut) return
|
||||
if (signOutPendingRef.current) return
|
||||
signOutPendingRef.current = true
|
||||
const generation = ++signOutGenerationRef.current
|
||||
setSigningOut(true)
|
||||
requestGuard.invalidate()
|
||||
// Start the request with the old token, then immediately remove local access.
|
||||
const pending = logout()
|
||||
clearSession()
|
||||
applySession(null)
|
||||
try {
|
||||
await logout()
|
||||
await pending
|
||||
} catch {
|
||||
clearSession()
|
||||
// Local sign-out already completed; a late error cannot clear a new login.
|
||||
} finally {
|
||||
setSession(null)
|
||||
setSigningOut(false)
|
||||
if (generation === signOutGenerationRef.current) {
|
||||
signOutPendingRef.current = false
|
||||
setSigningOut(false)
|
||||
}
|
||||
}
|
||||
}, [signingOut])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession()
|
||||
}, [refreshSession])
|
||||
}, [applySession, requestGuard])
|
||||
|
||||
useEffect(() => {
|
||||
const syncSession = () => {
|
||||
setSession(readSession())
|
||||
setReady(true)
|
||||
requestGuard.invalidate()
|
||||
const current = readSession()
|
||||
if (current && signOutPendingRef.current) {
|
||||
signOutGenerationRef.current += 1
|
||||
signOutPendingRef.current = false
|
||||
setSigningOut(false)
|
||||
}
|
||||
applySession(current)
|
||||
}
|
||||
const syncOtherTab = (event: StorageEvent) => {
|
||||
if (event.key === "agent-desk-session") syncSession()
|
||||
if (event.key === "agent-desk-session" || event.key === null) syncSession()
|
||||
}
|
||||
|
||||
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
|
||||
window.addEventListener("storage", syncOtherTab)
|
||||
applySession(readSession())
|
||||
void refreshSession()
|
||||
return () => {
|
||||
requestGuard.invalidate()
|
||||
signOutGenerationRef.current += 1
|
||||
window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||
window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
|
||||
window.removeEventListener("storage", syncOtherTab)
|
||||
}
|
||||
}, [])
|
||||
}, [applySession, refreshSession, requestGuard])
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}>
|
||||
{children}
|
||||
<Fragment key={accountKey(session)}>{children}</Fragment>
|
||||
</SessionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { OperatorStatusControl } from "@/components/support-availability/operator-status"
|
||||
import { OperatorReadinessIndicator } from "@/components/operator-readiness-indicator"
|
||||
import { PaletteToggle } from "@/components/palette-toggle"
|
||||
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
||||
@@ -78,6 +80,7 @@ export function SiteHeader() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<OperatorStatusControl compact />
|
||||
<OperatorReadinessIndicator compact />
|
||||
<PaletteToggle />
|
||||
<ThemeToggle />
|
||||
|
||||
112
web/components/support-availability/lifecycle.test.mjs
Normal file
112
web/components/support-availability/lifecycle.test.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
async function compile(path, imports) {
|
||||
const source = await readFile(new URL(path, import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } })
|
||||
const loaded = { exports: {} }
|
||||
vm.runInNewContext(compiled.outputText, { exports: loaded.exports, module: loaded, Intl, Date, Set, AbortController, setTimeout, clearTimeout, require: name => { assert.ok(name in imports, `unexpected import ${name}`); return imports[name] } })
|
||||
return loaded.exports
|
||||
}
|
||||
const helpers = await compile("../../lib/support-availability.ts", {})
|
||||
const enums = await compile("../../lib/generated/enums.ts", {})
|
||||
const mappings = await compile("../../lib/support-availability-i18n.ts", { "@/lib/generated/enums": enums })
|
||||
const guard = await compile("../../lib/session-request-guard.ts", {})
|
||||
const sessionA = { user: { id: 1 }, accessToken: "fixture-a" }
|
||||
const sessionB = { user: { id: 2 }, accessToken: "fixture-b" }
|
||||
const calendar = { teamId: 5, mode: "recurring", timezone: "UTC", effectiveTimezone: "UTC", deploymentTimezone: "UTC", weekly: [], exceptions: [] }
|
||||
const operator = { userId: 1, serviceStatus: 1, effectiveServiceStatus: 1, returnServiceStatus: 0, reason: "busy", online: true, calendar }
|
||||
function deferred() { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } }
|
||||
function find(node, predicate) {
|
||||
if (!node || typeof node !== "object") return null
|
||||
if (predicate(node)) return node
|
||||
for (const value of Object.values(node.props ?? {})) {
|
||||
for (const child of Array.isArray(value) ? value.flat(Infinity) : [value]) {
|
||||
const found = find(child, predicate)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
async function harness(kind, options = {}) {
|
||||
let session = sessionA, stateIndex = 0
|
||||
const effects = [], calls = [], saved = []
|
||||
const apiResult = options.apiResult ?? Promise.resolve(operator)
|
||||
const formValues = options.formValues ?? { serviceStatus: "1", start: "", end: "", startOccurrence: "", endOccurrence: "" }
|
||||
const jsx = (type, props, key) => ({ type, props, key })
|
||||
const controls = new Proxy({}, { get: (_target, name) => name })
|
||||
const imports = {
|
||||
"react/jsx-runtime": { jsx, jsxs: jsx, Fragment: "Fragment" },
|
||||
react: { useRef: value => ({ current: value }), useState: value => { const current = stateIndex++; return [current === 0 && options.editing ? true : typeof value === "function" ? value() : value, () => {}] }, useEffect: fn => { effects.push(fn) }, useCallback: fn => fn },
|
||||
"react-hook-form": { Controller: "Controller", useFieldArray: () => ({ fields: [] }), useForm: () => ({ control: {}, register: () => ({}), handleSubmit: fn => fn, watch: key => key ? formValues[key] : calendar, setError: () => {}, clearErrors: () => {}, reset: () => {}, formState: { errors: {}, isDirty: true } }) },
|
||||
"lucide-react": controls, "next/link": { default: "Link" }, sonner: { toast: { success: () => {} } },
|
||||
"@/components/auth-provider": { useAuth: () => ({ session }) },
|
||||
"@/components/confirm-provider": { useConfirm: () => options.confirm ?? (async () => true) },
|
||||
"@/components/option-combobox": controls, "@/components/project-dialog": controls,
|
||||
"@/components/ui/badge": controls, "@/components/ui/button": controls, "@/components/ui/field": controls, "@/components/ui/input": controls, "@/components/ui/switch": controls,
|
||||
"@/i18n/provider": { useI18n: () => key => key, useAppLocale: () => ({ locale: "en-US" }) },
|
||||
"@/lib/auth": { readSession: () => session }, "@/lib/session-request-guard": guard,
|
||||
"@/lib/generated/enums": enums, "@/lib/support-availability-i18n": mappings, "@/lib/support-availability": helpers,
|
||||
"@/components/support-availability/operator-status": { notifyAvailabilityUpdated: () => {} },
|
||||
"@/lib/api/support-availability": { updateMyAvailability: payload => { calls.push(payload); return apiResult }, updateAgentAvailability: (id, payload) => { calls.push({ id, payload }); return apiResult }, updateSupportCalendar: payload => { calls.push(payload); return options.apiResult ?? Promise.resolve(calendar) } },
|
||||
}
|
||||
const file = kind === "operator" ? "./operator-status.tsx" : "../../app/(dashboard)/dashboard/support-availability/_components/calendar-editor.tsx"
|
||||
const loaded = await compile(file, imports)
|
||||
const root = kind === "operator" ? loaded.OperatorStatusDialog({ value: operator, onClose() {}, onSaved: value => saved.push(value) }) : loaded.CalendarEditor({ initial: calendar, writable: options.writable ?? true, onDirtyChange() {}, onSaved: value => saved.push(value) })
|
||||
// Execute only lifetime effects: calendar also registers browser navigation listeners.
|
||||
const lifetime = effects.filter(fn => fn.toString().includes("mounted.current = true"))
|
||||
assert.equal(lifetime.length, 1)
|
||||
const setup = () => lifetime[0]()
|
||||
const submit = find(root, node => node.type === "form").props.onSubmit
|
||||
return { calls, saved, setup, submit: () => submit(kind === "operator" ? formValues : calendar), switchSession: () => { session = sessionB } }
|
||||
}
|
||||
|
||||
test("operator save still completes after StrictMode setup-cleanup-setup", async () => {
|
||||
const h = await harness("operator")
|
||||
h.setup()(); h.setup()
|
||||
await h.submit()
|
||||
assert.equal(h.calls.length, 1)
|
||||
assert.equal(h.saved.length, 1)
|
||||
})
|
||||
test("operator save is single-flight and a late prior-session result is ignored", async () => {
|
||||
const pending = deferred()
|
||||
const h = await harness("operator", { apiResult: pending.promise })
|
||||
h.setup()
|
||||
const first = h.submit(), second = h.submit()
|
||||
h.switchSession(); pending.resolve(operator)
|
||||
await Promise.all([first, second])
|
||||
assert.equal(h.calls.length, 1)
|
||||
assert.equal(h.saved.length, 0)
|
||||
})
|
||||
test("operator controls from a stale session cannot submit a mutation", async () => {
|
||||
const h = await harness("operator")
|
||||
h.setup(); h.switchSession(); await h.submit()
|
||||
assert.equal(h.calls.length, 0)
|
||||
})
|
||||
test("read-only calendar or preview Enter cannot save even when form appears dirty", async () => {
|
||||
for (const writable of [false, true]) {
|
||||
const h = await harness("calendar", { writable })
|
||||
h.setup(); await h.submit()
|
||||
assert.equal(h.calls.length, 0)
|
||||
}
|
||||
})
|
||||
test("calendar confirmation cannot cross session or unmount boundaries", async () => {
|
||||
for (const action of ["session", "unmount"]) {
|
||||
const approval = deferred()
|
||||
const h = await harness("calendar", { editing: true, confirm: () => approval.promise })
|
||||
const cleanup = h.setup()
|
||||
const submit = h.submit()
|
||||
if (action === "session") h.switchSession(); else cleanup()
|
||||
approval.resolve(true); await submit
|
||||
assert.equal(h.calls.length, 0)
|
||||
}
|
||||
})
|
||||
test("calendar save completes after StrictMode restart and explicit confirmation", async () => {
|
||||
const h = await harness("calendar", { editing: true })
|
||||
h.setup()(); h.setup(); await h.submit()
|
||||
assert.equal(h.calls.length, 1)
|
||||
assert.equal(h.saved.length, 1)
|
||||
})
|
||||
132
web/components/support-availability/operator-status.tsx
Normal file
132
web/components/support-availability/operator-status.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { Clock3Icon, Loader2Icon, RefreshCwIcon } from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useConfirm } from "@/components/confirm-provider"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { ProjectDialog } from "@/components/project-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { fetchAvailabilityAgents, fetchMyAvailability, updateAgentAvailability, updateMyAvailability, type OperatorAvailability } from "@/lib/api/support-availability"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { ServiceStatus } from "@/lib/generated/enums"
|
||||
import { availabilityReasonLabel, availabilityStatusLabel, availabilityStatusOptions } from "@/lib/support-availability-i18n"
|
||||
import { formatAvailabilityTime, localDateTimeCandidates, selectedLocalDateTimeISO, toLocalDateTime } from "@/lib/support-availability"
|
||||
|
||||
export const AVAILABILITY_UPDATED_EVENT = "support-availability-updated"
|
||||
export function notifyAvailabilityUpdated() { window.dispatchEvent(new Event(AVAILABILITY_UPDATED_EVENT)) }
|
||||
|
||||
export function OperatorStatusControl(props: { userId?: number; compact?: boolean }) {
|
||||
const { session } = useAuth()
|
||||
return <OperatorStatusControlContent key={session?.user.id ?? "signed-out"} {...props} />
|
||||
}
|
||||
function OperatorStatusControlContent({ userId, compact = false }: { userId?: number; compact?: boolean }) {
|
||||
const t = useI18n()
|
||||
const { session } = useAuth()
|
||||
const [value, setValue] = useState<OperatorAvailability | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const accountId = session?.user.id
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let inFlight = false
|
||||
let controller: AbortController | null = null
|
||||
async function load() {
|
||||
if (inFlight || !accountId) return
|
||||
inFlight = true
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const data = userId ? (await fetchAvailabilityAgents(undefined, controller.signal)).find(item => item.userId === userId) ?? null : await fetchMyAvailability(controller.signal)
|
||||
if (active) { setValue(data); setError(false) }
|
||||
} catch { if (active) { setError(true); setValue(null) } }
|
||||
finally { inFlight = false; if (active) setLoading(false) }
|
||||
}
|
||||
void load()
|
||||
const refresh = () => { if (document.visibilityState === "visible") void load() }
|
||||
window.addEventListener(AVAILABILITY_UPDATED_EVENT, refresh)
|
||||
window.addEventListener("focus", refresh)
|
||||
const timer = window.setInterval(refresh, 30000)
|
||||
return () => { active = false; controller?.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); window.removeEventListener(AVAILABILITY_UPDATED_EVENT, refresh) }
|
||||
}, [accountId, userId, refreshKey])
|
||||
// Responses are scoped to the mounted authenticated header/dialog; never show a previous account.
|
||||
if (!accountId || (value && !userId && value.userId !== accountId)) return null
|
||||
if (!value && !error) return loading ? <span className="inline-flex h-8 w-8 items-center justify-center" role="status" aria-label={t("supportAvailability.loading")}><Loader2Icon className="size-4 animate-spin" /></span> : null
|
||||
return <>
|
||||
<Button variant="outline" size="sm" className="h-8 max-w-56 gap-1.5 rounded-md px-2" title={t("supportAvailability.myStatus")} aria-label={t("supportAvailability.myStatus")} onClick={() => error ? setRefreshKey(key => key + 1) : setOpen(true)}>
|
||||
{error ? <RefreshCwIcon className="size-4 shrink-0" /> : <Clock3Icon className="size-4 shrink-0" />}
|
||||
<span className={compact ? "hidden truncate md:inline" : "truncate"}>{error ? t("supportAvailability.retryStatus") : value ? availabilityStatusLabel(value.effectiveServiceStatus, t) : t("supportAvailability.myStatus")}</span>
|
||||
</Button>
|
||||
{open && value ? <OperatorStatusDialog key={`${accountId}:${userId ?? "me"}`} value={value} admin={Boolean(userId)} onClose={() => setOpen(false)} onSaved={next => { setValue(next); notifyAvailabilityUpdated() }} /> : null}
|
||||
</>
|
||||
}
|
||||
|
||||
type StatusForm = { serviceStatus: string; start: string; end: string; startOccurrence: string; endOccurrence: string }
|
||||
export function OperatorStatusDialog({ value, admin = false, onClose, onSaved }: { value: OperatorAvailability; admin?: boolean; onClose: () => void; onSaved: (value: OperatorAvailability) => void }) {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const confirm = useConfirm()
|
||||
const { session } = useAuth()
|
||||
const capturedSession = useRef(session)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [failure, setFailure] = useState("")
|
||||
const savingRef = useRef(false)
|
||||
const form = useForm<StatusForm>({ defaultValues: { serviceStatus: String(value.serviceStatus), start: toLocalDateTime(value.absenceStartAt), end: toLocalDateTime(value.absenceEndAt), startOccurrence: value.absenceStartAt ? new Date(value.absenceStartAt).toISOString() : "", endOccurrence: value.absenceEndAt ? new Date(value.absenceEndAt).toISOString() : "" } })
|
||||
const { register, control, handleSubmit, watch, setError, formState: { errors, isDirty } } = form
|
||||
const vacation = Number(watch("serviceStatus")) === ServiceStatus.Vacation
|
||||
const startISO = selectedLocalDateTimeISO(watch("start"), watch("startOccurrence")), endISO = selectedLocalDateTimeISO(watch("end"), watch("endOccurrence"))
|
||||
async function close() {
|
||||
if (savingRef.current) return
|
||||
if (isDirty && !(await confirm({ title: t("supportAvailability.discardTitle"), description: t("supportAvailability.discardDescription"), confirmText: t("supportAvailability.discard") }))) return
|
||||
onClose()
|
||||
}
|
||||
async function save(fields: StatusForm) {
|
||||
if (savingRef.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
const serviceStatus = Number(fields.serviceStatus) as ServiceStatus
|
||||
if (serviceStatus === ServiceStatus.Vacation) {
|
||||
const start = selectedLocalDateTimeISO(fields.start, fields.startOccurrence), end = selectedLocalDateTimeISO(fields.end, fields.endOccurrence)
|
||||
if (!start || !end || Date.parse(start) >= Date.parse(end)) { setError("end", { message: t("supportAvailability.invalidVacation") }); return }
|
||||
}
|
||||
savingRef.current = true; setSaving(true); setFailure("")
|
||||
try {
|
||||
const payload = { serviceStatus, ...(serviceStatus === ServiceStatus.Vacation ? { absenceStartAt: selectedLocalDateTimeISO(fields.start, fields.startOccurrence)!, absenceEndAt: selectedLocalDateTimeISO(fields.end, fields.endOccurrence)! } : {}) }
|
||||
const result = admin ? await updateAgentAvailability(value.userId, payload) : await updateMyAvailability(payload)
|
||||
if (!mounted.current || !isSameSession(capturedSession.current, readSession())) return
|
||||
onSaved(result); toast.success(t("supportAvailability.statusSaved")); onClose()
|
||||
} catch (error) { if (mounted.current) setFailure(error instanceof Error ? error.message : t("supportAvailability.saveFailed")) }
|
||||
finally { savingRef.current = false; if (mounted.current) setSaving(false) }
|
||||
}
|
||||
return <ProjectDialog open onOpenChange={next => { if (!next) void close() }} title={admin ? t("supportAvailability.operatorStatus", { name: value.displayName || String(value.userId) }) : t("supportAvailability.myStatus")} description={t("supportAvailability.presenceHelp")} size="md" footer={<><Button variant="outline" disabled={saving} onClick={() => void close()}>{t("supportAvailability.cancel")}</Button><Button type="submit" form="operator-availability-form" disabled={saving || !isDirty}>{saving ? <Loader2Icon className="size-4 animate-spin" /> : null}{t(saving ? "supportAvailability.saving" : "supportAvailability.saveStatus")}</Button></>}>
|
||||
<div className="mb-4 space-y-2 rounded-md border p-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2"><Badge variant="outline">{t(value.online ? "supportAvailability.online" : "supportAvailability.offline")}</Badge><Badge variant="outline">{availabilityStatusLabel(value.effectiveServiceStatus, t)}</Badge></div>
|
||||
<p>{availabilityReasonLabel(value.reason, t)}</p>
|
||||
{value.nextOpeningAt ? <p className="text-muted-foreground">{t("supportAvailability.nextOpening", { time: formatAvailabilityTime(value.nextOpeningAt, value.calendar.effectiveTimezone, locale) })}</p> : null}
|
||||
</div>
|
||||
<form id="operator-availability-form" onSubmit={handleSubmit(save)} className="space-y-4">
|
||||
<Field><FieldLabel>{t("supportAvailability.workingStatus")}</FieldLabel><Controller control={control} name="serviceStatus" render={({ field }) => <OptionCombobox options={availabilityStatusOptions(t)} value={field.value} onChange={field.onChange} ariaLabel={t("supportAvailability.workingStatus")} placeholder={t("supportAvailability.workingStatus")} disabled={saving} />} /></Field>
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.statusHelp")}</p>
|
||||
{vacation ? <>
|
||||
<p className="text-sm text-muted-foreground">{t("supportAvailability.vacationTimezone", { timezone: browserTimezone })}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field><FieldLabel htmlFor="absence-start">{t("supportAvailability.vacationStart")}</FieldLabel><Input id="absence-start" type="datetime-local" disabled={saving} {...register("start")} /></Field>
|
||||
<Field data-invalid={Boolean(errors.end)}><FieldLabel htmlFor="absence-end">{t("supportAvailability.vacationEnd")}</FieldLabel><Input id="absence-end" type="datetime-local" disabled={saving} {...register("end")} aria-invalid={Boolean(errors.end)} /><FieldError errors={[errors.end]} /></Field>
|
||||
</div>
|
||||
{(["start", "end"] as const).map(name => localDateTimeCandidates(watch(name)).length > 1 ? <Field key={name}><FieldLabel>{t("supportAvailability.dstOccurrence", { field: t(name === "start" ? "supportAvailability.vacationStart" : "supportAvailability.vacationEnd") })}</FieldLabel><Controller control={control} name={name === "start" ? "startOccurrence" : "endOccurrence"} render={({ field }) => <OptionCombobox value={selectedLocalDateTimeISO(watch(name), field.value) ?? ""} options={localDateTimeCandidates(watch(name)).map(value => ({ value, label: value }))} onChange={field.onChange} disabled={saving} ariaLabel={t("supportAvailability.chooseOccurrence")} placeholder={t("supportAvailability.chooseOccurrence")} />} /></Field> : null)}
|
||||
{startISO && endISO ? <p className="break-words text-xs text-muted-foreground">{t("supportAvailability.vacationExact", { start: startISO, end: endISO })}</p> : null}
|
||||
<p className="text-xs text-muted-foreground">{t("supportAvailability.vacationHelp", { status: availabilityStatusLabel(value.serviceStatus === ServiceStatus.Vacation ? value.returnServiceStatus ?? value.effectiveServiceStatus : value.effectiveServiceStatus, t) })}</p>
|
||||
</> : value.serviceStatus === ServiceStatus.Vacation ? <p className="text-sm text-muted-foreground">{t("supportAvailability.cancelVacationHelp")}</p> : null}
|
||||
{failure ? <p role="alert" className="text-sm text-destructive">{failure}</p> : null}
|
||||
</form>
|
||||
</ProjectDialog>
|
||||
}
|
||||
47
web/components/support-availability/public-status.tsx
Normal file
47
web/components/support-availability/public-status.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAppLocale, useI18n } from "@/i18n/provider"
|
||||
import { fetchPublicSupportStatus, type PublicSupportStatus } from "@/lib/api/support-availability"
|
||||
import { formatAvailabilityTime } from "@/lib/support-availability"
|
||||
|
||||
// Public server aggregates only. This never reads employee identities or private absence data.
|
||||
// A successful HTTP check is not a socket connection and is not itself human presence.
|
||||
export function PublicSupportAvailability({ identityScope }: { identityScope: string }) {
|
||||
return <PublicSupportAvailabilityContent key={identityScope} />
|
||||
}
|
||||
function PublicSupportAvailabilityContent() {
|
||||
const t = useI18n()
|
||||
const { locale } = useAppLocale()
|
||||
const [status, setStatus] = useState<PublicSupportStatus | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [retry, setRetry] = useState(0)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let inFlight = false
|
||||
let controller: AbortController | null = null
|
||||
async function check() {
|
||||
if (inFlight) return
|
||||
inFlight = true
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const result = await fetchPublicSupportStatus(controller.signal)
|
||||
if (active) { setStatus(result); setFailed(false) }
|
||||
} catch { if (active) { setStatus(null); setFailed(true) } }
|
||||
finally { inFlight = false }
|
||||
}
|
||||
void check()
|
||||
const refresh = () => { if (document.visibilityState === "visible") void check() }
|
||||
window.addEventListener("focus", refresh)
|
||||
document.addEventListener("visibilitychange", refresh)
|
||||
const timer = window.setInterval(refresh, 30000)
|
||||
return () => { active = false; controller?.abort(); window.clearInterval(timer); window.removeEventListener("focus", refresh); document.removeEventListener("visibilitychange", refresh) }
|
||||
}, [retry])
|
||||
const reason = status?.available ? "available" : status && ["outside_hours", "temporarily_absent", "capacity"].includes(status.reason) ? status.reason : "unknown"
|
||||
return <div className="mt-2 flex items-start justify-between gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground" aria-live="polite">
|
||||
<div className="min-w-0 space-y-1"><p>{t(!status && !failed ? "supportAvailability.publicChecking" : `supportAvailability.public.${reason}`)}</p>{status?.nextOpeningAt ? <p>{t("supportAvailability.publicNextOpening", { time: formatAvailabilityTime(status.nextOpeningAt, status.timezone, locale) })}</p> : null}</div>
|
||||
{failed ? <Button type="button" variant="ghost" size="icon" className="size-6 shrink-0" aria-label={t("supportAvailability.retry")} onClick={() => setRetry(value => value + 1)}><RefreshCwIcon className="size-3" /></Button> : null}
|
||||
</div>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in this independent fork for support availability; see docs/hardening/README.md.
|
||||
"use client"
|
||||
|
||||
import { BellIcon, KeyRoundIcon, LogOutIcon, UserIcon } from "lucide-react"
|
||||
@@ -5,6 +6,7 @@ import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
||||
import { OperatorStatusControl } from "@/components/support-availability/operator-status"
|
||||
import { OperatorReadinessIndicator } from "@/components/operator-readiness-indicator"
|
||||
import { PaletteToggle } from "@/components/palette-toggle"
|
||||
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
|
||||
@@ -33,7 +35,8 @@ export function WorkbenchHeader() {
|
||||
<header className="flex h-(--header-height) shrink-0 items-center border-b border-border/70 bg-background/88 backdrop-blur supports-[backdrop-filter]:bg-background/76">
|
||||
<div className="flex w-full min-w-0 items-center justify-end gap-3 px-3 lg:px-4">
|
||||
<div className="flex min-w-0 items-center justify-end gap-2">
|
||||
<OperatorReadinessIndicator />
|
||||
<OperatorStatusControl compact />
|
||||
<OperatorReadinessIndicator compact />
|
||||
<div className="hidden sm:block">
|
||||
<RealtimeConnectionStatus status={realtimeStatus} compact />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified by the independent AgentDesk fork for support reliability and availability.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, it } from "node:test";
|
||||
@@ -26,7 +27,9 @@ it("does not auto-open the rail menu from focus or hover events", async () => {
|
||||
it("centers the dashboard switcher logo and shows a collapsed switch indicator", async () => {
|
||||
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:p-0!/);
|
||||
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:justify-center/);
|
||||
assert.match(appSidebarSource, /className="relative data-\[slot=sidebar-menu-button\]:p-1\.5! group-data-\[collapsible=icon\]:justify-center group-data-\[collapsible=icon\]:p-0!"/);
|
||||
assert.match(appSidebarSource, /<WorkspaceSwitcher\s+currentWorkspace="dashboard"\s+variant="sidebar"\s*\/>/);
|
||||
assert.match(source, /variant === "sidebar" &&\s*"relative data-\[slot=sidebar-menu-button\]:p-1\.5! group-data-\[collapsible=icon\]:justify-center group-data-\[collapsible=icon\]:p-0!/);
|
||||
assert.match(source, /trigger \?\? <Button variant="ghost" className=\{triggerClassName\} \/>/);
|
||||
assert.match(source, /const switchIndicatorClassName =\s*"absolute bottom-0\.5 right-0\.5 size-2\.5/);
|
||||
assert.match(source, /variant === "rail" \? \([\s\S]*<ChevronsUpDownIcon className=\{switchIndicatorClassName\} \/>/);
|
||||
assert.match(source, /className=\{cn\(switchIndicatorClassName, "hidden group-data-\[collapsible=icon\]:block"\)\}/);
|
||||
|
||||
Reference in New Issue
Block a user