797 lines
26 KiB
TypeScript
797 lines
26 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useServerFn } from "@tanstack/react-start";
|
|
import { ArrowUpIcon, HistoryIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
|
|
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
|
|
import { Bubble, BubbleContent } from "#/components/ui/bubble";
|
|
import { Button } from "#/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "#/components/ui/dropdown-menu";
|
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "#/components/ui/empty";
|
|
import { Checkbox } from "#/components/ui/checkbox";
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "#/components/ui/collapsible";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupTextarea,
|
|
} from "#/components/ui/input-group";
|
|
import { Marker, MarkerContent } from "#/components/ui/marker";
|
|
import { Message, MessageContent } from "#/components/ui/message";
|
|
import {
|
|
MessageScroller,
|
|
MessageScrollerButton,
|
|
MessageScrollerContent,
|
|
MessageScrollerItem,
|
|
MessageScrollerProvider,
|
|
MessageScrollerViewport,
|
|
} from "#/components/ui/message-scroller";
|
|
import type { Connection } from "../connections/model";
|
|
import type { Deck } from "../decks/model";
|
|
import { useUrlState } from "../navigation/use-url-state";
|
|
import {
|
|
isResearchActive,
|
|
type ResearchCitation,
|
|
type ResearchRun,
|
|
type ResearchSnapshot,
|
|
} from "./model";
|
|
import { ResearchMarkdown } from "./research-markdown";
|
|
import { cancelResearch, loadResearchStatus, startResearch } from "./server-functions";
|
|
|
|
const statusNames: Record<ResearchRun["status"], string> = {
|
|
starting: "Starting",
|
|
running: "Researching",
|
|
complete: "Complete",
|
|
failed: "Failed",
|
|
cancelled: "Cancelled",
|
|
interrupted: "Interrupted · Ready to resume",
|
|
};
|
|
type ResearchInput = { topic: string; connectionIds: string[] };
|
|
type ResearchDraft = {
|
|
runId: string | null;
|
|
topic: string;
|
|
selected: string[] | null;
|
|
};
|
|
type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode;
|
|
type RenderResearchHistory = (onSelect?: () => void) => ReactNode;
|
|
|
|
export function ResearchPanel({
|
|
ready,
|
|
contextDeck,
|
|
connections,
|
|
onOpenDeck,
|
|
onNavigateCitation,
|
|
children,
|
|
}: {
|
|
ready: boolean;
|
|
contextDeck: Deck;
|
|
connections: Connection[];
|
|
onOpenDeck: (deck: Deck, activate?: boolean) => unknown;
|
|
onNavigateCitation?: (url: string, citations: ResearchCitation[], restore?: boolean) => boolean;
|
|
children?: (renderPanel: RenderResearchPanel, renderHistory: RenderResearchHistory) => ReactNode;
|
|
}) {
|
|
const [search, setSearch] = useUrlState();
|
|
const fetchStatus = useServerFn(loadResearchStatus);
|
|
const start = useServerFn(startResearch);
|
|
const cancel = useServerFn(cancelResearch);
|
|
const client = useQueryClient();
|
|
const selectedId = search.run === "new" ? undefined : search.run;
|
|
const selection = useRef(selectedId);
|
|
useEffect(() => {
|
|
selection.current = selectedId;
|
|
}, [selectedId]);
|
|
const queryKey = ["research-status", selectedId ?? "new"] as const;
|
|
const status = useQuery({
|
|
queryKey,
|
|
queryFn: () => fetchStatus({ data: { id: selectedId } }),
|
|
retry: false,
|
|
});
|
|
const run = status.data?.run ?? null;
|
|
const runId = run?.id ?? null;
|
|
const closedRuns = search.closedRuns ?? [];
|
|
const history = status.data?.history ?? [];
|
|
const openChats = history.filter(
|
|
(entry) => entry.id === selectedId || !closedRuns.includes(entry.id),
|
|
);
|
|
const [draft, setDraft] = useState<ResearchDraft>({
|
|
runId,
|
|
topic: "",
|
|
selected: run?.connectionIds ?? null,
|
|
});
|
|
// Conversation changes reset the draft; responsive view remounts do not.
|
|
if (draft.runId !== runId) {
|
|
setDraft({ runId, topic: "", selected: run?.connectionIds ?? null });
|
|
}
|
|
function updateDraft(update: Partial<Pick<ResearchDraft, "topic" | "selected">>) {
|
|
setDraft((current) => (current.runId === runId ? { ...current, ...update } : current));
|
|
}
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string>();
|
|
const [reconnecting, setReconnecting] = useState(false);
|
|
const previousRunId = useRef<string | null>(null);
|
|
const syncedRunId = useRef<string | null>(null);
|
|
const attemptedVersion = useRef<string | null>(null);
|
|
const pendingTurn = useRef<{
|
|
runId?: string;
|
|
previousRunId?: string;
|
|
version: number;
|
|
} | null>(null);
|
|
const syncQueue = useRef<Promise<unknown>>(Promise.resolve());
|
|
const syncDeck = useCallback(
|
|
(deck: Deck, activate: boolean) => {
|
|
const pending = syncQueue.current.then(() => onOpenDeck(deck, activate));
|
|
syncQueue.current = pending.catch(() => undefined);
|
|
return pending;
|
|
},
|
|
[onOpenDeck],
|
|
);
|
|
const request = useRef<{ signature: string; id: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (previousRunId.current && previousRunId.current !== run?.id) {
|
|
request.current = null;
|
|
pendingTurn.current = null;
|
|
}
|
|
previousRunId.current = run?.id ?? null;
|
|
}, [run]);
|
|
|
|
useEffect(() => {
|
|
let subscribed = true;
|
|
const key = ["research-status", selectedId ?? "new"];
|
|
const events = new EventSource(
|
|
`/api/research/events${selectedId ? `?run=${encodeURIComponent(selectedId)}` : ""}`,
|
|
);
|
|
events.onopen = () => setReconnecting(false);
|
|
events.onerror = () => setReconnecting(true);
|
|
events.onmessage = (event) => {
|
|
try {
|
|
const next = JSON.parse(event.data) as ResearchSnapshot;
|
|
if ((next.run?.id ?? undefined) !== selectedId) return;
|
|
void client
|
|
.cancelQueries({ queryKey: key })
|
|
.then(() => {
|
|
if (subscribed) client.setQueryData(key, next);
|
|
})
|
|
.catch(() => setReconnecting(true));
|
|
} catch {
|
|
setReconnecting(true);
|
|
}
|
|
};
|
|
return () => {
|
|
subscribed = false;
|
|
events.close();
|
|
};
|
|
}, [client, selectedId]);
|
|
|
|
useEffect(() => {
|
|
if (!run) {
|
|
syncedRunId.current = null;
|
|
attemptedVersion.current = null;
|
|
return;
|
|
}
|
|
if (!ready) return;
|
|
const switched = syncedRunId.current !== run.id;
|
|
const deck = run.deck ?? (switched ? run.contextDeck : undefined);
|
|
if (!deck) return;
|
|
syncedRunId.current = run.id;
|
|
const version = `${run.id}:${run.deckVersion}`;
|
|
if (attemptedVersion.current === version) return;
|
|
attemptedVersion.current = version;
|
|
const turn = pendingTurn.current;
|
|
const activate =
|
|
(switched && !search.citation && (!search.deck || search.deck === deck.id)) ||
|
|
(!!turn &&
|
|
run.deckVersion > turn.version &&
|
|
(turn.runId ? run.id === turn.runId : run.id !== turn.previousRunId));
|
|
if (activate && run.deck && turn && run.deckVersion > turn.version) pendingTurn.current = null;
|
|
void syncDeck(deck, activate).catch(() => {
|
|
setError("Unable to sync the research deck. Use “Open temporary deck” to try again.");
|
|
});
|
|
}, [run, syncDeck, ready, search.deck, search.citation]);
|
|
|
|
const restoredCitation = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (!search.citation) {
|
|
restoredCitation.current = undefined;
|
|
return;
|
|
}
|
|
if (!ready || !run) return;
|
|
const key = `${run.id}:${search.citation}`;
|
|
if (restoredCitation.current === key) return;
|
|
const citation = run.citations.find((entry) => entry.post.key === search.citation);
|
|
if (!citation) return;
|
|
let cancelled = false;
|
|
void syncQueue.current.then(() => {
|
|
if (!cancelled && onNavigateCitation?.(citation.post.url, run.citations, true))
|
|
restoredCitation.current = key;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [ready, search.citation, run, onNavigateCitation]);
|
|
|
|
async function begin(input: ResearchInput) {
|
|
const startedFrom = selectedId;
|
|
const beforeStart = client.getQueryData(queryKey);
|
|
pendingTurn.current = {
|
|
runId: run?.threadId ? run.id : undefined,
|
|
previousRunId: run?.id,
|
|
version: run?.threadId ? run.deckVersion : 0,
|
|
};
|
|
setBusy(true);
|
|
setError(undefined);
|
|
const payload = {
|
|
...input,
|
|
contextDeck,
|
|
...(run ? { runId: run.id } : {}),
|
|
};
|
|
const signature = JSON.stringify(payload);
|
|
if (request.current?.signature !== signature)
|
|
request.current = { signature, id: crypto.randomUUID() };
|
|
try {
|
|
const next = await start({
|
|
data: { ...payload, requestId: request.current.id },
|
|
});
|
|
request.current = null;
|
|
client.setQueryData<ResearchSnapshot>(["research-status", next.id], (existing) =>
|
|
existing && existing !== beforeStart
|
|
? existing
|
|
: {
|
|
configured: true,
|
|
run: next,
|
|
history: status.data?.history ?? [],
|
|
},
|
|
);
|
|
if (selection.current === startedFrom) setSearch({ run: next.id, citation: undefined });
|
|
return true;
|
|
} catch {
|
|
pendingTurn.current = null;
|
|
setError("Unable to send. Check your connection and try again. Your draft has been kept.");
|
|
return false;
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function stop() {
|
|
if (!run) return;
|
|
const beforeCancel = client.getQueryData(queryKey);
|
|
setBusy(true);
|
|
setError(undefined);
|
|
try {
|
|
const next = await cancel({ data: { id: run.id } });
|
|
client.setQueryData(queryKey, (existing: unknown) =>
|
|
existing === beforeCancel
|
|
? {
|
|
configured: status.data?.configured ?? true,
|
|
run: next,
|
|
history: status.data?.history ?? [],
|
|
}
|
|
: existing,
|
|
);
|
|
} catch {
|
|
setError("Unable to stop research. Please try again.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function newChat() {
|
|
setSearch({ run: "new", citation: undefined });
|
|
request.current = null;
|
|
pendingTurn.current = null;
|
|
}
|
|
|
|
function selectChat(id: string) {
|
|
if (!id || id === run?.id) return;
|
|
const remaining = closedRuns.filter((closed) => closed !== id);
|
|
setSearch({
|
|
run: id,
|
|
citation: undefined,
|
|
closedRuns: remaining.length ? remaining : undefined,
|
|
});
|
|
}
|
|
|
|
function closeChat(id: string) {
|
|
const index = openChats.findIndex((entry) => entry.id === id);
|
|
const next = openChats[index + 1] ?? openChats[index - 1];
|
|
setSearch({
|
|
closedRuns: [...closedRuns.filter((closed) => closed !== id), id],
|
|
...(id === selectedId ? { run: next?.id ?? "new", citation: undefined } : {}),
|
|
});
|
|
}
|
|
|
|
async function openDeck(deck: Deck) {
|
|
setBusy(true);
|
|
setError(undefined);
|
|
try {
|
|
await syncDeck(deck, true);
|
|
} catch {
|
|
setError("Unable to open the research deck. Try again after saving finishes.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const renderHistory: RenderResearchHistory = (onSelect) => (
|
|
<nav
|
|
aria-label="Chat history"
|
|
data-testid="research-history"
|
|
className="flex h-12 min-w-0 shrink-0 items-stretch border-b bg-sidebar"
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-end gap-1 overflow-x-auto px-2 pt-1">
|
|
{openChats.map((entry) => (
|
|
<div key={entry.id} className="relative shrink-0">
|
|
<Button
|
|
type="button"
|
|
variant="tab"
|
|
motion="none"
|
|
size="tab"
|
|
aria-current={entry.id === selectedId ? "page" : undefined}
|
|
title={entry.title}
|
|
disabled={busy}
|
|
onClick={() => {
|
|
selectChat(entry.id);
|
|
onSelect?.();
|
|
}}
|
|
>
|
|
<span className="truncate">{entry.title}</span>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
motion="none"
|
|
size="icon-xs"
|
|
className="absolute top-2 right-1"
|
|
aria-label={`Close ${entry.title}`}
|
|
title="Close tab"
|
|
disabled={busy}
|
|
onClick={() => closeChat(entry.id)}
|
|
>
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
{!selectedId && (
|
|
<span
|
|
className="flex h-10 w-48 shrink-0 items-center rounded-t-lg border-b-2 border-primary bg-background px-3 text-sm font-medium"
|
|
aria-current="page"
|
|
>
|
|
New chat
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
type="button"
|
|
className="mx-2 self-center"
|
|
motion="none"
|
|
aria-label="New chat"
|
|
title="New chat"
|
|
disabled={busy || !run}
|
|
onClick={() => {
|
|
newChat();
|
|
onSelect?.();
|
|
}}
|
|
>
|
|
<PlusIcon />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
motion="none"
|
|
size="icon"
|
|
className="mr-2 self-center"
|
|
aria-label="Open chat history"
|
|
title="Chat history"
|
|
disabled={busy || history.length === 0}
|
|
/>
|
|
}
|
|
>
|
|
<HistoryIcon />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-64">
|
|
{history.map((entry) => (
|
|
<DropdownMenuItem
|
|
key={entry.id}
|
|
onClick={() => {
|
|
selectChat(entry.id);
|
|
onSelect?.();
|
|
}}
|
|
>
|
|
<span className="truncate">{entry.title}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</nav>
|
|
);
|
|
|
|
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
|
|
<aside
|
|
data-testid="research-panel"
|
|
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background"
|
|
aria-label="Research chat"
|
|
>
|
|
<header
|
|
data-testid="research-heading"
|
|
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b p-4"
|
|
>
|
|
<h2 className="m-0 text-base">Research chat</h2>
|
|
</header>
|
|
{reconnecting && (
|
|
<p
|
|
data-testid="deck-save-status"
|
|
className="m-0 px-4 py-2 text-research-field leading-normal text-muted-foreground"
|
|
role="status"
|
|
>
|
|
Reconnecting to research updates. Processing continues on your home server.
|
|
</p>
|
|
)}
|
|
{status.isError && (
|
|
<p
|
|
data-testid="deck-error"
|
|
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
|
|
role="alert"
|
|
>
|
|
Unable to load research status.
|
|
<Button type="button" onClick={() => void status.refetch()}>
|
|
Retry
|
|
</Button>
|
|
</p>
|
|
)}
|
|
{selectedId && status.isSuccess && !run && (
|
|
<p
|
|
data-testid="deck-error"
|
|
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
|
|
role="alert"
|
|
>
|
|
This conversation could not be found.
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p
|
|
data-testid="deck-error"
|
|
className="m-0 px-4 py-2 text-research-field leading-body text-destructive wrap-anywhere"
|
|
role="alert"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
{status.isPending ? (
|
|
<p className="m-0 px-4 py-2 text-research-field" role="status">
|
|
Checking the research connection…
|
|
</p>
|
|
) : !status.data?.configured ? (
|
|
<p className="m-0 px-4 py-2 text-research-field" role="status">
|
|
The connection to Codex on your home server is not configured.
|
|
</p>
|
|
) : null}
|
|
<ResearchConversation
|
|
key={`conversation:${run?.id ?? "new"}`}
|
|
messages={run?.messages ?? []}
|
|
citations={run?.citations ?? []}
|
|
streaming={isResearchActive(run)}
|
|
onNavigateCitation={(url, citations) => {
|
|
const navigated = onNavigateCitation?.(url, citations) ?? false;
|
|
if (navigated) onCitationNavigated?.();
|
|
return navigated;
|
|
}}
|
|
/>
|
|
{run && <ResearchRunStatus run={run} onOpenDeck={(deck) => void openDeck(deck)} />}
|
|
<ResearchComposer
|
|
key={run?.id ?? "new"}
|
|
topic={draft.topic}
|
|
selected={draft.selected}
|
|
onTopicChange={(topic) => updateDraft({ topic })}
|
|
onSelectionChange={(selected) => updateDraft({ selected })}
|
|
connections={connections}
|
|
contextTitle={contextDeck.title}
|
|
disabled={
|
|
!ready || busy || isResearchActive(status.data?.run ?? null) || !status.data?.configured
|
|
}
|
|
onSend={begin}
|
|
active={isResearchActive(run)}
|
|
busy={busy}
|
|
onStop={() => void stop()}
|
|
/>
|
|
</aside>
|
|
);
|
|
return children ? (
|
|
children(renderPanel, renderHistory)
|
|
) : (
|
|
<>
|
|
{renderHistory()}
|
|
{renderPanel()}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ResearchConversation({
|
|
messages,
|
|
citations,
|
|
streaming,
|
|
onNavigateCitation,
|
|
}: {
|
|
streaming: boolean;
|
|
messages: ResearchRun["messages"];
|
|
citations: ResearchCitation[];
|
|
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean;
|
|
}) {
|
|
const names = { user: "You", assistant: "Codex", tool: "Tool activity" };
|
|
return (
|
|
<MessageScrollerProvider autoScroll>
|
|
<MessageScroller data-testid="research-conversation" className="min-h-[80px] flex-1">
|
|
<MessageScrollerViewport aria-label="Research messages">
|
|
<MessageScrollerContent aria-label="Research conversation" className="gap-5 p-4">
|
|
{messages.length === 0 ? (
|
|
<MessageScrollerItem messageId="empty">
|
|
<Empty data-testid="research-empty">
|
|
<EmptyHeader>
|
|
<EmptyTitle>What would you like to explore?</EmptyTitle>
|
|
<EmptyDescription>
|
|
Send a topic or question. Codex adds columns as it searches and saves a report
|
|
to your home server.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
</MessageScrollerItem>
|
|
) : (
|
|
messages.map((message, index) => (
|
|
<MessageScrollerItem
|
|
key={message.id}
|
|
messageId={message.id}
|
|
scrollAnchor={message.role === "user"}
|
|
>
|
|
{message.role === "tool" ? (
|
|
<Marker
|
|
data-testid="research-tool-message"
|
|
className="text-research-label whitespace-pre-wrap text-muted-foreground wrap-anywhere"
|
|
>
|
|
<MarkerContent>
|
|
<span className="sr-only">{names.tool}: </span>
|
|
{message.text}
|
|
</MarkerContent>
|
|
</Marker>
|
|
) : (
|
|
<Message
|
|
align={message.role === "user" ? "end" : "start"}
|
|
data-testid="research-message"
|
|
className="wrap-anywhere"
|
|
>
|
|
<MessageContent>
|
|
<span className="sr-only">{names[message.role]}</span>
|
|
{message.role === "assistant" ? (
|
|
<ResearchMarkdown
|
|
text={message.text}
|
|
isAnimating={streaming && index === messages.length - 1}
|
|
onNavigateCitation={(url) =>
|
|
onNavigateCitation?.(url, citations) ?? false
|
|
}
|
|
/>
|
|
) : (
|
|
<Bubble align="end" variant="muted">
|
|
<BubbleContent>
|
|
<p className="m-0 whitespace-pre-wrap">{message.text}</p>
|
|
</BubbleContent>
|
|
</Bubble>
|
|
)}
|
|
</MessageContent>
|
|
</Message>
|
|
)}
|
|
</MessageScrollerItem>
|
|
))
|
|
)}
|
|
</MessageScrollerContent>
|
|
</MessageScrollerViewport>
|
|
<MessageScrollerButton aria-label="Jump to latest message" />
|
|
</MessageScroller>
|
|
</MessageScrollerProvider>
|
|
);
|
|
}
|
|
|
|
function ResearchComposer({
|
|
topic,
|
|
selected,
|
|
onTopicChange,
|
|
onSelectionChange,
|
|
connections,
|
|
contextTitle,
|
|
disabled,
|
|
onSend,
|
|
active,
|
|
busy,
|
|
onStop,
|
|
}: {
|
|
topic: string;
|
|
selected: string[] | null;
|
|
onTopicChange: (topic: string) => void;
|
|
onSelectionChange: (selected: string[]) => void;
|
|
connections: Connection[];
|
|
contextTitle: string;
|
|
disabled: boolean;
|
|
onSend: (input: ResearchInput) => Promise<boolean>;
|
|
active: boolean;
|
|
busy: boolean;
|
|
onStop: () => void;
|
|
}) {
|
|
const available = connections.filter((connection) => connection.status === "connected");
|
|
const selectedIds = selected ?? available.slice(0, 6).map((connection) => connection.id);
|
|
const id = useId();
|
|
const validSelection = selectedIds.filter((value) =>
|
|
available.some((connection) => connection.id === value),
|
|
);
|
|
return (
|
|
<form
|
|
data-testid="research-start-form"
|
|
className="shrink-0 p-3"
|
|
aria-label="Research messages"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (!topic.trim() || validSelection.length === 0 || disabled) return;
|
|
void onSend({
|
|
topic: topic.trim(),
|
|
connectionIds: validSelection,
|
|
}).then((sent) => {
|
|
if (sent) onTopicChange("");
|
|
});
|
|
}}
|
|
>
|
|
<Collapsible data-testid="research-account-picker" disabled={disabled}>
|
|
<label className="sr-only" htmlFor={id}>
|
|
Message
|
|
</label>
|
|
<InputGroup>
|
|
<InputGroupTextarea
|
|
id={id}
|
|
value={topic}
|
|
onChange={(event) => onTopicChange(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
event.preventDefault();
|
|
event.currentTarget.form?.requestSubmit();
|
|
}
|
|
}}
|
|
maxLength={1000}
|
|
rows={3}
|
|
placeholder="Ask about this deck…"
|
|
required
|
|
disabled={disabled}
|
|
/>
|
|
<InputGroupAddon align="block-end">
|
|
<CollapsibleTrigger render={<InputGroupButton size="sm" />}>
|
|
Connected accounts · {validSelection.length}
|
|
</CollapsibleTrigger>
|
|
{active ? (
|
|
<InputGroupButton
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="outline"
|
|
className="ml-auto"
|
|
aria-label="Stop research"
|
|
disabled={busy}
|
|
onClick={onStop}
|
|
>
|
|
<SquareIcon />
|
|
</InputGroupButton>
|
|
) : (
|
|
<InputGroupButton
|
|
type="submit"
|
|
variant="default"
|
|
size="icon-sm"
|
|
className="ml-auto"
|
|
aria-label="Send"
|
|
disabled={disabled || !topic.trim() || validSelection.length === 0}
|
|
>
|
|
<ArrowUpIcon />
|
|
</InputGroupButton>
|
|
)}
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
<CollapsibleContent>
|
|
<fieldset
|
|
data-testid="research-accounts"
|
|
className="grid max-h-[160px] min-w-0 gap-2 overflow-auto border p-3"
|
|
>
|
|
<legend>Accounts for research (up to 6)</legend>
|
|
{available.length === 0 ? (
|
|
<p>Add a connected account first.</p>
|
|
) : (
|
|
available.map((connection) => (
|
|
<label
|
|
className="flex items-center gap-2 text-research-field wrap-anywhere"
|
|
key={connection.id}
|
|
htmlFor={`${id}-${connection.id}`}
|
|
>
|
|
<Checkbox
|
|
id={`${id}-${connection.id}`}
|
|
checked={selectedIds.includes(connection.id)}
|
|
disabled={
|
|
disabled ||
|
|
(!selectedIds.includes(connection.id) && validSelection.length >= 6)
|
|
}
|
|
onCheckedChange={(checked) =>
|
|
onSelectionChange(
|
|
checked
|
|
? [...selectedIds, connection.id]
|
|
: selectedIds.filter((value) => value !== connection.id),
|
|
)
|
|
}
|
|
/>
|
|
{connection.displayName} ·{" "}
|
|
{connection.platform === "twitter" ? "Twitter" : "Mastodon"}
|
|
</label>
|
|
))
|
|
)}
|
|
</fieldset>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
<p
|
|
data-testid="research-context"
|
|
className="mt-2 mb-0 text-research-label text-muted-foreground wrap-anywhere"
|
|
>
|
|
Deck to send: <strong>{contextTitle}</strong>
|
|
</p>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function ResearchRunStatus({
|
|
run,
|
|
onOpenDeck,
|
|
}: {
|
|
run: ResearchRun;
|
|
onOpenDeck: (deck: Deck) => void;
|
|
}) {
|
|
const deck = run.deck ?? run.contextDeck;
|
|
return (
|
|
<section
|
|
data-testid="research-run"
|
|
className="max-h-[180px] shrink-0 overflow-y-auto border-t px-3 py-2 text-research-label"
|
|
aria-label="Research status"
|
|
>
|
|
<Marker role="status">
|
|
<MarkerContent>{statusNames[run.status]}</MarkerContent>
|
|
</Marker>
|
|
{run.error && (
|
|
<p
|
|
data-testid="deck-error"
|
|
className="mt-0 mb-2 text-sm leading-body text-destructive wrap-anywhere"
|
|
role="alert"
|
|
>
|
|
{run.error}
|
|
</p>
|
|
)}
|
|
{run.reportPath && (
|
|
<p className="mt-0 mb-2 wrap-anywhere">
|
|
Markdown location
|
|
<br />
|
|
<code data-testid="research-report-path" className="text-research-label wrap-anywhere">
|
|
{run.reportPath}
|
|
</code>
|
|
</p>
|
|
)}
|
|
<div data-testid="deck-actions" className="flex flex-wrap items-center gap-2">
|
|
{deck && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
type="button"
|
|
onClick={() => {
|
|
onOpenDeck(deck);
|
|
}}
|
|
>
|
|
Open temporary deck
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|