import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowClockwiseIcon, 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, usePaneOpen, Workspace } from "./workspace"; import type { Session } from "@chopin/protocol"; import type { DecisionView, DecisionViewState } from "@chopin/editor"; import type { Status } from "./wire"; const TONE: Record = { connecting: "text-text-tertiary", connected: "text-text-tertiary", reconnecting: "text-warning-ink", denied: "text-destructive-ink", closed: "text-text-tertiary", }; function PaneToggle({ onToggle, open }: { onToggle: () => void; open: boolean }) { return ( ); } function Header( { chatOpen, members, onToggleChat, onResetAgent, reason, label, repository, status, }: { chatOpen: boolean; members: Session.Member[]; onToggleChat: () => void; onResetAgent?: () => Promise; reason?: string; label: string; repository: Api.Repository; status: Status; }, ) { 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 (
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 [reason, setReason] = useState(); let [members, setMembers] = useState([]); let [effectiveCanEdit, setEffectiveCanEdit] = useState(canEdit); let user = useMemo(() => cursor(handle), [handle]); let [chatOpen, setChatOpen] = usePaneOpen("chat"); 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); useEffect(() => { setDecisionView(state => advanceDecisionView(state, hasPlanContent, unanswered)); }, [hasPlanContent, unanswered]); 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) => { setDecisionView(state => ({ ...state, preferred: next })); if (hasPlanContent) localStorage.setItem("chopin:view:document", next); if (next === "decisions") { let first = entries.find(entry => entry.value.questions.some(question => question.answer === undefined) ); setReveal({ widget: first?.id ?? "", token: Date.now() }); } }; let showPlan = (widget: string, question: string) => { selectView("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, why) => { setStatus(next); setReason(why); }, }); 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 ( } chatOpen={chatOpen} header={
setChatOpen(value => !value)} onResetAgent={effectiveCanEdit ? onResetAgent : undefined} reason={status === "connected" && !effectiveCanEdit ? "view only" : reason} label={label} repository={repository} status={status} /> } controls={ } decisions={ } plan={ } 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 ; }