| 1 | import { useEffect } from "react"; |
| 2 | import type { SessionSelector } from "../generated/desktopContract.generated"; |
| 3 | import { useCommittedCommand } from "../lib/useCommittedCommand"; |
| 4 | import { applyThemeScene } from "../lib/themePack"; |
| 5 | import { useOverlayStore } from "../store/overlays"; |
| 6 | import type { Translator } from "../lib/i18n"; |
| 7 | import type { Item, LiveStream } from "../lib/useController"; |
| 8 | |
| 9 | export type SessionExportFormat = "markdown" | "json" | "pdf" | "image" | "diagnostic"; |
| 10 | |
| 11 | /** |
| 12 | * Owns the session export commands (markdown/json/pdf/image file pickers and |
| 13 | * writers), the export popover outside-click close and the theme scene that |
| 14 | * switches between the empty home and the content task scene. Each command |
| 15 | * captures the source identity and title of the render that published |
| 16 | * it; resident items are used only for diagnostic counters; the renderer chunks stay lazy behind the file dialog. |
| 17 | */ |
| 18 | export function useSessionExportCommands(input: { |
| 19 | tabId?: string; |
| 20 | selector?: SessionSelector; |
| 21 | remote: boolean; |
| 22 | sessionTitle: string; |
| 23 | items: readonly Item[]; |
| 24 | live: LiveStream | undefined; |
| 25 | hasContent: boolean; |
| 26 | t: Translator; |
| 27 | showToast: (message: string, kind: "info" | "warn" | "error", options?: { durationMs?: number }) => void; |
| 28 | }) { |
| 29 | const { tabId, remote, sessionTitle, items, live, hasContent, t, showToast } = input; |
| 30 | const topicExportOpen = useOverlayStore((state) => state.topicExportOpen); |
| 31 | const setTopicExportOpen = useOverlayStore((state) => state.setTopicExportOpen); |
| 32 | |
| 33 | // Theme pack scene: home when the session is empty, task once content exists. |
| 34 | useEffect(() => { |
| 35 | applyThemeScene(hasContent ? "task" : "home"); |
| 36 | }, [hasContent]); |
| 37 | |
| 38 | useEffect(() => { |
| 39 | if (!topicExportOpen) return; |
| 40 | const onDown = (event: MouseEvent) => { |
| 41 | const target = event.target as Element | null; |
| 42 | if (!target?.closest(".topicbar__export")) setTopicExportOpen(false); |
| 43 | }; |
| 44 | document.addEventListener("mousedown", onDown); |
| 45 | return () => document.removeEventListener("mousedown", onDown); |
| 46 | }, [setTopicExportOpen, topicExportOpen]); |
| 47 | |
| 48 | const run = useCommittedCommand(async (format: SessionExportFormat | "clipboard") => (await import("../lib/sessionExportOperation")).runSessionExport({ |
| 49 | selector: input.selector ?? {}, tabId: tabId ?? "", format, title: sessionTitle, remote, |
| 50 | residentItems: items.length, runningStream: Boolean(live), unresolvedTools: items.filter(item => item.kind === "tool" && item.resultMissing).length, |
| 51 | })); |
| 52 | const getSessionMarkdown = useCommittedCommand(async () => { |
| 53 | try { |
| 54 | const result = await run("clipboard"); |
| 55 | if (!result || result.cancelled) throw new DOMException("Export cancelled", "AbortError"); |
| 56 | return result.text ?? ""; |
| 57 | } catch (error) { |
| 58 | if (!(error instanceof DOMException && error.name === "AbortError")) showToast(t("topicBar.exportFailed", { error: error instanceof Error ? error.message : String(error) }), "error", { durationMs: 8000 }); |
| 59 | throw error; |
| 60 | } |
| 61 | }); |
| 62 | const exportSession = useCommittedCommand(async (format: SessionExportFormat) => { |
| 63 | setTopicExportOpen(false); |
| 64 | try { |
| 65 | const result = await run(format); |
| 66 | if (result && !result.cancelled) showToast(`${sessionTitle} · ${t("topicBar.exportSuccess", { count: result.files })}`, "info"); |
| 67 | } catch (err) { |
| 68 | showToast(t("topicBar.exportFailed", { error: err instanceof Error ? err.message : String(err) }), "error", { durationMs: 8000 }); |
| 69 | } |
| 70 | }); |
| 71 | |
| 72 | return { getSessionMarkdown, exportSession }; |
| 73 | } |
| 74 |