import '../../styles/studio-assistant.css'; import { ImageIcon, MessageSquare } from 'lucide-react'; import { type HTMLAttributes, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from '../../i18n'; import { useAssistantStore } from '../../store/assistantStore'; import AssistantHeader from '../Assistant/AssistantHeader'; import AssistantSurfaceLazy from '../Assistant/AssistantSurfaceLazy'; import type { EntityTarget } from '../Assistant/cards/cardTypes'; import { providerLabel } from '../Assistant/providerCatalog'; import { useAssistantThread } from '../Assistant/useAssistantThread'; import { useToast } from '../shared/Toast'; import { SpreadView } from './SpreadView'; import { sheetSizePx } from './pageSnapshot'; import type { StudioAssistant, StudioRenderJob } from './useStudioAssistant'; /** * The assistant as a column of TREK Studio (journey-studio.md 4.5): the same * chat as the planner's window, on the journey's conversations, beside the * page it talks about. Desktop only, like the Studio itself. * * Studio closes on Escape and edits the selection on Delete, copy and paste. * Inside the column every key belongs to the chat, so it stops here, and * Escape stops a running answer first. A key from the header's menu, which * sits in a portal, is not the chat's to act on. */ /** The column is not a window: its head moves nothing. */ const NO_DRAG: HTMLAttributes = { style: { cursor: 'default' } }; /** * Images of a page leave only once the user agreed, per connection. Until * then the Studio's assistant reads the pages as text. "Not now" hides the * notice for this visit; the next one asks again. A model that reads no * images (a custom server, OpenRouter, an OpenAI text-only model) is never * asked: its pages always go as text. */ function StudioImagesNotice() { const { t } = useTranslation(); const toast = useToast(); const effective = useAssistantStore((s) => s.status?.effective ?? null); const reads = useAssistantStore((s) => s.status?.studioImages === true); const acked = useAssistantStore((s) => s.status?.studioImagesAcked === true); const ackStudioImages = useAssistantStore((s) => s.ackStudioImages); const [later, setLater] = useState(false); if (!effective || !reads || acked || later) return null; const provider = providerLabel(effective.kind, effective.host); const allow = async () => { try { await ackStudioImages(); } catch { toast.error(t('assistant.error.generic')); } }; return (
); } /** One page a turn asked for, drawn as the editor draws it and handed on once it is in the DOM. */ function StageSheet({ job, onDrawn, }: { job: StudioRenderJob; onDrawn: (job: StudioRenderJob, sheet: HTMLElement) => Promise; }) { const sheet = useRef(null); // A job goes up once, even when the effect runs again (development's double run, a new callback). const sent = useRef(null); const size = sheetSizePx(job.spread, job.page); useEffect(() => { if (!sheet.current || sent.current === job) return; sent.current = job; void onDrawn(job, sheet.current); }, [job, onDrawn]); return ( // A page is laid out in millimetres from its top left whatever the reading direction.
); } /** * The pages being drawn for a turn, where nobody sees them. Off screen rather * than hidden, as StudioExport does it: a hidden subtree lays nothing out, * and the page is measured in millimetres by the CSS that draws it. */ export function StudioPageStage({ assistant }: { assistant: StudioAssistant }) { if (assistant.jobs.length === 0) return null; return ( ); } export default function StudioAssistantPanel({ assistant, title, bookVersion, }: { assistant: StudioAssistant; title: string; /** The stored book's version the open book stands on, for the book's cards in the chat. */ bookVersion?: number | null; }) { const { t } = useTranslation(); const navigate = useNavigate(); const thread = useAssistantThread(assistant.chatKey); const { isRunning, stop } = thread; // A card points at a page of this book (`?page=`) or somewhere else in the app. const onOpen = useCallback( (target: EntityTarget) => { if (target.kind === 'page') void navigate(target.path); }, [navigate] ); // Every key pressed in the column is the chat's: the editor's own keys // (Delete, Backspace, copy, paste, undo, Escape) listen on the window and // would otherwise act on the page's selection from a focused chat button. // Keys from a portal (the header's menu) bubble here through React too; // they stop here as well, but they are not the chat's to act on. const onKeyDown = (e: KeyboardEvent) => { const inside = e.currentTarget.contains(e.target as Node); e.stopPropagation(); if (inside && e.key === 'Escape' && isRunning && !e.defaultPrevented) stop(); }; return ( ); } /** The Studio bar's switch for the column; only where the assistant can run for this user. */ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistant }) { const { t } = useTranslation(); if (!assistant.available) return null; return ( ); } /** The chat column at the end of the Studio's body, while it is open, named after the journey. */ export function StudioAssistantColumn({ assistant, title, bookVersion, }: { assistant: StudioAssistant; title?: string; bookVersion?: number | null; }) { const { t } = useTranslation(); if (!assistant.open) return null; return ; }