/** Three panes on one ground, with the document as the only raised surface. */ import { useEffect, useLayoutEffect, useReducer, useRef, useSyncExternalStore } from "react"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { presentWorkspace, transitionWorkspace, WORKSPACE_MEDIA, workspaceMode, } from "./workspace-model"; import conversationCloseIcon from "./assets/icons/conversation-close.svg"; import conversationIcon from "./assets/icons/conversation.svg"; import { ResizeHandle, usePaneWidth } from "./resizable-pane"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import type { Dispatch, ReactNode, RefObject } from "react"; import type { WorkspaceDestination, WorkspaceEvent, WorkspaceMode, WorkspaceState, } from "./workspace-model"; export type Pane = "chat"; const CHAT_PANE = { initial: 304, max: 400, min: 304, storageKey: "chopin:pane:chat", }; export function paneId(pane: Pane): string { return `pane-${pane}`; } function currentMode(): WorkspaceMode { return workspaceMode(matchMedia); } function subscribeMode(notify: () => void): () => void { let queries = WORKSPACE_MEDIA.map(matchMedia); for (let query of queries) query.addEventListener("change", notify); return () => { for (let query of queries) query.removeEventListener("change", notify); }; } /** Read CSS width atomically so a render cannot contain two workspace modes. */ export function useWorkspaceMode(): WorkspaceMode { return useSyncExternalStore(subscribeMode, currentMode, currentMode); } /** Only the desktop Conversation preference crosses a page load. */ export function useWorkspaceState(): [WorkspaceState, Dispatch] { let [state, dispatch] = useReducer(transitionWorkspace, undefined, (): WorkspaceState => ({ conversationOpen: false, desktopConversationOpen: localStorage.getItem("chopin:pane:chat:open") !== "false", })); useEffect(() => { localStorage.setItem( "chopin:pane:chat:open", String(state.desktopConversationOpen), ); }, [state.desktopConversationOpen]); return [state, dispatch]; } export type WorkspaceProps = { header: ReactNode; chat?: ReactNode; plan: ReactNode; decisions: ReactNode; backgroundWork?: ReactNode; controls: ReactNode; mode: WorkspaceMode; state: WorkspaceState; view: "plan" | "decisions" | "background-work"; onConversationOpen: (open: boolean) => void; onDesktopConversationOpen: (open: boolean) => void; onDestination: (destination: "plan" | "decisions" | "background-work") => void; unanswered: number; conversationActivity: { unread: number; busy: boolean }; backgroundActivity?: { active: number; paused: number; failed: number }; }; function ConversationToggle( { activity, buttonRef, className, onToggle, open, swapOnHover = false, }: { activity: WorkspaceProps["conversationActivity"]; buttonRef?: RefObject; className?: string; onToggle: () => void; open: boolean; swapOnHover?: boolean; }, ) { let status = activity.busy ? "Planner working" : activity.unread > 0 ? `${activity.unread} unread` : undefined; return ( ); } export const HEADING: Record = { plan: "workspace-plan-heading", decisions: "workspace-decisions-heading", "background-work": "workspace-background-work-heading", conversation: "workspace-conversation-heading", }; function destinationLabel( destination: WorkspaceDestination, unanswered: number, activity: WorkspaceProps["conversationActivity"], background: NonNullable, ): string { if (destination === "decisions" && unanswered > 0) { return `Decisions, ${unanswered} unanswered`; } if ( destination === "background-work" && (background.active > 0 || background.paused > 0 || background.failed > 0) ) { return `Background Work, ${background.active} active, ${background.paused} waiting, ${background.failed} failed`; } if (destination === "conversation" && activity.busy && activity.unread > 0) { return `Conversation, Planner working, ${activity.unread} unread`; } if (destination === "conversation" && activity.busy) return "Conversation, Planner working"; if (destination === "conversation" && activity.unread > 0) { return `Conversation, ${activity.unread} unread`; } return destination === "conversation" ? "Conversation" : destination === "background-work" ? "Background Work" : destination === "decisions" ? "Decisions" : "Document"; } export function Workspace( { backgroundActivity = { active: 0, paused: 0, failed: 0 }, backgroundWork, chat, controls, conversationActivity, decisions, header, mode, onConversationOpen, onDesktopConversationOpen, onDestination, plan, state, unanswered, view, }: WorkspaceProps, ) { let [chatWidth, resizeChat] = usePaneWidth({ active: mode === "split", ...CHAT_PANE }); let presentation = presentWorkspace(state, mode, view); let immediately = motionImmediately(); let contentSwapMotion = motionContract("content-swap"); let conversationPresence = useTransitionPresence( presentation.conversationVisible ? true : undefined, 220, immediately, ); let opener = useRef(undefined); let edgeTab = useRef(null); let previousConversationOpen = useRef(state.conversationOpen); let conversationInactive = !presentation.conversationVisible; let destinations: WorkspaceDestination[] = backgroundWork ? ["conversation", "plan", "decisions", "background-work"] : ["conversation", "plan", "decisions"]; useLayoutEffect(() => { if (!previousConversationOpen.current && state.conversationOpen) { let active = document.activeElement; if (active instanceof HTMLElement) opener.current = active; if (mode !== "split") { document.getElementById(HEADING.conversation)?.focus({ preventScroll: true }); } } previousConversationOpen.current = state.conversationOpen; }, [mode, state.conversationOpen]); let navigate = (destination: WorkspaceDestination, source?: HTMLElement) => { if (destination === "conversation" && source) opener.current = source; if (destination === "conversation") onConversationOpen(true); else onDestination(destination); requestAnimationFrame(() => { document.getElementById(HEADING[destination])?.focus({ preventScroll: true }); }); }; let dismissConversation = () => { if (mode === "split") { onDesktopConversationOpen(false); requestAnimationFrame(() => edgeTab.current?.focus({ preventScroll: true })); } else { onConversationOpen(false); requestAnimationFrame(() => opener.current?.focus({ preventScroll: true })); } }; let showDesktopConversation = () => { onDesktopConversationOpen(true); requestAnimationFrame(() => { document.getElementById(HEADING.conversation)?.focus({ preventScroll: true }); }); }; return (
{header}
{/* `hidden` preserves pane state and subscriptions after its closing transition. */} {chat && ( )} {chat && mode === "split" && !presentation.conversationVisible && (
)}
{mode === "split" && (
{controls}
)}

Document

{plan}
{decisions}
{backgroundWork && (
{backgroundWork}
)}
{mode !== "split" && ( )}
); }