import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { documentPath } from "@chopin/protocol/document-url"; import { ArchiveIcon, ChevronIcon, DocumentIcon } from "@chopin/icons"; import { Badge } from "@chopin/visuals"; import { advanceDecisionView, CardMetaStore, countUnanswered, cursor, Decisions, documentHasPlanningContent, Face, firstOpenDecision, PlanEditor, PlanStatus, QuestionnaireStore, ResearchLauncher, selectDecisionView, ThreadStore, useConnectionNotice, useHasPlanContent, useQuestionnaires, visibleDecisionView, } from "@chopin/editor"; import { Chat } from "./chat/chat"; import { ChildProvenance } from "./child-provenance"; import { shouldShowResearchActionError, useResearchOfferLinks } from "./chat/research-offer"; import type { ResearchDraftController } from "./chat/research-draft-controller"; import { advanceConversationAnnouncement } from "./conversation-plan/announcements"; import type { ConversationAnnouncementSummary } from "./conversation-plan/announcements"; import { evidenceRows, hasEvidence } from "./conversation-plan/evidence"; import { EvidencePopover } from "./conversation-plan/evidence-popover"; import { threadForCard } from "./conversation-plan/links"; import type { CardLink } from "./conversation-plan/links"; import type { ExcerptCorrectionAction } from "./conversation-plan/analysis-overview"; import { ConversationPlanStore, useConversationPlan } from "./conversation-plan/store"; import { rememberChannel } from "./channel-recovery"; import { DecisionViewControl, useDecisionAttention } from "./decision-view-control"; import { advanceDocumentActivity, ANSWER_FOLLOW_MS, documentActivity, QUIET_DOCUMENT, } from "./document-activity"; import { newestDocumentMetadata } from "./document-actions"; import { DocumentActionsMenu } from "./document-actions-menu"; import { DocumentRename } from "./document-rename"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import { useNavigationDocument } from "./navigation-shell"; import { titleEdits } from "./title-edit"; import { peopleHere } from "./presence"; import { ResearchRequestStore } from "./research-requests"; import { Wire } from "./wire"; import { ExperimentStore } from "./experiments/store"; import { ExperimentsPanel } from "./experiments/panel"; import { EvidenceDecisions } from "./experiments/evidence"; import { useWorkspaceIds, useWorkspaceLayout, useWorkspaceState, Workspace } from "./workspace"; import { initialDocumentView, presentWorkspace, workspaceProfile } from "./workspace-model"; import type { ConversationPlan, Plan, Question, Research, Session } from "@chopin/protocol"; import type { DecisionView, DecisionViewState, PlanState, Refusal, ResearchLaunchResult, } from "@chopin/editor"; import type { DocumentMetadata } from "./document-actions"; import type { DocumentAction } from "./document-actions-menu"; import type { TitleEdit } from "./title-edit"; import type { HostedWorkspaceProps } from "./hosted"; import type { Status } from "./wire"; import type { ChatDestination } from "./conversation-plan/source"; import type { SourceDestination } from "./conversation-plan/card-parts"; import type { WorkspacePresentation } from "./workspace-model"; type ManagedHello = Session.Hello & { archivedAt?: string; canManage: boolean }; type ManagedChannel = Session.Channel & { archivedAt?: string; canManage: boolean }; type ManagedAccess = Session.Access & { canManage: boolean }; type WorkspaceMetadata = DocumentMetadata; // Lives here rather than in `title-edit.ts` to keep it out of the initial bundle. function claimTitleEdit(id: string): TitleEdit | undefined { let edit = titleEdits.get(id); // StrictMode renders twice in one task, and both renders must see the claim. if (edit) queueMicrotask(() => titleEdits.delete(id)); return edit; } function settleMotionImmediately(): boolean { return motionImmediately(); } const QUESTION_MOTION = { contract: motionContract("content-swap"), immediately: settleMotionImmediately, }; export function Header( { archivedAt, canManage, editing, members, label, onAction, onEditingChange, onRenamed, presentation, project, room, }: { archivedAt?: string; canManage: boolean; editing?: TitleEdit; members: Session.Member[]; label: string; onAction: (action: DocumentAction) => void; onEditingChange: (editing?: TitleEdit) => void; onRenamed: (channel: DocumentMetadata) => void; presentation: WorkspacePresentation; project?: { id: string; name: string }; room: string; }, ) { let people = peopleHere(members); let header = useRef(null); let title = useRef(null); let previousEdit = useRef(editing); useEffect(() => { let previous = previousEdit.current; previousEdit.current = editing; // Hand the caret back only when the field took it with it, not after a blur commit. if (!editing && previous && document.activeElement === document.body) title.current?.focus(); }, [editing]); let finishEdit = () => { // Naming a new document leads into writing it, unless the user already clicked elsewhere. if (editing === "new" && header.current?.contains(document.activeElement)) { header.current.closest(".workspace-root") ?.querySelector(".plan-content[contenteditable='true']") ?.focus(); } onEditingChange(); }; return (
{project && presentation.type !== "parent-with-child" && ( <> {project.name} )} {presentation.type === "parent-with-child" ? ( <>
{people.map(handle => ( ))} {people.length > 3 && ( )}
); } const LOST_EDITS = "Your last edits couldn't be saved because the document changed while you were offline."; const UNDO_REFUSALS: Record = { others: "Others have edited this since.", change: "This change can't be reversed here.", }; /** Reconnect attempts a person can make in one outage before Reload is offered. */ const RECONNECTS_BEFORE_RELOAD = 3; export function RoomWorkspace( { agent = true, archivedAt, canEdit = true, canManage, description, descriptionRevision, handle, label, onMetadataChanged, presentation, repository, room, slug, updatedAt, userId, }: HostedWorkspaceProps, ) { let [wire, setWire] = useState(); let [sourceDestination, setSourceDestination] = useState(undefined); let sourceToken = useRef(0); let conversationStore = useMemo(() => new ConversationPlanStore(room), [room]); let conversation = useConversationPlan(conversationStore); let conversationGeneration = conversationStore.generation; let [announcement, setAnnouncement] = useState({ text: "", sequence: 0 }); let priorConversation = useRef< { store: ConversationPlanStore; generation: number; summary?: ConversationAnnouncementSummary; } | undefined >(undefined); let { onDocumentAction, onDocumentChanged, onDocumentDeleted, onRepositoryAccessChanged, onResearchChildOpen, onResearchChildPublished, } = useNavigationDocument(); let [status, setStatus] = useState("connecting"); let [planState, setPlanState] = useState({ synced: false }); // Claimed while rendering, so a new document's title takes focus as soon as it mounts. let [titleEdit, setTitleEdit] = useState(() => claimTitleEdit(room)); useEffect(() => { let listen = (event: Event) => { if ((event as CustomEvent).detail !== room) return; let edit = claimTitleEdit(room); if (edit) setTitleEdit(edit); }; addEventListener("title-edit", listen); return () => removeEventListener("title-edit", listen); }, [room]); // Controls dim only once a loss outlasts a blip; actions still read `status`. let treatAsConnected = useConnectionNotice(status !== "connected") === "none"; // Reconnecting in place keeps unsent work, so it is offered first. A reload // is the fallback once it has failed this often in one outage. let [reconnects, setReconnects] = useState(0); // The last loss of unsent edits this person has dismissed. let [lostSeen, setLostSeen] = useState(0); if (status === "connected" && reconnects) setReconnects(0); let [members, setMembers] = useState([]); let [effectiveCanEdit, setEffectiveCanEdit] = useState(canEdit && !archivedAt); let [effectiveCanManage, setEffectiveCanManage] = useState(canManage); let [deleted, setDeleted] = useState(false); let [chatReferences, setChatReferences] = useState<{ wire?: Wire; enabled: boolean }>({ enabled: false, }); let [chatSendAcks, setChatSendAcks] = useState<{ wire?: Wire; enabled: boolean }>({ enabled: false, }); let [metadata, setMetadata] = useState({ archivedAt, description, descriptionRevision, title: label, slug, updatedAt, }); let metadataRef = useRef(metadata); let repositoryRef = useRef(repository); repositoryRef.current = repository; let user = useMemo(() => cursor(handle), [handle]); let { available, frame, mode } = useWorkspaceLayout(); let workspaceIds = useWorkspaceIds(); let profile = workspaceProfile(presentation); let researchEnabled = profile.research; let [workspace, dispatch] = useWorkspaceState(profile); let [questions] = useState(() => new QuestionnaireStore()); let experiments = useMemo(() => new ExperimentStore(room), [room]); useSyncExternalStore(experiments.subscribe, experiments.snapshot); let [showExperiments, setShowExperiments] = useState(false); useEffect(() => experiments.connect(wire), [experiments, wire]); let [cardMeta] = useState(() => new CardMetaStore()); let [threads] = useState(() => new ThreadStore()); let [researchLauncher] = useState(() => new ResearchLauncher()); let research = useMemo( () => new ResearchRequestStore({ channelId: room, onOpen: (child, opener) => onResearchChildOpen(room, child, opener), onPublished: child => onResearchChildPublished(room, child), }), [onResearchChildOpen, onResearchChildPublished, room], ); let researchLinks = useResearchOfferLinks( wire, status === "connected", conversation.state?.researchOffers ?? [], ); let latestResearchLinks = useRef(researchLinks.links); latestResearchLinks.current = researchLinks.links; let pendingResearchActions = useRef(new Set()); let researchActionScope = useRef({ room, wire, connected: status === "connected", generation: 0, }); if ( researchActionScope.current.room !== room || researchActionScope.current.wire !== wire || researchActionScope.current.connected !== (status === "connected") ) { researchActionScope.current = { room, wire, connected: status === "connected", generation: researchActionScope.current.generation + 1, }; } let [researchBusy, setResearchBusy] = useState>(new Set()); let [researchErrors, setResearchErrors] = useState>({}); let [researchExecution, setResearchExecution] = useState(false); let researchDrafts = useMemo(() => new Map(), [room]); let [reveal, setReveal] = useState<{ widget: string; token: number }>(); let [planScrollTop, setPlanScrollTop] = useState(0); let entries = useQuestionnaires(questions); let cardMetadata = useSyncExternalStore(cardMeta.subscribe, cardMeta.snapshot, cardMeta.snapshot); let unanswered = countUnanswered(entries, cardMetadata); let hasPlanProse = useHasPlanContent(questions); let hasPlanContent = documentHasPlanningContent(hasPlanProse, entries); let [decisionView, setDecisionView] = useState(() => { let stored = localStorage.getItem("chopin:view:document"); return { phase: "initial", preferred: initialDocumentView(profile, stored), }; }); let view = visibleDecisionView(decisionView, hasPlanContent, unanswered); let attention = useDecisionAttention(unanswered); let latestCanEdit = useRef(canEdit); let latestCanManage = useRef(canManage); let workspacePresentation = presentWorkspace(workspace, mode, view); let chatActive = workspacePresentation.chatVisible; let [chatActivity, setChatActivity] = useState({ unread: 0, busy: false }); let onChatActivity = useCallback( (event: { type: "message" | "working"; busy: boolean }) => { setChatActivity(current => ({ busy: event.busy, unread: event.type === "message" && !chatActive ? current.unread + 1 : current.unread, })); }, [chatActive], ); let planVisible = workspacePresentation.documentVisible && view === "plan"; let latestPlanVisible = useRef(planVisible); latestPlanVisible.current = planVisible; let latestBusy = useRef(chatActivity.busy); latestBusy.current = chatActivity.busy; let [documentWatch, setDocumentWatch] = useState(QUIET_DOCUMENT); let updateMetadata = useCallback((next: WorkspaceMetadata) => { let previous = metadataRef.current; let metadata = newestDocumentMetadata(previous, next); metadataRef.current = metadata; setMetadata(metadata); let currentRepository = repositoryRef.current; rememberChannel( userId, { id: room, title: metadata.title, slug: metadata.slug }, currentRepository, ); onDocumentChanged(room, metadata); if (onMetadataChanged) { onMetadataChanged(metadata); return; } let previousPath = documentPath( currentRepository.owner, currentRepository.name, previous.slug, ); let channelPath = `/channels/${encodeURIComponent(room)}`; if (location.pathname !== previousPath && location.pathname !== channelPath) return; let path = documentPath(currentRepository.owner, currentRepository.name, metadata.slug); if (location.pathname !== path) { history.replaceState(history.state, "", `${path}${location.search}${location.hash}`); } }, [onDocumentChanged, onMetadataChanged, room, userId]); useEffect(() => { setDecisionView(state => advanceDecisionView(state, hasPlanContent, unanswered)); }, [hasPlanContent, unanswered]); useEffect(() => { if (!chatActive) return; setChatActivity(current => current.unread === 0 ? current : { ...current, unread: 0 }); }, [chatActive]); useEffect(() => { if (planVisible) setDocumentWatch(state => advanceDocumentActivity(state, { type: "seen" })); }, [planVisible]); useEffect(() => { setDocumentWatch(state => advanceDocumentActivity(state, { type: chatActivity.busy ? "started" : "idle" }) ); }, [chatActivity.busy]); useEffect(() => { if (documentWatch.following !== "awaiting") return; let timer = window.setTimeout( () => setDocumentWatch(state => advanceDocumentActivity(state, { type: "expired" })), ANSWER_FOLLOW_MS, ); return () => window.clearTimeout(timer); }, [documentWatch.following]); useEffect(() => { if (!wire) return; let offChanges = wire.on("plan:changes", () => { if (latestPlanVisible.current) return; setDocumentWatch(state => advanceDocumentActivity(state, { type: "changes", busy: latestBusy.current }) ); }); // Only this viewer's own answer resumes a turn worth following; a discard // or someone else's answer may start nothing. let offResolved = wire.on("question:resolved", event => { if (event.status !== "answered" || event.resolver !== handle) return; if (latestPlanVisible.current) return; setDocumentWatch(state => advanceDocumentActivity(state, { type: "answered", busy: latestBusy.current }) ); }); return () => { offChanges(); offResolved(); }; }, [wire, handle]); let selectView = (next: DecisionView, revealFirst = true) => { setDecisionView(state => selectDecisionView(state, next)); if (hasPlanContent && profile.persistView) { localStorage.setItem("chopin:view:document", next); } if (next === "decisions" && revealFirst) { let first = firstOpenDecision(entries, cardMetadata); setReveal({ widget: first?.id ?? "", token: Date.now() }); } }; let selectDestination = (destination: "plan" | "decisions") => { selectView(destination, mode === "split"); dispatch({ type: "set-chat", open: false }); }; useEffect(() => { setSourceDestination(undefined); }, [room]); useEffect(() => { if (!sourceDestination) return; let token = sourceDestination.token; let timeout = window.setTimeout(() => { setSourceDestination(current => current?.token === token ? undefined : current); }, 3_000); return () => window.clearTimeout(timeout); }, [sourceDestination]); useEffect(() => { let previous = priorConversation.current; let sameScope = previous?.store === conversationStore && previous.generation === conversationGeneration; let { summary, message } = advanceConversationAnnouncement( sameScope ? previous?.summary : undefined, conversation.state, ); priorConversation.current = { store: conversationStore, generation: conversationGeneration, summary, }; if (!sameScope || !conversation.state) { setAnnouncement(current => current.text ? { ...current, text: "" } : current); } if (message) { setAnnouncement(current => ({ text: message, sequence: current.sequence + 1 })); } }, [conversation.state, conversationStore, conversationGeneration]); let showDecisionCard = (questionnaireId: string) => { let entry = questions.snapshot().find(item => item.id === questionnaireId); let question = entry?.value.questions[0]?.id; if (entry && question) showPlan(entry.id, question); }; let setDesktopChatOpen = (open: boolean) => { dispatch({ type: "set-desktop-chat", open }); if (!open) dispatch({ type: "set-chat", open: false }); }; let showSource = useCallback((destination: SourceDestination) => { setSourceDestination({ ...destination, token: ++sourceToken.current }); if (mode === "split") setDesktopChatOpen(true); else dispatch({ type: "set-chat", open: true }); }, [dispatch, mode, setDesktopChatOpen]); let hasCardSource = useCallback((questionnaireId: string) => { let thread = conversation.state && threadForCard(conversation.state, questionnaireId); return !!thread?.questionSources[0]; }, [conversation.state]); let showCardSource = useCallback((questionnaireId: string) => { let thread = conversation.state && threadForCard(conversation.state, questionnaireId); let source = thread?.questionSources[0]; if (thread && source) showSource({ source, itemId: thread.id }); }, [conversation.state, showSource]); let showEvidence = useCallback((questionnaireId: string) => { if (!workspacePresentation.documentVisible || workspacePresentation.documentView !== "plan") { return null; } let state = conversation.enabled ? conversation.state : undefined; let thread = state && threadForCard(state, questionnaireId); let meta = cardMetadata.get(questionnaireId); if (!thread || !meta || (meta.status !== "open" && meta.status !== "reopened")) { return null; } let options = entries.find(entry => entry.id === questionnaireId) ?.value.questions[0]?.options; let rows = evidenceRows(thread, meta, options); return hasEvidence(rows) ? : null; }, [ cardMetadata, conversation.enabled, conversation.state, entries, showSource, workspacePresentation.documentView, workspacePresentation.documentVisible, ]); let retryAnalysis = async ( messageId: string, actionId: string, lane?: "decision" | "research", ) => { if (!wire || !workspaceCanEdit) throw new Error("This document is read-only."); try { await wire.ask("conversation-plan:retry", { messageId, actionId, ...(lane ? { lane } : {}) }); } catch { throw new Error("The message could not be retried. Try again when connected."); } }; let addExcerpt = async (action: ExcerptCorrectionAction) => { if (!wire || !workspaceCanEdit || status !== "connected") { throw new Error("This document is read-only or disconnected."); } try { await wire.ask("conversation-plan:correct", { actionId: action.actionId, threadId: action.threadId, expectedVersion: action.expectedVersion, change: action.change, }); } catch { throw new Error("The excerpt could not be added. Check that the card is still open."); } }; let retryJob = async (jobId: string) => { if (!wire || !workspaceCanEdit) throw new Error("This document is read-only."); try { let result = await wire.ask( "conversation-plan:retry-job", { jobId }, ); if (!result.queued) throw new Error("job is no longer retryable"); } catch { throw new Error("The Planner job could not be retried. Try again when connected."); } }; let showCard = (link: CardLink) => { let thread = conversation.state?.threads.find(item => item.id === link.threadId); let entry = thread?.questionnaireId ? questions.snapshot().find(item => item.id === thread.questionnaireId) : undefined; let question = entry?.value.questions[0]?.id; if (entry && question) showPlan(entry.id, question); }; let showPlan = (widget: string, question: string) => { selectDestination("plan"); requestAnimationFrame(() => { questions.reveal(widget, question); let card = document.querySelector( `[data-workspace-room="${ CSS.escape(room) }"] [data-document-view="plan"] article[data-plan-sidecar-questionnaire="${ CSS.escape(widget) }"]`, ); if (!card) return; card.tabIndex = -1; card.focus(); }); }; let startResearch = async (brief: string): Promise => { let checked = researchLauncher.check(brief); if (!checked.ok) return checked; selectDestination("plan"); // The document may have been hidden; open once it has laid out. await new Promise(resolve => requestAnimationFrame(resolve)); return researchLauncher.open(brief); }; let showResearchDraft = () => { selectDestination("plan"); requestAnimationFrame(() => researchLauncher.reveal()); }; useEffect(() => { let editable = canEdit && !archivedAt; latestCanEdit.current = editable; latestCanManage.current = canManage; setEffectiveCanEdit(editable); setEffectiveCanManage(canManage); }, [archivedAt, canEdit, canManage, room]); useEffect(() => { let next: WorkspaceMetadata = { archivedAt, description, descriptionRevision, title: label, slug, updatedAt, }; next = newestDocumentMetadata(metadataRef.current, next); metadataRef.current = next; setMetadata(next); }, [archivedAt, description, descriptionRevision, label, room, slug, updatedAt]); useEffect(() => () => research.reset(), [research]); useEffect(() => { pendingResearchActions.current.clear(); setResearchBusy(new Set()); setResearchErrors({}); }, [room, wire, status === "connected"]); useEffect(() => setResearchErrors({}), [conversation.state?.revision]); useEffect(() => { setResearchErrors(current => { let next = Object.fromEntries( Object.entries(current) .filter(([id]) => researchLinks.links[id]?.status !== "linked"), ); return Object.keys(next).length === Object.keys(current).length ? current : next; }); }, [researchLinks.links]); useEffect(() => { cardMeta.listen(undefined); let socket = new Wire({ channelId: room, onAuthenticationRequired: () => location.reload(), onDeleted: () => { setDeleted(true); onDocumentDeleted(room); }, onStatus: next => { if (next === "connected") { conversationStore.reset(); cardMeta.listen(undefined); } setStatus(next); }, }); setWire(socket); let off = [ conversationStore.listen(socket), socket.on("session:hello", frame => { let editable = frame.canEdit && !frame.archivedAt; let accessChanged = latestCanEdit.current !== editable || latestCanManage.current !== frame.canManage; latestCanEdit.current = editable; latestCanManage.current = frame.canManage; setMembers(frame.members); setEffectiveCanEdit(editable); setEffectiveCanManage(frame.canManage); setChatReferences({ wire: socket, enabled: frame.chatReferences === true }); setChatSendAcks({ wire: socket, enabled: frame.chatSendAcks === true }); setResearchExecution(frame.webResearch === true); updateMetadata(frame); if (accessChanged) onRepositoryAccessChanged(); }), socket.on("session:channel", frame => { if (frame.channelId !== room) return; let editable = !frame.archivedAt && frame.canManage; let accessChanged = latestCanEdit.current !== editable || latestCanManage.current !== frame.canManage; latestCanEdit.current = editable; latestCanManage.current = frame.canManage; setEffectiveCanEdit(editable); setEffectiveCanManage(frame.canManage); updateMetadata(frame); if (accessChanged) onRepositoryAccessChanged(); }), socket.on("session:presence", frame => setMembers(frame.members)), socket.on("session:access", frame => { latestCanEdit.current = frame.canEdit; latestCanManage.current = frame.canManage; setEffectiveCanEdit(frame.canEdit); setEffectiveCanManage(frame.canManage); onRepositoryAccessChanged(); }), socket.on("research:changed", frame => { if (researchEnabled) research.invalidate(frame.workspaceId); }), threads.listen(socket), cardMeta.listen(socket), ]; return () => { for (let unsubscribe of off) unsubscribe(); cardMeta.listen(undefined); socket.dispose(); setWire(undefined); }; }, [ handle, onDocumentDeleted, onRepositoryAccessChanged, research, researchEnabled, room, profile.surface, threads, cardMeta, conversationStore, updateMetadata, ]); let workspaceArchivedAt = archivedAt ?? metadata.archivedAt; let workspaceCanEdit = effectiveCanEdit && !workspaceArchivedAt; let actOnResearchOffer = ( offerId: string, choice: "research" | "dismiss" | "resume", ) => { if (!researchEnabled || !wire?.connected || status !== "connected" || !workspaceCanEdit) return; let offer = conversationStore.get().state?.researchOffers?.find(item => item.id === offerId); if ( !offer || (choice === "resume" ? offer.status !== "accepted" : offer.status !== "offered") ) { return; } if ( choice === "resume" && latestResearchLinks.current[offerId]?.status !== "pending" && latestResearchLinks.current[offerId]?.status !== "unlinked" ) return; if (pendingResearchActions.current.has(offerId)) return; pendingResearchActions.current.add(offerId); setResearchBusy(new Set(pendingResearchActions.current)); setResearchErrors(current => { let next = { ...current }; delete next[offerId]; return next; }); let payload = choice === "resume" ? { offerId, choice } : { offerId, choice, actionId: crypto.randomUUID() }; let generation = researchActionScope.current.generation; let currentScope = () => researchActionScope.current.generation === generation; void wire.ask( "conversation-plan:research", payload, ).then(result => { if (!currentScope()) return; if (result.offerId !== offerId) throw new Error("research action did not match the offer"); if (result.status === "accepted") researchLinks.refresh(offerId, true); }).catch(() => { if (!currentScope()) return; let currentOffer = conversationStore.get().state?.researchOffers ?.find(item => item.id === offerId); if ( !shouldShowResearchActionError( choice, currentOffer?.status, latestResearchLinks.current[offerId]?.status, ) ) return; setResearchErrors(current => ({ ...current, [offerId]: "Research status could not be confirmed. Try again when connected.", })); }).finally(() => { if (!currentScope()) return; pendingResearchActions.current.delete(offerId); setResearchBusy(new Set(pendingResearchActions.current)); }); }; if (deleted) { return (

This document was deleted.

); } return ( <> {showExperiments && ( setShowExperiments(false)} /> )}

