import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CloudRiskNotePayload, } from "../../../api-client"; import { EmptyState, PaneStatusBody, Prose, QueryBar, SectionHeading, Spinner, StatGrid, Tabs, usePaneFooter, usePaneHeaderTabs, type PaneFooterSegment, type PaneHint, type QueryBarFilter, } from "../../../components"; import { useShortcut } from "../../../react/input"; import { useAsyncResource } from "../../../react/async-resource"; import { colors } from "../../../theme/colors"; import { Box, ScrollBox, Text, useRendererHost, useUiCapabilities, type ScrollBoxRenderable, } from "../../../ui"; import { isPlainKey } from "../../../utils/keyboard"; import { usePluginPaneState } from "../../runtime"; import { useBoundTicker } from "../shared/ticker-request"; import { discardRiskData, loadRiskReport, loadRiskReports } from "./data"; export const RISK_FACTORS_PANE_ID = "risk-factors"; const MAX_PROSE_WIDTH = 100; function noteFor( notes: CloudRiskNotePayload[], index: number, ): string | undefined { return notes.find((note) => note.index === index)?.text; } /** A risk heading, its group, and the model's line under it. */ function RiskLine({ tag, group, heading, note, width, }: { tag: string; group: string | null; heading: string; note?: string; width: number; }) { return ( {tag} {group ? {group} : null} {note ? ( ) : null} ); } export function RiskFactorsResearchTab(props: { focused: boolean; width: number; height: number }) { return ; } export function RiskFactorsPane({ focused, width, nested = false, }: { focused: boolean; width: number; height: number; /** Inside Ticker Research, whose own tab strip keeps h/l and the arrows. */ nested?: boolean; }) { const { symbol } = useBoundTicker(); const ticker = symbol ? symbol.toUpperCase() : null; const nativePaneChrome = useUiCapabilities().nativePaneChrome === true; const rendererHost = useRendererHost(); // The year is remembered with the pane; the ticker it was chosen on is the // one the pane opened with, so a restored year applies to it. const [selectedYear, setSelectedYear] = usePluginPaneState("filingYear", null); const [selectedTicker, setSelectedTicker] = useState(ticker); const listLoader = useCallback((force: boolean) => loadRiskReports(ticker!, { force }), [ticker]); const list = useAsyncResource(ticker ? listLoader : null, { clearOnError: discardRiskData }); const years = useMemo(() => [...(list.data?.reports ?? [])].sort((a, b) => b.reportYear - a.reportYear), [list.data]); // Null follows the newest discovered filing; an explicit choice stays on that year. const year = selectedTicker === ticker && selectedYear !== null ? selectedYear : years[0]?.reportYear ?? null; const reportLoader = useCallback((force: boolean) => loadRiskReport(ticker!, year!, { force }), [ticker, year]); const detail = useAsyncResource(ticker && year !== null ? reportLoader : null, { clearOnError: discardRiskData }); const report = detail.data; const listError = list.error ?? list.data?.refreshError ?? null; const reportError = detail.error ?? report?.refreshError ?? null; const refresh = useCallback(() => { void list.reload(); if (year !== null) void detail.reload(); }, [list.reload, detail.reload, year]); const scrollRef = useRef(null); useEffect(() => { const scrollBox = scrollRef.current; if (scrollBox) scrollBox.scrollTop = 0; }, [ticker, year]); const openFiling = useCallback(() => { if (report?.docUrl) void rendererHost.openExternal(report.docUrl); }, [rendererHost, report]); const nextYear = useCallback(() => { if (years.length < 2) return; const index = years.findIndex((entry) => entry.reportYear === year); setSelectedTicker(ticker); setSelectedYear(years[(index + 1) % years.length]!.reportYear); }, [setSelectedYear, ticker, year, years]); const scrollBy = useCallback((delta: number) => { const scrollBox = scrollRef.current; if (!scrollBox?.viewport) return; const max = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height); scrollBox.scrollTop = Math.max( 0, Math.min(max, scrollBox.scrollTop + delta), ); }, []); useShortcut( (event) => { if (isPlainKey(event, "r")) refresh(); else if (isPlainKey(event, "o")) { event.preventDefault(); openFiling(); } // One line per press; marked handled so the pane scroll keys, which // page this report, do not scroll it again. else if (isPlainKey(event, "j", "down")) { event.preventDefault(); scrollBy(1); } else if (isPlainKey(event, "k", "up")) { event.preventDefault(); scrollBy(-1); } }, { enabled: focused, scope: "risk-factors" }, ); usePaneFooter(RISK_FACTORS_PANE_ID, () => { const info: PaneFooterSegment[] = []; if (list.loading || detail.loading) info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] }); if (listError) info.push({ id: "list-error", parts: [{ text: `Report list: ${listError}`, tone: "warning" }] }); if (reportError) info.push({ id: "report-error", parts: [{ text: `${year} report: ${reportError}`, tone: "warning" }] }); if ((listError || list.data?.stale) && list.data) { info.push({ id: "list-stale", parts: [{ text: `List cached ${new Date(list.data.fetchedAt).toISOString()}`, tone: "muted" }] }); } if ((reportError || report?.stale) && report) { info.push({ id: "report-stale", parts: [{ text: `Report cached ${new Date(report.fetchedAt).toISOString()}`, tone: "muted" }] }); } const hints: PaneHint[] = report?.docUrl ? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }] : []; // The year strip answers h/l only where it is the pane's own strip; `y` // steps it everywhere, including under Ticker Research's strip. if (ticker && years.length > 1) hints.push({ id: "year", key: "y", label: "ear", onPress: nextYear }); return { info, hints }; }, [list.loading, detail.loading, listError, reportError, list.data, report, year, openFiling, ticker, years.length, nextYear]); const bodyWidth = Math.max(12, width - 2); const proseWidth = Math.min(bodyWidth, MAX_PROSE_WIDTH); const yearTabs = useMemo(() => years.map((entry) => ({ label: `${entry.reportYear} 10-K`, value: String(entry.reportYear), })), [years]); const selectYear = (value: string) => { setSelectedTicker(ticker); setSelectedYear(Number(value)); }; const tabsInHeader = usePaneHeaderTabs(years.length > 1 ? { tabs: yearTabs, activeValue: year === null ? "" : String(year), onSelect: selectYear, focused, } : null); // Nested in Ticker Research the years stay in the body: the terminal keeps // its tab row, the desktop picks the year from the query bar. const yearStrip = years.length > 1 && !tabsInHeader; const yearFilters: QueryBarFilter[] = yearStrip && nativePaneChrome ? [{ id: "year", label: "10-K", inline: years.length <= 4, value: year === null ? "" : String(year), options: years.map((entry) => ({ label: String(entry.reportYear), value: String(entry.reportYear) })), onChange: selectYear, }] : []; if (!ticker) return ; if (!list.data && list.loading) return ; if (!list.data && listError && year === null) return ; if (year === null) return ; const diff = report?.diff ?? null; // A choice of years names the year in the tabs or the filter; a single // report says which one it is here. const meta = report ? [ years.length > 1 ? null : `${report.reportYear} 10-K`, `Filed ${report.filedAt?.slice(0, 10) || "unavailable"}`, `updated ${report.updatedAt?.slice(0, 10) || "unavailable"}`, diff ? null : "no prior year on file", ].filter(Boolean).join(" · ") : ""; return ( {yearStrip && !nativePaneChrome && ( )} {(yearFilters.length > 0 || meta) && } {report && diff ? ( ) : null} {report ? ( {report.overview ? ( {report.overview.split("\n").map((point) => ( ))} ) : null} {diff ? ( {diff.added.length === 0 && diff.removed.length === 0 && diff.reworded.length === 0 ? ( ) : null} {diff.added.map((index) => ( ))} {diff.removed.map((risk, index) => ( ))} {diff.reworded.map((item) => ( ))} ) : report.notes.top.length > 0 ? ( {report.notes.top.map((note) => ( ))} ) : null} {report.risks.map((risk, index) => ( ))} ) : reportError ? ( ) : ( )} ); }