import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react"; import { SearchIcon } from "@chopin/icons"; import type { ConversationPlan, Research } from "@chopin/protocol"; import type { ResearchRequestStore } from "../research-requests"; import type { Wire } from "../wire"; import { ResearchDraftController } from "./research-draft-controller"; import { ResearchBriefEditor } from "./research-brief-editor"; const RETRY_DELAYS = [2_000, 5_000, 10_000]; export type OfferLinkView = { status: "checking" | "pending" | "unlinked" | "linked" | "error"; researchRequestId?: string; exhausted?: boolean; }; export type ResearchOfferControls = { links: Readonly>; busy: ReadonlySet; errors: Readonly>; canAct: boolean; canCheckLink: boolean; canExecute?: boolean; wire?: Wire; controllers?: Map; onSource?: (source: ConversationPlan.ResearchSource) => void; store: ResearchRequestStore; onAction: (offerId: string, choice: "research" | "dismiss" | "resume") => void; onRetryLink: (offerId: string) => void; }; export function shouldShowResearchActionError( choice: "research" | "dismiss" | "resume", offerStatus: ConversationPlan.ResearchOffer["status"] | undefined, linkStatus: OfferLinkView["status"] | undefined, ): boolean { return choice === "resume" ? offerStatus === "accepted" && linkStatus !== "linked" : offerStatus === "offered"; } type Tracker = { id: string; inFlight: boolean; dirty: boolean; retries: number; status: OfferLinkView["status"]; timer?: ReturnType; }; /** Read-only accepted-offer observer; the timer boundary is injected for controlled-clock tests. */ export class ResearchOfferLinkObserver { #trackers = new Map(); #links: Record = {}; #disposed = false; constructor( private readonly ask: (offerId: string) => Promise, private readonly publish: (links: Readonly>) => void, private readonly schedule = (run: () => void, ms: number) => setTimeout(run, ms), private readonly cancel = (timer: ReturnType) => clearTimeout(timer), ) {} accept(ids: ReadonlySet): void { if (this.#disposed) return; let changed = false; for (let [id, tracker] of this.#trackers) { if (ids.has(id)) continue; if (tracker.timer !== undefined) this.cancel(tracker.timer); this.#trackers.delete(id); delete this.#links[id]; changed = true; } for (let id of ids) { if (this.#trackers.has(id)) continue; this.#trackers.set(id, { id, inFlight: false, dirty: false, retries: 0, status: "checking", }); this.#links[id] = { status: "checking" }; changed = true; this.#read(id); } if (changed) this.#publish(); } changed(): void { for (let tracker of this.#trackers.values()) { if (tracker.status !== "linked") this.#read(tracker.id); } } refresh(id: string, restart = false): void { let tracker = this.#trackers.get(id); if (!tracker) return; if (restart && tracker.status !== "linked") { tracker.retries = 0; tracker.status = "checking"; this.#links[id] = { status: "checking" }; this.#publish(); } this.#read(id); } dispose(): void { this.#disposed = true; for (let tracker of this.#trackers.values()) { if (tracker.timer !== undefined) this.cancel(tracker.timer); } this.#trackers.clear(); } #publish(): void { this.publish({ ...this.#links }); } #read(id: string): void { let tracker = this.#trackers.get(id); if (!tracker || this.#disposed || tracker.status === "linked") return; if (tracker.timer !== undefined) this.cancel(tracker.timer); tracker.timer = undefined; if (tracker.inFlight) { tracker.dirty = true; return; } tracker.inFlight = true; void this.ask(id).then(result => { if (this.#trackers.get(id) !== tracker) return; if ( result.offerId !== id || !["pending", "unlinked", "linked"].includes(result.status) || result.status === "pending" && result.researchRequestId !== undefined || result.status !== "pending" && !result.researchRequestId ) throw new Error("research link response did not match the offer"); tracker.status = result.status; this.#links[id] = { status: result.status, ...(result.researchRequestId ? { researchRequestId: result.researchRequestId } : {}), }; this.#publish(); }).catch(() => { if (this.#trackers.get(id) !== tracker) return; tracker.status = "error"; this.#links[id] = { status: "error" }; this.#publish(); }).finally(() => { if (this.#trackers.get(id) !== tracker) return; tracker.inFlight = false; if (tracker.status === "linked") return; if (tracker.dirty) { tracker.dirty = false; this.#read(id); return; } if (tracker.retries < RETRY_DELAYS.length) { let delay = RETRY_DELAYS[tracker.retries++]!; tracker.timer = this.schedule(() => this.#read(id), delay); } else { this.#links[id] = { ...this.#links[id]!, exhausted: true }; this.#publish(); } }); } } /** Observe exact accepted-offer links without starting or resuming research. */ export function useResearchOfferLinks( wire: Wire | undefined, connected: boolean, offers: readonly ConversationPlan.ResearchOffer[], ): { links: Readonly>; refresh: (offerId: string, restart?: boolean) => void; } { let [links, setLinks] = useState>>({}); let observer = useRef(undefined); useEffect(() => { if (!wire || !connected) { setLinks({}); return; } let current = new ResearchOfferLinkObserver( offerId => wire.ask( "conversation-plan:research-link", { offerId }, ), setLinks, ); observer.current = current; let off = wire.on("research:changed", () => current.changed()); return () => { off(); current.dispose(); if (observer.current === current) observer.current = undefined; }; }, [wire, connected]); useEffect(() => { observer.current?.accept( new Set( offers.filter(offer => offer.status === "accepted") .map(offer => offer.id), ), ); }, [wire, connected, offers]); let refresh = useCallback((offerId: string, restart = false) => { observer.current?.refresh(offerId, restart); }, []); return { links, refresh }; } function LinkedResearch( { id, store, canRetry }: { id: string; store: ResearchRequestStore; canRetry: boolean }, ) { let [retrying, setRetrying] = useState(false); let [error, setError] = useState(""); let subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]); let request = useSyncExternalStore(subscribe, () => store.get(id), () => undefined); useEffect(() => store.retain(id), [id, store]); if (!request) return

