"use client"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { ChevronRight, CircleCheck, Compass, Info, LoaderCircle, MessageSquarePlus, SendHorizontal, TriangleAlert, WifiOff } from "lucide-react"; import { LOTSE_CHAT_IDLE, type ChatMessageView, type ChatView, type LotseChatActionState, type LotseChatErrorCode } from "@/lib/lotse/chat"; import { confirmAllProposalsAction, confirmProposalAction, discardProposalAction, newConversationAction, sendChatMessageAction } from "@/server/actions/lotse/chat"; import { cn } from "@/lib/utils"; import { chip, inputClass, noticeError, noticeWarn } from "@/components/field/ui"; import { LotseMark } from "../lotse-mark"; import { MicButton } from "./mic-button"; import { ProposalCard } from "./proposal-card"; type Action = (prev: LotseChatActionState, fd: FormData) => Promise; const draftKey = (workOrderId: string | null) => `craftvia:lotse-chat-draft:${workOrderId ?? "general"}`; /** next-intl typing does not know dynamic keys with values — loose signature for server-provided keys. */ type LooseT = (key: string, values?: Record) => string; function subscribeOnline(cb: () => void) { window.addEventListener("online", cb); window.addEventListener("offline", cb); return () => { window.removeEventListener("online", cb); window.removeEventListener("offline", cb); }; } /** * Lotse chat for technicians (lane L16, `/m/lotse`): history, input with microphone key, chips, jump * links and action cards. Needs a connection — offline the input stays (also in localStorage) and * nothing is sent; there is no offline execution. */ export function LotseChat({ initial, workOrderId, configured, transcription, locale, timeZone, quotaExhausted = false, }: { initial: ChatView; workOrderId: string | null; configured: boolean; transcription: boolean; locale: string; timeZone: string; /** L17 Pakete: monthly chat quota used up with hard limit → hint instead of the input */ quotaExhausted?: boolean; }) { const t = useTranslations("lotse"); const router = useRouter(); const [view, setView] = useState(initial); const [text, setText] = useState(""); const [pending, setPending] = useState(null); const [error, setError] = useState(null); const [micError, setMicError] = useState(null); const online = useSyncExternalStore(subscribeOnline, () => navigator.onLine, () => true); const [sentText, setSentText] = useState(null); const endRef = useRef(null); useEffect(() => { let saved: string | null = null; try { saved = window.localStorage.getItem(draftKey(workOrderId)); } catch { // storage unavailable (private mode) — draft only in memory } if (!saved) return; const restore = setTimeout(() => setText((current) => current || saved), 0); return () => clearTimeout(restore); }, [workOrderId]); useEffect(() => { try { if (text) window.localStorage.setItem(draftKey(workOrderId), text); else window.localStorage.removeItem(draftKey(workOrderId)); } catch { // ignore } }, [text, workOrderId]); useEffect(() => { endRef.current?.scrollIntoView({ block: "end" }); }, [view.messages.length, pending]); async function run(key: string, action: Action, values: Record): Promise { if (!navigator.onLine) { setError("offline"); return false; } const fd = new FormData(); for (const [k, v] of Object.entries(values)) if (v) fd.set(k, v); setPending(key); setError(null); try { const res = await action(LOTSE_CHAT_IDLE, fd); if (res.status === "ok") { setView(res.view); return true; } if (res.status === "error") { setError(res.code); if (res.view) setView(res.view); } return false; } catch { setError(navigator.onLine ? "generic" : "offline"); return false; } finally { setPending(null); } } async function send(value: string) { const message = value.trim(); if (!message || pending) return; setSentText(message); const ok = await run("send", sendChatMessageAction, { text: message, conversationId: view.conversationId, workOrderId }); setSentText(null); if (ok && message === text.trim()) setText(""); } async function confirm(proposalId: string, edits: Record | null) { const ok = await run(`confirm:${proposalId}`, confirmProposalAction, { proposalId, conversationId: view.conversationId, edits: edits ? JSON.stringify(edits) : null }); if (ok) router.refresh(); // clock bar / order status in the shell } const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user"); const sendLocked = quotaExhausted || error === "quota_exhausted"; const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null; function notice(m: ChatMessageView): string | null { const key = m.content.noticeKey; if (!key || !t.has(`chat.system.${key}`)) return null; const values = { ...(m.content.noticeValues ?? {}) } as Record; if (key === "failed") { const code = String(values.code ?? "generic"); values.reason = t.has(`chat.errors.${code}`) ? t(`chat.errors.${code}`) : t("chat.errors.generic"); } return (t as unknown as LooseT)(`chat.system.${key}`, values); } return (

{view.workOrder ? t("chat.context", { number: view.workOrder.number, title: view.workOrder.title }) : t("chat.noContext")}

{view.messages.length === 0 && (

{t("chat.intro")}

)}
    {view.messages.map((m) => { const openCards = m.proposals.filter((p) => p.status === "proposed"); const noticeText = notice(m); if (m.role === "user") { return (
  1. {t("chat.userLabel")}: {m.text}

  2. ); } if (m.role === "tool") { return (
  3. {noticeText && (

    {noticeText}

    )}
  4. ); } return (
  5. {m.text &&

    {m.text}

    } {noticeText && (

    {noticeText}

    )}
    {m.proposals.length > 0 && (
    {m.proposals.map((p) => ( confirm(p.id, edits)} onDiscard={() => run(`discard:${p.id}`, discardProposalAction, { proposalId: p.id, conversationId: view.conversationId })} /> ))} {openCards.length > 1 && ( )}
    )} {m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
    {m.content.chips.map((c) => ( ))}
    )}
  6. ); })} {pending === "send" && sentText && (
  7. {sentText}

    {t("chat.sending")}

  8. )}
{!configured && (

{t("chat.notConfigured")}

)} {!online && (

{t("chat.offline")}

)} {errorText && error !== "quota_exhausted" && (

{errorText}

)} {sendLocked ? (

{t("chat.quotaExhausted")}

) : (
{ e.preventDefault(); void send(text); }} >