import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
import { useBridge, useData, usePanelState, usePublish, type PanelRef } from '@jaspers-ai/sdk'
import { agentIdFrom, emptySearch, ORIGINS, type Agent } from './agents'
import { companiesUsed, type AgentMessage, type Chat, type Message, type Passage, type Search } from './chat'
import type { CompanyStatus } from './sec'
import { CompanyChip, useCompanyCard } from './Company'
import { AddButton, CopyButton, EditButton, IconButton, StopButton } from './buttons'
import { withSources } from './cite'
import { Attach } from './Attach'
import { DocWindow } from './DocWindow'
import { isMarkup, isPdf, type Doc } from './docs'
import { Markdown } from './MarkdownView'
import { Mic } from './Mic'
import { readPdf } from './pdf'
import { Setup } from './Setup'
import type { Setup as SetupState } from './status'
import './styles.css'
// Each panel is one conversation: the agents on the left, and under them the companies the agents read
// in during this conversation; the conversation on the right, its documents behind the paperclip
// beside the message box. A panel placed anew starts a new conversation; its own conversation and
// whether its side panel is folded are the panel's state, so a view comes back as it was.
// A click on an agent tags it in the message being written; its pencil opens the form where the
// conversation was. Typing @ offers the agents whose ids start with what follows. Files dropped
// anywhere on the view, or picked with Upload, are read here as text and indexed by the plugin.
/** The panel's state arrives a round trip after the frame mounts; nothing is opened until it has. */
export function AgentsView({ panel }: { panel: PanelRef }): ReactElement {
const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as
{ conversation?: string; folded?: boolean; ask?: string } | undefined
if (state === undefined) return
Loading…
return (
)
}
function Agents({
panel,
initial,
initialFolded,
initialAsk,
}: {
panel: PanelRef
initial: string | undefined
initialFolded: boolean
initialAsk: string | undefined
}): ReactElement {
const bridge = useBridge()
const agents = (useData('live/agents') as Agent[] | undefined) ?? []
const [conversation, setConversation] = usePanelState(panel, 'conversation', initial)
const [folded, setFolded] = usePanelState(panel, 'folded', initialFolded)
// A question the panel was placed with: posted once its conversation is open, then cleared.
const [ask, setAsk] = usePanelState(panel, 'ask', initialAsk)
const asked = useRef(false)
const [open, setOpen] = useState(null)
const chat = useData(`live/chat/${open ?? 'none'}`) as Chat | undefined
const [editing, setEditing] = useState(null)
const [draft, setDraft] = useState('')
const docs = (useData('live/docs') as Doc[] | undefined) ?? []
const companies = (useData('live/sec') as CompanyStatus[] | undefined) ?? []
const setup = useData('live/setup') as SetupState | undefined
const [failure, setFailure] = useState(null)
const [note, setNote] = useState(null)
const messages = open ? (chat?.messages ?? []) : []
const card = useCompanyCard()
// The companies this conversation's agents touched, as the store has them now.
const added = companiesUsed(messages).map(
(ticker) => companies.find((c) => c.ticker === ticker) ?? { ticker, pending: true as const },
)
// The panel's conversation, or a new one for a panel placed anew: whichever the plugin opened is the state's.
useEffect(() => {
bridge
.runSource('agents/open-conversation', conversation ? { id: conversation } : {})
.then((result) => {
const id = result.kind === 'text' ? result.text : null
setOpen(id)
if (id && id !== conversation) setConversation(id)
})
.catch((err: unknown) => setFailure(messageOf(err)))
}, [bridge, conversation, setConversation])
usePublish(panel, { agents: agents.map((a) => a.id), messages: messages.length })
const run = (source: string, args: unknown): Promise =>
bridge.runSource(`agents/${source}`, args).then(
() => setFailure(null),
(err: unknown) => {
setFailure(messageOf(err))
throw err
},
)
useEffect(() => {
if (!open || !ask?.trim() || asked.current) return
asked.current = true
void run('post', { conversation: open, text: ask })
.catch(() => {})
.finally(() => setAsk(undefined))
}, [open, ask])
/** One file at a time, so each says how it went. */
const upload = async (files: File[]): Promise => {
for (const file of files) {
setNote(`Indexing ${file.name}…`)
try {
const result = await bridge.runSource('agents/upload', {
name: file.name,
...(await readDoc(file)),
...(open ? { conversation: open } : {}),
})
setNote(result.kind === 'text' ? result.text : null)
setFailure(null)
} catch (err) {
setNote(null)
setFailure(messageOf(err))
}
}
}
return (