Research Agents: Lets users define research agents that answer chat questions from SEC filings, uploaded documents, and the web, with every figure cited.
| 1 | import { useCallback, useRef, useState, type ReactElement } from 'react' |
| 2 | import { RefreshButton } from './buttons' |
| 3 | import { Meter } from './Meter' |
| 4 | import { readsText, type CompanyStatus } from './sec' |
| 5 | |
| 6 | // One company the store holds, as a chip with its ticker, a line along its foot while it is read or |
| 7 | // embedded, and a mark when something failed. Hovered or focused, it opens a card: its name, how far |
| 8 | // its reading and embedding are, in words and as a bar, what failed, and its refresh. Companies are |
| 9 | // everyone's, so there is no stop and no delete. The card is placed against the window, so the |
| 10 | // scrolling list does not clip it. One card is open at a time: hovering another chip swaps it at once. |
| 11 | |
| 12 | /** How long the card waits after the pointer leaves, so it can cross the gap from the chip to the card. */ |
| 13 | const CLOSE_MS = 150 |
| 14 | const CARD_WIDTH = 300 |
| 15 | |
| 16 | /** Which company's card is open, for the whole list: showing one closes any other at once. */ |
| 17 | export function useCompanyCard(): { |
| 18 | open: string | null |
| 19 | show: (ticker: string) => void |
| 20 | hide: (ticker: string) => void |
| 21 | } { |
| 22 | const [open, setOpen] = useState<string | null>(null) |
| 23 | const timer = useRef<ReturnType<typeof setTimeout> | null>(null) |
| 24 | const show = useCallback((ticker: string) => { |
| 25 | if (timer.current) clearTimeout(timer.current) |
| 26 | setOpen(ticker) |
| 27 | }, []) |
| 28 | const hide = useCallback((ticker: string) => { |
| 29 | if (timer.current) clearTimeout(timer.current) |
| 30 | timer.current = setTimeout(() => setOpen((now) => (now === ticker ? null : now)), CLOSE_MS) |
| 31 | }, []) |
| 32 | return { open, show, hide } |
| 33 | } |
| 34 | |
| 35 | export function CompanyChip({ |
| 36 | c, |
| 37 | run, |
| 38 | open, |
| 39 | onShow, |
| 40 | onHide, |
| 41 | }: { |
| 42 | c: CompanyStatus |
| 43 | run: (source: string, args: unknown) => Promise<void> |
| 44 | open: boolean |
| 45 | onShow: () => void |
| 46 | onHide: () => void |
| 47 | }): ReactElement { |
| 48 | const chip = useRef<HTMLButtonElement>(null) |
| 49 | const [place, setPlace] = useState<{ left: number; width: number; top?: number; bottom?: number } | null>(null) |
| 50 | const show = (): void => { |
| 51 | onShow() |
| 52 | const r = chip.current?.getBoundingClientRect() |
| 53 | if (!r) return |
| 54 | const width = Math.min(CARD_WIDTH, window.innerWidth - 16) |
| 55 | const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8)) |
| 56 | // Low in the window, the card opens upward. |
| 57 | setPlace( |
| 58 | r.bottom > window.innerHeight * 0.6 |
| 59 | ? { left, width, bottom: window.innerHeight - r.top + 4 } |
| 60 | : { left, width, top: r.bottom + 4 }, |
| 61 | ) |
| 62 | } |
| 63 | const reading = c.progress?.running ? c.progress : null |
| 64 | const share = reading |
| 65 | ? reading.done / Math.max(reading.total, 1) |
| 66 | : c.unembedded > 0 |
| 67 | ? (c.chunks - c.unembedded) / Math.max(c.chunks, 1) |
| 68 | : null |
| 69 | const trouble = Boolean(c.progress?.failed || c.progress?.error) |
| 70 | return ( |
| 71 | <span className="ag-chip-wrap" onMouseEnter={show} onMouseLeave={onHide}> |
| 72 | <button |
| 73 | ref={chip} |
| 74 | type="button" |
| 75 | className={open ? 'ag-chip ag-chip-open' : 'ag-chip'} |
| 76 | aria-expanded={open} |
| 77 | aria-label={`${c.ticker}, ${c.name}: ${line(c)}`} |
| 78 | onFocus={show} |
| 79 | onBlur={onHide} |
| 80 | > |
| 81 | {c.ticker} |
| 82 | {trouble && <span className="ag-chip-mark" />} |
| 83 | {share !== null && ( |
| 84 | <span className="ag-chip-bar"> |
| 85 | <span style={{ width: `${Math.round(Math.min(1, share) * 100)}%` }} /> |
| 86 | </span> |
| 87 | )} |
| 88 | </button> |
| 89 | {open && place && ( |
| 90 | <div |
| 91 | className="ag-agent ag-company ag-chip-card" |
| 92 | style={place} |
| 93 | onMouseEnter={onShow} |
| 94 | onMouseLeave={onHide} |
| 95 | onFocus={onShow} |
| 96 | onBlur={onHide} |
| 97 | > |
| 98 | <CompanyDetails c={c} run={run} /> |
| 99 | </div> |
| 100 | )} |
| 101 | </span> |
| 102 | ) |
| 103 | } |
| 104 | |
| 105 | export function CompanyDetails({ |
| 106 | c, |
| 107 | run, |
| 108 | }: { |
| 109 | c: CompanyStatus |
| 110 | run: (source: string, args: unknown) => Promise<void> |
| 111 | }): ReactElement { |
| 112 | return ( |
| 113 | <> |
| 114 | <span className="ag-doc ag-line" title={`${c.chunks.toLocaleString()} chunks`}> |
| 115 | <span className="ag-name">{c.ticker}</span> <span className="ag-muted">{c.name}</span> |
| 116 | </span> |
| 117 | {!c.progress?.running && ( |
| 118 | <RefreshButton |
| 119 | label={`Update ${c.ticker}: read what it has filed since, retry what failed`} |
| 120 | onClick={() => void run('refresh', { ticker: c.ticker }).catch(() => {})} |
| 121 | /> |
| 122 | )} |
| 123 | <span className="ag-muted ag-line ag-whole">{line(c)}</span> |
| 124 | {c.progress?.running ? ( |
| 125 | <Meter part={c.progress.done} whole={c.progress.total} /> |
| 126 | ) : c.unembedded > 0 ? ( |
| 127 | <Meter part={c.chunks - c.unembedded} whole={c.chunks} /> |
| 128 | ) : null} |
| 129 | {c.progress?.failed ? ( |
| 130 | <span className="ag-error ag-line ag-whole">{c.progress.failed} failed: refresh retries them</span> |
| 131 | ) : null} |
| 132 | {c.progress?.error && <span className="ag-error ag-line ag-whole">{c.progress.error}</span>} |
| 133 | </> |
| 134 | ) |
| 135 | } |
| 136 | |
| 137 | /** How far a company is: reading its filings, then embedding them, then done; after what it reads. */ |
| 138 | function line(c: CompanyStatus): string { |
| 139 | const reads = readsText(c.reads) |
| 140 | const years = reads ? `${reads} · ` : '' |
| 141 | const p = c.progress |
| 142 | if (p?.running) |
| 143 | return `${years}${p.total ? `reading ${p.done.toLocaleString()} of ${p.total.toLocaleString()} filings…` : 'listing filings…'}` |
| 144 | const filings = `${years}${c.filings.toLocaleString()} filings` |
| 145 | return c.unembedded > 0 ? `${filings} · embedding, ${c.unembedded.toLocaleString()} chunks to go` : filings |
| 146 | } |