| 1 | import { lazy, Suspense, useEffect, useRef, useState } from "react"; |
| 2 | import { Activity, Download, TerminalSquare } from "lucide-react"; |
| 3 | import { CopyButton } from "./CopyButton"; |
| 4 | import { Tooltip } from "./Tooltip"; |
| 5 | import { t } from "../lib/i18n"; |
| 6 | |
| 7 | const loadExportMenu = () => import("./TopicbarExportMenu"); |
| 8 | const TopicbarExportMenu = lazy(async () => ({ default: (await loadExportMenu()).TopicbarExportMenu })); |
| 9 | const SessionExportProgress = lazy(async () => ({ default: (await import("./SessionExportProgress")).SessionExportProgress })); |
| 10 | |
| 11 | export interface TopicbarSessionActionsProps { |
| 12 | sessionHasContent: boolean; |
| 13 | getSessionMarkdown: () => string | Promise<string>; |
| 14 | exportSession: (format: "markdown" | "json" | "pdf" | "image" | "diagnostic") => void; |
| 15 | toggleTerminal: () => void; |
| 16 | terminalEnabled?: boolean; |
| 17 | terminalOpen: boolean; |
| 18 | prefetchTerminal?: () => void; |
| 19 | openSessionSummary: () => void; |
| 20 | tasksOpen: boolean; |
| 21 | } |
| 22 | |
| 23 | const actionClass = "topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"; |
| 24 | |
| 25 | /** Keep session actions directly accessible; only export formats need a menu. */ |
| 26 | export function TopicbarSessionActions({ |
| 27 | sessionHasContent, getSessionMarkdown, exportSession, |
| 28 | toggleTerminal, terminalEnabled = true, terminalOpen, prefetchTerminal, |
| 29 | openSessionSummary, tasksOpen, |
| 30 | }: TopicbarSessionActionsProps) { |
| 31 | const exportRootRef = useRef<HTMLDivElement>(null); |
| 32 | const exportTriggerRef = useRef<HTMLButtonElement>(null); |
| 33 | const [exportFocus, setExportFocus] = useState<"first" | "last" | null>(null); |
| 34 | const exportVisible = exportFocus !== null && sessionHasContent; |
| 35 | |
| 36 | const closeExport = (restoreFocus: boolean) => { |
| 37 | setExportFocus(null); |
| 38 | if (restoreFocus) exportTriggerRef.current?.focus(); |
| 39 | }; |
| 40 | |
| 41 | useEffect(() => { |
| 42 | if (!exportVisible) return; |
| 43 | const dismissOutside = (event: Event) => { |
| 44 | if (!exportRootRef.current?.contains(event.target as Node)) setExportFocus(null); |
| 45 | }; |
| 46 | const onKeyDown = (event: KeyboardEvent) => { |
| 47 | if (event.key !== "Escape") return; |
| 48 | event.preventDefault(); |
| 49 | setExportFocus(null); |
| 50 | exportTriggerRef.current?.focus(); |
| 51 | }; |
| 52 | document.addEventListener("pointerdown", dismissOutside); |
| 53 | document.addEventListener("focusin", dismissOutside); |
| 54 | document.addEventListener("keydown", onKeyDown); |
| 55 | return () => { |
| 56 | document.removeEventListener("pointerdown", dismissOutside); |
| 57 | document.removeEventListener("focusin", dismissOutside); |
| 58 | document.removeEventListener("keydown", onKeyDown); |
| 59 | }; |
| 60 | }, [exportVisible]); |
| 61 | |
| 62 | return ( |
| 63 | <> |
| 64 | <Suspense fallback={null}><SessionExportProgress /></Suspense> |
| 65 | <Tooltip label={t("topicBar.copyAll")}> |
| 66 | <CopyButton getText={getSessionMarkdown} label={t("topicBar.copyAll")} className={actionClass} showInlineLabel={false} /> |
| 67 | </Tooltip> |
| 68 | <div ref={exportRootRef} className={`topicbar__export${exportVisible ? " topicbar__export--open" : ""}`}> |
| 69 | <Tooltip label={t("topicBar.export")}> |
| 70 | <button |
| 71 | ref={exportTriggerRef} className={actionClass} type="button" |
| 72 | aria-label={t("topicBar.export")} aria-haspopup="menu" aria-expanded={exportVisible} |
| 73 | disabled={!sessionHasContent} |
| 74 | onPointerEnter={() => { void loadExportMenu(); }} |
| 75 | onFocus={() => { void loadExportMenu(); }} |
| 76 | onKeyDown={(event) => { |
| 77 | if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; |
| 78 | event.preventDefault(); |
| 79 | setExportFocus(event.key === "ArrowUp" ? "last" : "first"); |
| 80 | }} |
| 81 | onClick={() => setExportFocus((focus) => focus ? null : "first")} |
| 82 | > |
| 83 | <Download size={14} /> |
| 84 | </button> |
| 85 | </Tooltip> |
| 86 | {exportVisible && ( |
| 87 | <Suspense fallback={null}> |
| 88 | <TopicbarExportMenu initialFocus={exportFocus!} exportSession={exportSession} onClose={closeExport} /> |
| 89 | </Suspense> |
| 90 | )} |
| 91 | </div> |
| 92 | <Tooltip label={t("rightDock.terminal")}> |
| 93 | <button |
| 94 | className={actionClass} type="button" aria-label={t("rightDock.terminal")} |
| 95 | aria-pressed={terminalOpen} disabled={!terminalEnabled} |
| 96 | onPointerEnter={terminalEnabled ? prefetchTerminal : undefined} |
| 97 | onFocus={terminalEnabled ? prefetchTerminal : undefined} |
| 98 | onClick={toggleTerminal} |
| 99 | > |
| 100 | <TerminalSquare size={14} /> |
| 101 | </button> |
| 102 | </Tooltip> |
| 103 | <Tooltip label={t("summary.session")}> |
| 104 | <button className={actionClass} type="button" aria-label={t("summary.session")} aria-expanded={tasksOpen} onClick={openSessionSummary}> |
| 105 | <Activity size={14} /> |
| 106 | </button> |
| 107 | </Tooltip> |
| 108 | </> |
| 109 | ); |
| 110 | } |
| 111 |