import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowClockwiseIcon, DotsThreeIcon, SidebarSimpleIcon } from "@phosphor-icons/react"; import { advanceDecisionView, countUnanswered, cursor, Decisions, Face, PlanEditor, QuestionnaireStore, ThreadStore, useHasPlanContent, useQuestionnaires, visibleDecisionView, } from "@chopin/editor"; import { Chat } from "./chat/chat"; import { decisionAttention, DecisionViewControl } from "./decision-view-control"; import * as Api from "./api"; import { HostedApp, HostedFailure, HostedLoading, HostedLogin } from "./hosted"; import { RepositoryPicker } from "./repository-picker"; import { Wire } from "./wire"; import { paneId, useWorkspaceMode, useWorkspaceState, Workspace } from "./workspace"; import { presentWorkspace } from "./workspace-model"; import type { Session } from "@chopin/protocol"; import type { DecisionView, DecisionViewState } from "@chopin/editor"; import type { Status } from "./wire"; type ConversationActivity = { unread: number; busy: boolean }; function PaneToggle( { activity, onToggle, open, }: { activity: ConversationActivity; onToggle: () => void; open: boolean }, ) { let status = activity.busy ? "Planner working" : activity.unread > 0 ? `${activity.unread} unread` : undefined; return ( ); } function Header( { chatOpen, conversationActivity, members, onToggleChat, onResetAgent, label, repository, showConversationToggle, }: { chatOpen: boolean; conversationActivity: ConversationActivity; members: Session.Member[]; onToggleChat: () => void; onResetAgent?: () => Promise; label: string; repository: Api.Repository; showConversationToggle: boolean; }, ) { let [resetting, setResetting] = useState(false); let [resetError, setResetError] = useState(false); let resetLabel = resetting ? "Resetting..." : resetError ? "Reset failed" : "New planner session"; async function resetAgent() { if (!onResetAgent || resetting) return; setResetting(true); setResetError(false); try { await onResetAgent(); } catch { setResetError(true); } finally { setResetting(false); } } return (
{showConversationToggle && ( )} chopin
); } export function RoomWorkspace( { agent = true, canEdit = true, handle, label, onResetAgent, repository, room, }: { agent?: boolean; canEdit?: boolean; handle: string; label: string; onResetAgent?: () => Promise; repository: Api.Repository; room: string; }, ) { let [wire, setWire] = useState(); let [status, setStatus] = useState("connecting"); let [members, setMembers] = useState([]); let [effectiveCanEdit, setEffectiveCanEdit] = useState(canEdit); let user = useMemo(() => cursor(handle), [handle]); let mode = useWorkspaceMode(); let [workspace, dispatch] = useWorkspaceState(); let [questions] = useState(() => new QuestionnaireStore()); let [threads] = useState(() => new ThreadStore()); let [reveal, setReveal] = useState<{ widget: string; token: number }>(); let [planScrollTop, setPlanScrollTop] = useState(0); let entries = useQuestionnaires(questions); let unanswered = countUnanswered(entries); let hasPlanContent = useHasPlanContent(questions); let [decisionView, setDecisionView] = useState(() => { let stored = localStorage.getItem("chopin:view:document"); return { phase: "initial", preferred: stored === "decisions" ? "decisions" : "plan", }; }); let view = visibleDecisionView(decisionView, hasPlanContent, unanswered); let previousUnanswered = useRef(unanswered); let [attention, setAttention] = useState(false); let workspacePresentation = presentWorkspace(workspace, mode, view); let conversationActive = workspacePresentation.conversationVisible; let [conversationActivity, setConversationActivity] = useState({ unread: 0, busy: false }); let onConversationActivity = useCallback( (event: { type: "message" | "working"; busy: boolean }) => { setConversationActivity(current => ({ busy: event.busy, unread: event.type === "message" && !conversationActive ? current.unread + 1 : current.unread, })); }, [conversationActive], ); useEffect(() => { setDecisionView(state => advanceDecisionView(state, hasPlanContent, unanswered)); }, [hasPlanContent, unanswered]); useEffect(() => { if (!conversationActive) return; setConversationActivity(current => current.unread === 0 ? current : { ...current, unread: 0 }); }, [conversationActive]); useEffect(() => { let previous = previousUnanswered.current; previousUnanswered.current = unanswered; if (!decisionAttention(previous, unanswered)) return; setAttention(true); let timer = window.setTimeout(() => setAttention(false), 200); return () => window.clearTimeout(timer); }, [unanswered]); let selectView = (next: DecisionView, revealFirst = true) => { setDecisionView(state => ({ ...state, preferred: next })); if (hasPlanContent) localStorage.setItem("chopin:view:document", next); if (next === "decisions" && revealFirst) { let first = entries.find(entry => entry.value.questions.some(question => question.answer === undefined) ); setReveal({ widget: first?.id ?? "", token: Date.now() }); } }; let selectDestination = (destination: "plan" | "decisions") => { selectView(destination, mode === "split"); dispatch({ type: "set-conversation", open: false }); }; let toggleConversation = () => { if (mode !== "split") { dispatch({ type: "set-conversation", open: !workspace.conversationOpen }); return; } if (workspacePresentation.conversationVisible) { dispatch({ type: "set-desktop-conversation", open: false }); dispatch({ type: "set-conversation", open: false }); } else dispatch({ type: "set-desktop-conversation", open: true }); }; let showPlan = (widget: string, question: string) => { selectDestination("plan"); requestAnimationFrame(() => { questions.reveal(widget, question); let card = document.querySelector( `[data-document-view="plan"] article[data-plan-sidecar-questionnaire="${ CSS.escape(widget) }"]`, ); if (!card) return; card.tabIndex = -1; card.focus(); }); }; useEffect(() => { setEffectiveCanEdit(canEdit); }, [canEdit, room]); useEffect(() => { let socket = new Wire({ channelId: room, onAuthenticationRequired: () => location.assign("/"), onStatus: next => { setStatus(next); }, }); setWire(socket); let off = [ socket.on("session:hello", frame => { setMembers(frame.members); setEffectiveCanEdit(frame.canEdit); }), socket.on("session:presence", frame => setMembers(frame.members)), socket.on("session:access", frame => setEffectiveCanEdit(frame.canEdit)), threads.listen(socket), ]; return () => { for (let unsubscribe of off) unsubscribe(); socket.dispose(); setWire(undefined); }; }, [room, handle, threads]); return ( } conversationActivity={conversationActivity} header={
} controls={ } mode={mode} onConversationOpen={open => dispatch({ type: "set-conversation", open })} onDestination={selectDestination} decisions={ } plan={ } state={workspace} unanswered={unanswered} view={view} /> ); } export function App() { let [session, setSession] = useState(); let [error, setError] = useState(); useEffect(() => { let active = true; Api.session().then(value => { if (active) setSession(value); }, reason => { if (active) setError(reason); }); return () => { active = false; }; }, []); if (error) return ; if (!session) return ; if (!session.user) return ; return ; }