import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react' import { useBridge, useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk' import type { Analyst } from './analysts' import { MessageRow } from './Message' import type { RoomLive } from './rooms' import './styles.css' // The research room. With no room in its state it lists the rooms there are and the analysts a room // can have; with one, it is the thread: who is in the room and busy, every message as it grows, and // the controls that need no words (stop, remove an analyst, show the folder). Talking to the room is // the composer's job, through the orchestrator. type Listed = Omit /** A room the orchestrator asked for by placing the view, not yet made. */ interface Start { name: string analysts: string[] tickers?: string[] ask?: string } interface RoomState { room?: string start?: Start } /** The panel's state arrives a round trip after the frame mounts; nothing is shown as a room until it has. */ export function RoomView({ panel }: { panel: PanelRef }): ReactElement { const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as RoomState | undefined if (state === undefined) { return (
Loading…
) } return } /** * Which room this is. state.start is a room to make: the view creates it, posts its first question, * and writes the new id to state.room, so the orchestrator starts a room with one placement and never * has to know an id first. A state.room that names no room shows the rooms there are, and says so. */ function Room({ panel, initial }: { panel: PanelRef; initial: RoomState }): ReactElement { const bridge = useBridge() const [room, setRoom] = usePanelState(panel, 'room', initial.room) const [start, setStart] = usePanelState(panel, 'start', initial.start) const analysts = (useData('live/analysts') as Listed[] | undefined) ?? [] const [missing, setMissing] = useState(null) const [failure, setFailure] = useState(null) const [note, setNote] = useState(null) const creating = useRef(false) // A different room, however it was set, gets its own chance to load. useEffect(() => { setMissing(null) setFailure(null) }, [room]) useEffect(() => { if (!start || creating.current || (room && !missing)) return creating.current = true setFailure(null) void (async () => { try { const made = await bridge.runSource('research/create-room', { name: start.name, analysts: start.analysts, tickers: start.tickers ?? [] }) const result = made.kind === 'text' ? (JSON.parse(made.text) as { room?: string; analysts?: string[]; unmatched?: string[] }) : {} const id = result.room if (!id) throw new Error('The room was not created.') setMissing(null) setRoom(id) setStart(undefined) const left = result.unmatched ?? [] setNote(left.length > 0 ? `Left out ${left.join(', ')}: no analyst by that name.` : null) if (start.ask?.trim()) await bridge.runSource('research/post', { room: id, text: start.ask, to: result.analysts ?? start.analysts }) } catch (err) { setFailure(`Could not start ${start.name}: ${err instanceof Error ? err.message : String(err)}`) setStart(undefined) } finally { creating.current = false } })() }, [bridge, start, room, missing, setRoom, setStart]) if (room && !missing) { return setRoom(undefined)} onMissing={setMissing} /> } const notice = start ? null : failure ?? (missing && room ? `There is no room "${room}". Open one below, or ask Jaspers to start a room.` : null) return ( { setMissing(null) setFailure(null) setRoom(id) }} /> ) } function Picker({ panel, analysts, starting, notice, onOpen, }: { panel: PanelRef analysts: Listed[] starting: string | null notice: string | null onOpen: (room: string) => void }): ReactElement { const { data, loading, error } = useData('research/rooms', {}) const rooms = (data ?? []) as { id: string; name: string; tickers: string[]; analysts: string[]; working: string[] }[] usePublish(panel, { room: null, available: analysts.filter((a) => a.error === null).length, ...(starting ? { starting } : {}), ...(notice ? { error: notice } : {}), }) return (
Research rooms Ask Jaspers: “start a research room on FLWS with Credit and Risk”.
{starting &&

Starting {starting}…

} {notice &&

{notice}

} {error &&

{error}

} {loading && data === undefined &&

Loading…

} {!loading && !error && rooms.length === 0 && !starting &&

No rooms on this workspace yet.

} {rooms.map((room) => (
{room.name} {room.tickers.join(', ')}
{room.analysts.join(', ') || 'no analysts'} {room.working.length > 0 ? ` · ${room.working.join(', ')} working` : ''}
))}
Analysts
{analysts.map((analyst) => (
{analyst.name} {analyst.id} {analyst.origin === 'user' ? ' · yours' : ''}
{analyst.error !== null ?
{analyst.error}
:
{analyst.description}
}
))}
) } function Thread({ panel, room, analysts, note, onLeave, onMissing, }: { panel: PanelRef room: string analysts: Listed[] note: string | null onLeave: () => void onMissing: (message: string) => void }): ReactElement { const bridge = useBridge() const live = useData(`live/rooms/${room}`) as RoomLive | undefined // The room is loaded into the host when the view opens, and again if the host restarted and its // live values went away with it. const [attempt, setAttempt] = useState(0) const { error } = useData('research/load-room', { room, attempt }) // A room id that names no room goes back to the list, which says so. useEffect(() => { if (error && /^No room /.test(error)) onMissing(error) }, [error, onMissing]) const had = useRef(false) useEffect(() => { if (live) had.current = true else if (had.current) { had.current = false setAttempt((n) => n + 1) } }, [live]) usePublish(panel, live ? { ...live.output } : { room }) const names = useMemo(() => Object.fromEntries(analysts.map((a) => [a.id, a.name])), [analysts]) const colors = useMemo(() => Object.fromEntries(analysts.map((a) => [a.id, a.color])), [analysts]) const [problem, setProblem] = useState(null) const act = (source: string, args: Record): void => { setProblem(null) bridge.runSource(source, args).catch((err: unknown) => setProblem(err instanceof Error ? err.message : String(err))) } const copy = (text: string): Promise => { setProblem(null) return bridge.copyText(text).then( () => true, (err: unknown) => { setProblem(err instanceof Error ? err.message : String(err)) return false }, ) } // Follow new content while the reader is at the bottom; leave them be once they scroll up. const scroller = useRef(null) const content = useRef(null) const pinned = useRef(true) useEffect(() => { const box = scroller.current const inner = content.current if (!box || !inner) return const observer = new ResizeObserver(() => { if (pinned.current) box.scrollTop = box.scrollHeight }) observer.observe(inner) return () => observer.disconnect() }, []) const output = live?.output const busy = output?.analysts.some((a) => a.status !== 'idle') ?? false return (
{live?.room.name ?? room} {live?.room.tickers.map((ticker) => ( {ticker} ))} {output?.analysts.map((analyst) => ( {analyst.name} {analyst.status !== 'idle' && {analyst.status}} ))} {busy && ( )}
{note &&

{note}

} {(error || problem) &&

{problem ?? error}

}
{ const box = event.currentTarget pinned.current = box.scrollHeight - box.scrollTop - box.clientHeight < 40 }} >
{live && live.seqs.length === 0 && (

No messages yet. Talk to the room through Jaspers: “ask Credit what the refinancing risk is”.

)} {live?.seqs.map((seq) => ( void bridge.openLink(href).catch((err: unknown) => setProblem(err instanceof Error ? err.message : String(err)))} onFile={(path) => act('research/open-file', { room, path })} onCopy={copy} /> ))}
) }