Jaspers Hub

jaspers / agents

pluginOfficialReviewed★ 0↓ 1

Research Agents: Agents you define that answer research chats from SEC filings, your documents, and the web, citing every passage.

5.2 KB
1import { useCallback, useRef, useState, type ReactElement } from 'react'
2import { RefreshButton } from './buttons'
3import { Meter } from './Meter'
4import { 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. */
13const CLOSE_MS = 150
14const CARD_WIDTH = 300
15
16/** Which company's card is open, for the whole list: showing one closes any other at once. */
17export 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
35export 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
105export 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. */
138function 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}