{announcement.text}

selectDestination("decisions")} onResearch={researchEnabled ? startResearch : undefined} onShowResearch={showResearchDraft} people={peopleHere(members)} conversationPlan={conversation.state} conversationPlanJobs={conversation.jobs} onCardLink={showCard} onAddExcerpt={addExcerpt} onRetryAnalysis={retryAnalysis} onRetryJob={retryJob} sourceDestination={sourceDestination} researchOffers={researchEnabled && conversation.enabled ? { links: researchLinks.links, busy: researchBusy, errors: researchErrors, canAct: status === "connected" && !!wire?.connected && !!workspaceCanEdit, canCheckLink: status === "connected" && !!wire?.connected, canExecute: researchExecution, wire, controllers: researchDrafts, handle, onSource: source => showSource({ source: { ...source, role: "support" }, itemId: source.messageId }), store: research, onAction: actOnResearchOffer, onRetryLink: offerId => researchLinks.refresh(offerId, true), } : undefined} decisions={{ questions, meta: cardMeta, wire, connected: treatAsConnected, canEdit: workspaceCanEdit, onOpenCard: showDecisionCard, }} referencesEnabled={chatReferences.wire === wire && chatReferences.enabled} repository={repository} room={room} sendAcknowledgements={chatSendAcks.wire === wire && chatSendAcks.enabled} wire={wire} /> } chatActivity={chatActivity} documentActivity={documentActivity(documentWatch, chatActivity.busy)} header={
onDocumentAction(room, action)} onEditingChange={setTitleEdit} onRenamed={updateMetadata} presentation={presentation} project={{ id: repository.id, name: repository.name }} room={room} /> } controls={ <> } status={ <> {!!planState.lost && planState.lost !== lostSeen && (
{LOST_EDITS}
)} {/* Apart from PlanStatus, which would announce its end as "Reconnected". */} {planState.refused && (
)} {planState.refused && `Can't undo. ${UNDO_REFUSALS[planState.refused.reason]}`} { setReconnects(count => count + 1); wire.reconnect(); } : undefined} synced={planState.synced} /> } ids={workspaceIds} identity={room} mode={mode} onDesktopChatOpen={setDesktopChatOpen} onChatOpen={open => dispatch({ type: "set-chat", open })} onDestination={selectDestination} decisions={ } hasAdditional={[...experiments.values.values()].some(item => item.decisions.length > 0)} cardMeta={cardMeta} canEdit={workspaceCanEdit} connected={treatAsConnected && workspaceCanEdit} headingId={workspaceIds.heading.decisions} motion={motionContract("collapse")} motionImmediately={settleMotionImmediately} onShowPlan={showPlan} planner={agent} questionMotion={QUESTION_MOTION} reveal={reveal} self={handle} store={questions} wire={wire} /> } plan={ : undefined} questionMotion={QUESTION_MOTION} questions={questions} readOnly={!workspaceCanEdit} research={profile.research ? research : undefined} researchLauncher={researchLauncher} scrollTop={planScrollTop} threads={threads} user={user} wire={wire} /> } state={workspace} presentation={presentation} unanswered={unanswered} view={view} /> ); }