Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.
| 1 | import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react' |
| 2 | import { useBridge, useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import type { Analyst } from './analysts' |
| 4 | import { MessageRow } from './Message' |
| 5 | import type { RoomLive } from './rooms' |
| 6 | import './styles.css' |
| 7 | |
| 8 | // The research room. With no room in its state it lists the rooms there are and the analysts a room |
| 9 | // can have; with one, it is the thread: who is in the room and busy, every message as it grows, and |
| 10 | // the controls that need no words (stop, remove an analyst, show the folder). Talking to the room is |
| 11 | // the composer's job, through the orchestrator. |
| 12 | |
| 13 | type Listed = Omit<Analyst, 'instructions'> |
| 14 | |
| 15 | /** A room the orchestrator asked for by placing the view, not yet made. */ |
| 16 | interface Start { |
| 17 | name: string |
| 18 | analysts: string[] |
| 19 | tickers?: string[] |
| 20 | ask?: string |
| 21 | } |
| 22 | |
| 23 | interface RoomState { |
| 24 | room?: string |
| 25 | start?: Start |
| 26 | } |
| 27 | |
| 28 | /** The panel's state arrives a round trip after the frame mounts; nothing is shown as a room until it has. */ |
| 29 | export function RoomView({ panel }: { panel: PanelRef }): ReactElement { |
| 30 | const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as RoomState | undefined |
| 31 | if (state === undefined) { |
| 32 | return ( |
| 33 | <div className="rs-root"> |
| 34 | <div className="rs-bar"> |
| 35 | <span className="rs-muted">Loading…</span> |
| 36 | </div> |
| 37 | </div> |
| 38 | ) |
| 39 | } |
| 40 | return <Room panel={panel} initial={state} /> |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Which room this is. state.start is a room to make: the view creates it, posts its first question, |
| 45 | * and writes the new id to state.room, so the orchestrator starts a room with one placement and never |
| 46 | * has to know an id first. A state.room that names no room shows the rooms there are, and says so. |
| 47 | */ |
| 48 | function Room({ panel, initial }: { panel: PanelRef; initial: RoomState }): ReactElement { |
| 49 | const bridge = useBridge() |
| 50 | const [room, setRoom] = usePanelState<string | undefined>(panel, 'room', initial.room) |
| 51 | const [start, setStart] = usePanelState<Start | undefined>(panel, 'start', initial.start) |
| 52 | const analysts = (useData('live/analysts') as Listed[] | undefined) ?? [] |
| 53 | const [missing, setMissing] = useState<string | null>(null) |
| 54 | const [failure, setFailure] = useState<string | null>(null) |
| 55 | const [note, setNote] = useState<string | null>(null) |
| 56 | const creating = useRef(false) |
| 57 | |
| 58 | // A different room, however it was set, gets its own chance to load. |
| 59 | useEffect(() => { |
| 60 | setMissing(null) |
| 61 | setFailure(null) |
| 62 | }, [room]) |
| 63 | |
| 64 | useEffect(() => { |
| 65 | if (!start || creating.current || (room && !missing)) return |
| 66 | creating.current = true |
| 67 | setFailure(null) |
| 68 | void (async () => { |
| 69 | try { |
| 70 | const made = await bridge.runSource('research/create-room', { name: start.name, analysts: start.analysts, tickers: start.tickers ?? [] }) |
| 71 | const result = made.kind === 'text' ? (JSON.parse(made.text) as { room?: string; analysts?: string[]; unmatched?: string[] }) : {} |
| 72 | const id = result.room |
| 73 | if (!id) throw new Error('The room was not created.') |
| 74 | setMissing(null) |
| 75 | setRoom(id) |
| 76 | setStart(undefined) |
| 77 | const left = result.unmatched ?? [] |
| 78 | setNote(left.length > 0 ? `Left out ${left.join(', ')}: no analyst by that name.` : null) |
| 79 | if (start.ask?.trim()) await bridge.runSource('research/post', { room: id, text: start.ask, to: result.analysts ?? start.analysts }) |
| 80 | } catch (err) { |
| 81 | setFailure(`Could not start ${start.name}: ${err instanceof Error ? err.message : String(err)}`) |
| 82 | setStart(undefined) |
| 83 | } finally { |
| 84 | creating.current = false |
| 85 | } |
| 86 | })() |
| 87 | }, [bridge, start, room, missing, setRoom, setStart]) |
| 88 | |
| 89 | if (room && !missing) { |
| 90 | return <Thread key={room} panel={panel} room={room} analysts={analysts} note={note} onLeave={() => setRoom(undefined)} onMissing={setMissing} /> |
| 91 | } |
| 92 | const notice = start ? null : failure ?? (missing && room ? `There is no room "${room}". Open one below, or ask Jaspers to start a room.` : null) |
| 93 | return ( |
| 94 | <Picker |
| 95 | panel={panel} |
| 96 | analysts={analysts} |
| 97 | starting={start?.name ?? null} |
| 98 | notice={notice} |
| 99 | onOpen={(id) => { |
| 100 | setMissing(null) |
| 101 | setFailure(null) |
| 102 | setRoom(id) |
| 103 | }} |
| 104 | /> |
| 105 | ) |
| 106 | } |
| 107 | |
| 108 | function Picker({ |
| 109 | panel, |
| 110 | analysts, |
| 111 | starting, |
| 112 | notice, |
| 113 | onOpen, |
| 114 | }: { |
| 115 | panel: PanelRef |
| 116 | analysts: Listed[] |
| 117 | starting: string | null |
| 118 | notice: string | null |
| 119 | onOpen: (room: string) => void |
| 120 | }): ReactElement { |
| 121 | const { data, loading, error } = useData('research/rooms', {}) |
| 122 | const rooms = (data ?? []) as { id: string; name: string; tickers: string[]; analysts: string[]; working: string[] }[] |
| 123 | usePublish(panel, { |
| 124 | room: null, |
| 125 | available: analysts.filter((a) => a.error === null).length, |
| 126 | ...(starting ? { starting } : {}), |
| 127 | ...(notice ? { error: notice } : {}), |
| 128 | }) |
| 129 | return ( |
| 130 | <div className="rs-root"> |
| 131 | <div className="rs-bar"> |
| 132 | <span className="rs-title">Research rooms</span> |
| 133 | <span className="rs-muted">Ask Jaspers: “start a research room on FLWS with Credit and Risk”.</span> |
| 134 | </div> |
| 135 | <div className="rs-scroll"> |
| 136 | {starting && <p className="rs-muted rs-pad">Starting {starting}…</p>} |
| 137 | {notice && <p className="rs-error rs-pad">{notice}</p>} |
| 138 | {error && <p className="rs-error rs-pad">{error}</p>} |
| 139 | {loading && data === undefined && <p className="rs-muted rs-pad">Loading…</p>} |
| 140 | {!loading && !error && rooms.length === 0 && !starting && <p className="rs-muted rs-pad">No rooms on this workspace yet.</p>} |
| 141 | {rooms.map((room) => ( |
| 142 | <div key={room.id} className="rs-row"> |
| 143 | <div className="rs-body"> |
| 144 | <div className="rs-line"> |
| 145 | <span className="rs-name">{room.name}</span> |
| 146 | <span className="rs-muted">{room.tickers.join(', ')}</span> |
| 147 | </div> |
| 148 | <div className="rs-muted"> |
| 149 | {room.analysts.join(', ') || 'no analysts'} |
| 150 | {room.working.length > 0 ? ` · ${room.working.join(', ')} working` : ''} |
| 151 | </div> |
| 152 | </div> |
| 153 | <button type="button" className="rs-btn" onClick={() => onOpen(room.id)}> |
| 154 | Open |
| 155 | </button> |
| 156 | </div> |
| 157 | ))} |
| 158 | <div className="rs-section">Analysts</div> |
| 159 | {analysts.map((analyst) => ( |
| 160 | <div key={analyst.id} className="rs-row"> |
| 161 | <span className="rs-swatch" style={{ background: analyst.color ?? 'var(--jaspers-muted-foreground, #737373)' }} aria-hidden /> |
| 162 | <div className="rs-body"> |
| 163 | <div className="rs-line"> |
| 164 | <span className="rs-name">{analyst.name}</span> |
| 165 | <span className="rs-muted"> |
| 166 | {analyst.id} |
| 167 | {analyst.origin === 'user' ? ' · yours' : ''} |
| 168 | </span> |
| 169 | </div> |
| 170 | {analyst.error !== null ? <div className="rs-error">{analyst.error}</div> : <div className="rs-muted rs-clamp">{analyst.description}</div>} |
| 171 | </div> |
| 172 | </div> |
| 173 | ))} |
| 174 | </div> |
| 175 | </div> |
| 176 | ) |
| 177 | } |
| 178 | |
| 179 | function Thread({ |
| 180 | panel, |
| 181 | room, |
| 182 | analysts, |
| 183 | note, |
| 184 | onLeave, |
| 185 | onMissing, |
| 186 | }: { |
| 187 | panel: PanelRef |
| 188 | room: string |
| 189 | analysts: Listed[] |
| 190 | note: string | null |
| 191 | onLeave: () => void |
| 192 | onMissing: (message: string) => void |
| 193 | }): ReactElement { |
| 194 | const bridge = useBridge() |
| 195 | const live = useData(`live/rooms/${room}`) as RoomLive | undefined |
| 196 | // The room is loaded into the host when the view opens, and again if the host restarted and its |
| 197 | // live values went away with it. |
| 198 | const [attempt, setAttempt] = useState(0) |
| 199 | const { error } = useData('research/load-room', { room, attempt }) |
| 200 | // A room id that names no room goes back to the list, which says so. |
| 201 | useEffect(() => { |
| 202 | if (error && /^No room /.test(error)) onMissing(error) |
| 203 | }, [error, onMissing]) |
| 204 | const had = useRef(false) |
| 205 | useEffect(() => { |
| 206 | if (live) had.current = true |
| 207 | else if (had.current) { |
| 208 | had.current = false |
| 209 | setAttempt((n) => n + 1) |
| 210 | } |
| 211 | }, [live]) |
| 212 | usePublish(panel, live ? { ...live.output } : { room }) |
| 213 | |
| 214 | const names = useMemo(() => Object.fromEntries(analysts.map((a) => [a.id, a.name])), [analysts]) |
| 215 | const colors = useMemo(() => Object.fromEntries(analysts.map((a) => [a.id, a.color])), [analysts]) |
| 216 | const [problem, setProblem] = useState<string | null>(null) |
| 217 | const act = (source: string, args: Record<string, unknown>): void => { |
| 218 | setProblem(null) |
| 219 | bridge.runSource(source, args).catch((err: unknown) => setProblem(err instanceof Error ? err.message : String(err))) |
| 220 | } |
| 221 | const copy = (text: string): Promise<boolean> => { |
| 222 | setProblem(null) |
| 223 | return bridge.copyText(text).then( |
| 224 | () => true, |
| 225 | (err: unknown) => { |
| 226 | setProblem(err instanceof Error ? err.message : String(err)) |
| 227 | return false |
| 228 | }, |
| 229 | ) |
| 230 | } |
| 231 | |
| 232 | // Follow new content while the reader is at the bottom; leave them be once they scroll up. |
| 233 | const scroller = useRef<HTMLDivElement>(null) |
| 234 | const content = useRef<HTMLDivElement>(null) |
| 235 | const pinned = useRef(true) |
| 236 | useEffect(() => { |
| 237 | const box = scroller.current |
| 238 | const inner = content.current |
| 239 | if (!box || !inner) return |
| 240 | const observer = new ResizeObserver(() => { |
| 241 | if (pinned.current) box.scrollTop = box.scrollHeight |
| 242 | }) |
| 243 | observer.observe(inner) |
| 244 | return () => observer.disconnect() |
| 245 | }, []) |
| 246 | |
| 247 | const output = live?.output |
| 248 | const busy = output?.analysts.some((a) => a.status !== 'idle') ?? false |
| 249 | return ( |
| 250 | <div className="rs-root"> |
| 251 | <div className="rs-bar"> |
| 252 | <button type="button" className="rs-link" onClick={onLeave}> |
| 253 | Rooms |
| 254 | </button> |
| 255 | <span className="rs-title">{live?.room.name ?? room}</span> |
| 256 | {live?.room.tickers.map((ticker) => ( |
| 257 | <span key={ticker} className="rs-tag"> |
| 258 | {ticker} |
| 259 | </span> |
| 260 | ))} |
| 261 | <span className="rs-roster"> |
| 262 | {output?.analysts.map((analyst) => ( |
| 263 | <span key={analyst.id} className="rs-chip"> |
| 264 | <span className="rs-swatch" style={{ background: colors[analyst.id] ?? 'var(--jaspers-muted-foreground, #737373)' }} aria-hidden /> |
| 265 | {analyst.name} |
| 266 | {analyst.status !== 'idle' && <span className="rs-muted"> {analyst.status}</span>} |
| 267 | <button |
| 268 | type="button" |
| 269 | className="rs-x" |
| 270 | aria-label={`Remove ${analyst.name}`} |
| 271 | onClick={() => act('research/update-room', { room, analysts: live!.room.analysts.filter((id) => id !== analyst.id) })} |
| 272 | > |
| 273 | × |
| 274 | </button> |
| 275 | </span> |
| 276 | ))} |
| 277 | </span> |
| 278 | {busy && ( |
| 279 | <button type="button" className="rs-btn" onClick={() => act('research/stop', { room })}> |
| 280 | Stop |
| 281 | </button> |
| 282 | )} |
| 283 | <button type="button" className="rs-btn" onClick={() => act('research/reveal', { room })}> |
| 284 | Show folder |
| 285 | </button> |
| 286 | </div> |
| 287 | {note && <p className="rs-muted rs-pad">{note}</p>} |
| 288 | {(error || problem) && <p className="rs-error rs-pad">{problem ?? error}</p>} |
| 289 | <div |
| 290 | className="rs-scroll" |
| 291 | ref={scroller} |
| 292 | onScroll={(event) => { |
| 293 | const box = event.currentTarget |
| 294 | pinned.current = box.scrollHeight - box.scrollTop - box.clientHeight < 40 |
| 295 | }} |
| 296 | > |
| 297 | <div ref={content} className="rs-thread"> |
| 298 | {live && live.seqs.length === 0 && ( |
| 299 | <p className="rs-muted rs-pad">No messages yet. Talk to the room through Jaspers: “ask Credit what the refinancing risk is”.</p> |
| 300 | )} |
| 301 | {live?.seqs.map((seq) => ( |
| 302 | <MessageRow |
| 303 | key={seq} |
| 304 | room={room} |
| 305 | seq={seq} |
| 306 | names={names} |
| 307 | colors={colors} |
| 308 | onLink={(href) => void bridge.openLink(href).catch((err: unknown) => setProblem(err instanceof Error ? err.message : String(err)))} |
| 309 | onFile={(path) => act('research/open-file', { room, path })} |
| 310 | onCopy={copy} |
| 311 | /> |
| 312 | ))} |
| 313 | </div> |
| 314 | </div> |
| 315 | </div> |
| 316 | ) |
| 317 | } |