Loading research…

; return (
{request.stage === "ready" ? "Research ready" : `Research ${request.stage}`} {request.activity &&

{request.activity}

} {request.stage === "failed" && (

{request.error}

)} {canRetry && (request.stage === "failed" || request.stage === "cancelled") && ( )} {error &&

{error}

} {request.stage === "ready" && request.child && ( )}
); } export function ResearchOfferCard( { offer, controls }: { offer: ConversationPlan.ResearchOffer; controls: ResearchOfferControls }, ) { let busy = controls.busy.has(offer.id); let [controller] = useState(() => { let controller = controls.controllers?.get(offer.id) ?? new ResearchDraftController(offer); controls.controllers?.set(offer.id, controller); return controller; }); let draft = useSyncExternalStore(controller.subscribe, controller.get, controller.get); let [actionError, setActionError] = useState(""); let [acting, setActing] = useState(false); let [editors, setEditors] = useState>({}); useLayoutEffect(() => { controller.configure(controls.wire, !!controls.wire?.connected, offer); }, [controller, controls.wire, controls.canAct, offer]); useEffect(() => { setEditors({}); if (!controls.wire?.connected) return; return controls.wire.on( "conversation-plan:research-presence", frame => { if (frame.offerId !== offer.id) return; setEditors(previous => { let next = { ...previous }; if (frame.editing) next[frame.client] = frame.handle; else delete next[frame.client]; return next; }); }, ); }, [controls.wire, controls.canAct, offer.id]); useEffect(() => () => controller.focus(false), [controller]); let perform = (run: () => Promise) => { setActing(true); setActionError(""); void run().catch(error => setActionError(error instanceof Error ? error.message : "Research action failed.") ).finally(() => setActing(false)); }; let edit = async (operation: ConversationPlan.ResearchEdit["operation"]) => { if (!controls.wire?.connected) throw new Error("Reconnect to edit research."); await controller.flush(); let result = await controls.wire.ask( "conversation-plan:research-edit", { offerId: offer.id, operation }, ); controller.receive(result.offer); }; let disabled = busy || acting; let canExecute = controls.canExecute ?? controls.canAct; let link = controls.links[offer.id]; let canResume = link?.status === "pending" || link?.status === "unlinked"; let canRetryLink = link?.status === "error" && link.exhausted && controls.canCheckLink; return (
{offer.workflow?.previousOfferId && (

Follow-up to earlier research

)} {offer.status === "offered" && Object.keys(editors).length > 0 && (

Editing: {[...new Set(Object.values(editors))].map(handle => `@${handle}` ).join(", ")}

)} {offer.workflow && controls.onSource && (
{offer.workflow.sources.map(source => ( ))}
)} {offer.status === "offered" && offer.workflow?.additions.filter(item => item.status === "pending").map(addition => (

Suggested addition

{addition.text}

{controls.canAct && (
)}
))} {offer.status === "offered" && controls.canAct && (
{offer.workflow && controls.wire && ( )}
)} {offer.status === "offered" && controls.canAct && !canExecute && (

Research execution is unavailable on this instance.

)} {offer.status === "offered" && offer.workflow?.preparation === "failed" && (
Brief refinement failed.{controls.canAct && ( )}
)} {offer.status === "dismissed" &&

Dismissed

} {offer.status === "accepted" && link?.status === "linked" && link.researchRequestId ? ( ) : offer.status === "accepted" && (
{link?.exhausted || link?.status === "error" ? "Accepted; request link not yet verified" : link?.status === "unlinked" ? "Accepted; waiting to start" : "Research accepted"} {controls.canAct && canExecute && canResume && ( )} {canRetryLink && ( )}
)} {busy &&

Saving…

} {(actionError || draft.error) && (

{actionError || draft.error}

)} {link?.status !== "linked" && controls.errors[offer.id] && (

{controls.errors[offer.id]}

)}
); }