diff --git a/v5/messages/ar.json b/v5/messages/ar.json index 16c21e5..986ef66 100644 --- a/v5/messages/ar.json +++ b/v5/messages/ar.json @@ -1,5 +1,8 @@ { "nav": { + "report": "إبلاغ", + "reportAria": "الإبلاغ عن مشكلة", + "reportSeed": "أريد الإبلاغ عن مشكلة.", "tools": "الأدوات", "projects": "المشاريع", "about": "حول", diff --git a/v5/messages/en.json b/v5/messages/en.json index 5f359df..5d4389d 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -1,5 +1,8 @@ { "nav": { + "report": "REPORT", + "reportAria": "Report a problem", + "reportSeed": "I'd like to report a problem.", "tools": "TOOLS", "projects": "PROJECTS", "about": "ABOUT", diff --git a/v5/messages/es.json b/v5/messages/es.json index f9a7d58..22a7c67 100644 --- a/v5/messages/es.json +++ b/v5/messages/es.json @@ -1,5 +1,8 @@ { "nav": { + "report": "REPORTAR", + "reportAria": "Reportar un problema", + "reportSeed": "Quiero reportar un problema.", "tools": "HERRAMIENTAS", "projects": "PROYECTOS", "about": "ACERCA DE", diff --git a/v5/messages/fr.json b/v5/messages/fr.json index 2568804..0780a96 100644 --- a/v5/messages/fr.json +++ b/v5/messages/fr.json @@ -1,5 +1,8 @@ { "nav": { + "report": "SIGNALER", + "reportAria": "Signaler un problème", + "reportSeed": "Je voudrais signaler un problème.", "tools": "OUTILS", "projects": "PROJETS", "about": "À PROPOS", diff --git a/v5/messages/he.json b/v5/messages/he.json index 0befa99..f126d43 100644 --- a/v5/messages/he.json +++ b/v5/messages/he.json @@ -1,5 +1,8 @@ { "nav": { + "report": "דיווח", + "reportAria": "דיווח על תקלה", + "reportSeed": "אני רוצה לדווח על תקלה.", "tools": "כלים", "projects": "פרויקטים", "about": "אודות", diff --git a/v5/messages/hi.json b/v5/messages/hi.json index bb6a007..a9cb2a6 100644 --- a/v5/messages/hi.json +++ b/v5/messages/hi.json @@ -1,5 +1,8 @@ { "nav": { + "report": "रिपोर्ट करें", + "reportAria": "समस्या की रिपोर्ट करें", + "reportSeed": "मैं एक समस्या की रिपोर्ट करना चाहता हूँ।", "tools": "उपकरण", "projects": "परियोजनाएँ", "about": "परिचय", diff --git a/v5/messages/ja.json b/v5/messages/ja.json index 38308e2..53c8a2f 100644 --- a/v5/messages/ja.json +++ b/v5/messages/ja.json @@ -1,5 +1,8 @@ { "nav": { + "report": "報告", + "reportAria": "問題を報告", + "reportSeed": "問題を報告したいです。", "tools": "ツール", "projects": "プロジェクト", "about": "概要", diff --git a/v5/messages/ko.json b/v5/messages/ko.json index 1950d6a..8635c90 100644 --- a/v5/messages/ko.json +++ b/v5/messages/ko.json @@ -1,5 +1,8 @@ { "nav": { + "report": "신고", + "reportAria": "문제 신고", + "reportSeed": "문제를 신고하고 싶어요.", "tools": "도구", "projects": "프로젝트", "about": "소개", diff --git a/v5/messages/pt-BR.json b/v5/messages/pt-BR.json index 42be387..ce91fb8 100644 --- a/v5/messages/pt-BR.json +++ b/v5/messages/pt-BR.json @@ -1,5 +1,8 @@ { "nav": { + "report": "RELATAR", + "reportAria": "Relatar um problema", + "reportSeed": "Quero relatar um problema.", "tools": "FERRAMENTAS", "projects": "PROJETOS", "about": "SOBRE", diff --git a/v5/messages/ru.json b/v5/messages/ru.json index 38e4721..f36aaca 100644 --- a/v5/messages/ru.json +++ b/v5/messages/ru.json @@ -1,5 +1,8 @@ { "nav": { + "report": "СООБЩИТЬ", + "reportAria": "Сообщить о проблеме", + "reportSeed": "Я хочу сообщить о проблеме.", "tools": "ИНСТРУМЕНТЫ", "projects": "ПРОЕКТЫ", "about": "О ПРОЕКТЕ", diff --git a/v5/messages/tr.json b/v5/messages/tr.json index 633b91e..657765e 100644 --- a/v5/messages/tr.json +++ b/v5/messages/tr.json @@ -1,5 +1,8 @@ { "nav": { + "report": "BİLDİR", + "reportAria": "Sorun bildir", + "reportSeed": "Bir sorun bildirmek istiyorum.", "tools": "ARAÇLAR", "projects": "PROJELER", "about": "HAKKINDA", diff --git a/v5/messages/zh-CN.json b/v5/messages/zh-CN.json index 0a8768b..79c087a 100644 --- a/v5/messages/zh-CN.json +++ b/v5/messages/zh-CN.json @@ -1,5 +1,8 @@ { "nav": { + "report": "报告", + "reportAria": "报告问题", + "reportSeed": "我想报告一个问题。", "tools": "工具", "projects": "项目", "about": "关于", diff --git a/v5/src/app/layout.tsx b/v5/src/app/layout.tsx index 0d921b9..866ee3d 100644 --- a/v5/src/app/layout.tsx +++ b/v5/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { NextIntlClientProvider } from "next-intl"; import "../styles/globals.css"; import { ChatFab } from "../components/ChatFab"; +import { ChatLauncherProvider } from "../components/ChatLauncherContext"; import { GlobalChrome } from "../components/GlobalChrome"; import { ThemeScript } from "../components/ThemeScript"; import { LocaleHtmlScript } from "../components/LocaleHtmlScript"; @@ -53,11 +54,13 @@ async function LocalizedTree({ }) { return ( - - {children} - - - + + + {children} + + + + ); } diff --git a/v5/src/components/ChatFab.tsx b/v5/src/components/ChatFab.tsx index 18de337..5c022e6 100644 --- a/v5/src/components/ChatFab.tsx +++ b/v5/src/components/ChatFab.tsx @@ -8,6 +8,7 @@ import { usePathname } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { useChatLauncher } from "./ChatLauncherContext"; interface Suggestion { icon: "search" | "clipboard" | "pin"; @@ -101,7 +102,7 @@ interface PendingPhoto { export function ChatFab() { const t = useTranslations("chat"); const locale = useLocale(); - const [isOpen, setIsOpen] = useState(false); + const { isOpen, open, close, pendingSeed, consumeSeed } = useChatLauncher(); const [draft, setDraft] = useState(""); const pathname = usePathname() || "/"; const toolId = useMemo(() => { @@ -259,10 +260,6 @@ export function ChatFab() { } }, [messages]); - function close() { - setIsOpen(false); - } - const markdownComponents = useMemo( () => ({ a({ href, children }) { @@ -282,7 +279,7 @@ export function ChatFab() { ); }, }), - [] + [close] ); // Send a message and clear the stale "Reading: …manuals…" indicator from any @@ -294,6 +291,21 @@ export function ChatFab() { sendMessage({ text }); } + // Auto-send a seeded message when something outside ChatFab (e.g. the nav + // "Report" button) opens the chat with an intent. The nonce guard makes this + // idempotent so a re-render never resends, and we wait until any in-flight + // turn finishes before sending. + const lastSeedNonce = useRef(null); + useEffect(() => { + if (!pendingSeed || isLoading) return; + if (lastSeedNonce.current !== pendingSeed.nonce) { + lastSeedNonce.current = pendingSeed.nonce; + send(pendingSeed.text); + } + consumeSeed(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `send`/`consumeSeed` are stable for this purpose; the nonce ref guards against resends. + }, [pendingSeed, isLoading]); + function handleSuggestion(label: string) { if (isLoading) return; send(label); @@ -326,7 +338,7 @@ export function ChatFab() { aria-expanded={isOpen} aria-controls="makerlab-chat-sheet" aria-label={t("openAria")} - onClick={() => setIsOpen(true)} + onClick={() => open()} > >_ diff --git a/v5/src/components/ChatLauncherContext.tsx b/v5/src/components/ChatLauncherContext.tsx new file mode 100644 index 0000000..4d0ca77 --- /dev/null +++ b/v5/src/components/ChatLauncherContext.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +/** + * A one-shot message to auto-send into the chat. The nonce lets the same text + * re-trigger a send on repeat clicks — a plain string wouldn't change, so the + * consumer's effect wouldn't fire again. + */ +interface ChatSeed { + text: string; + nonce: number; +} + +interface ChatLauncher { + /** Whether the chat sheet is open. */ + isOpen: boolean; + /** A pending message to auto-send, or null. `ChatFab` consumes it. */ + pendingSeed: ChatSeed | null; + /** Open the chat. Pass `seedText` to also auto-send an opening message. */ + open: (seedText?: string) => void; + /** Close the chat (the conversation is preserved). */ + close: () => void; + /** Clear the pending seed once it has been sent. */ + consumeSeed: () => void; +} + +const ChatLauncherContext = createContext(null); + +/** + * Owns just the chat launcher's open state and any one-shot seed message, so + * that entry points outside `ChatFab` (e.g. the nav "Report" button) can open + * and seed the chat. All conversation/message state stays inside `ChatFab`. + */ +export function ChatLauncherProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [isOpen, setIsOpen] = useState(false); + const [pendingSeed, setPendingSeed] = useState(null); + + const open = useCallback((seedText?: string) => { + setIsOpen(true); + if (seedText) { + setPendingSeed((prev) => ({ + text: seedText, + nonce: (prev?.nonce ?? 0) + 1, + })); + } + }, []); + + const close = useCallback(() => setIsOpen(false), []); + const consumeSeed = useCallback(() => setPendingSeed(null), []); + + const value = useMemo( + () => ({ isOpen, pendingSeed, open, close, consumeSeed }), + [isOpen, pendingSeed, open, close, consumeSeed] + ); + + return ( + + {children} + + ); +} + +export function useChatLauncher(): ChatLauncher { + const ctx = useContext(ChatLauncherContext); + if (!ctx) { + throw new Error("useChatLauncher must be used within a ChatLauncherProvider"); + } + return ctx; +} diff --git a/v5/src/components/PrimaryNav.tsx b/v5/src/components/PrimaryNav.tsx index d86e96d..24463fb 100644 --- a/v5/src/components/PrimaryNav.tsx +++ b/v5/src/components/PrimaryNav.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; +import { useChatLauncher } from "./ChatLauncherContext"; const LINKS = [ { href: "/", key: "tools", match: (path: string) => path === "/" || path.startsWith("/tools") }, @@ -13,6 +14,7 @@ const LINKS = [ export function PrimaryNav() { const pathname = usePathname() || "/"; const t = useTranslations("nav"); + const { open } = useChatLauncher(); return ( ); } diff --git a/v5/src/styles/globals.css b/v5/src/styles/globals.css index 3fbd808..a3dfc58 100644 --- a/v5/src/styles/globals.css +++ b/v5/src/styles/globals.css @@ -180,6 +180,25 @@ img { border-color: var(--primary); } +/* "Report" is an action, not a page — accent-colored to stand out, but it + inherits the nav's mono/uppercase/size so it sits inline with the links. */ +.primary-nav-report { + padding-block: 8px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--primary); + font: inherit; + cursor: pointer; + transition: color 160ms ease-in, border-color 160ms ease-in; +} + +.primary-nav-report:hover, +.primary-nav-report:focus-visible { + border-color: var(--primary); + outline: none; +} + .nav-actions { display: flex; align-items: center;