feat(chat): terminal chat parity for every tool; Desktop-like artifacts

- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
This commit is contained in:
2026-10-21 15:44:17 +07:00
parent aee4fc190e
commit 383b9ae231
41 changed files with 2582 additions and 426 deletions
@@ -96,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
// at most this often: the first event still fetches at once, the last is never
// lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500;
// The server saw this session's transcript grow (`transcript_changed`, itself
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
const PUSH_FETCH_MS = 300;
interface ConversationViewProps {
sessionId: string;
@@ -108,6 +111,10 @@ interface ConversationViewProps {
compact?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** Opens an artifact card's file in the chat's artifact panel. */
onOpenArtifact?: (path: string) => void;
/** The messages on screen (for an artifact's versions), as they change. */
onMessages?: (messages: TranscriptMessage[]) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
@@ -133,6 +140,8 @@ function ConversationViewPane({
fill = false,
compact = false,
onOpenFile,
onOpenArtifact,
onMessages,
live = false,
follow = 0,
waiting = false,
@@ -144,19 +153,21 @@ function ConversationViewPane({
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const shown = messages;
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => onMessages?.(shown), [shown, onMessages]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
for (let i = shown.length - 1; i >= 0; i--) {
const m = shown[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
}, [shown, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -209,12 +220,13 @@ function ConversationViewPane({
const agentLinks = useMemo<AgentLinks>(() => {
const ids = new Map<string, string>();
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
return { ids, notices: agentNotices(messages), live, render: renderAgent };
}, [transcripts, messages, live, renderAgent]);
return { ids, notices: agentNotices(shown), live, render: renderAgent };
}, [transcripts, shown, live, renderAgent]);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
async function loadTranscripts() {
try {
@@ -254,6 +266,12 @@ function ConversationViewPane({
firstLineRef.current = 0;
async function load() {
// No session yet: no history.
if (!sessionId) {
setMessages([]);
setLoading(false);
return;
}
try {
setError(null);
setLoading(true);
@@ -297,6 +315,7 @@ function ConversationViewPane({
// parsed message we've seen. The server already de-overlaps via
// afterLine, so we can safely append.
const fetchNewMessages = useCallback(async () => {
if (!sessionId) return;
if (fetchingRef.current) {
// Coalesce: remember a trigger arrived during this fetch and re-run
// exactly once when the in-flight request settles.
@@ -358,7 +377,12 @@ function ConversationViewPane({
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
[fetchNewMessages]
);
const fetchPushed = useMemo(
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab or hidden pane: catching up once on show is enough.
@@ -367,13 +391,14 @@ function ConversationViewPane({
fetchSoon();
return;
}
if (msg.type !== "new_event") return;
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchSoon();
if (msg.type === "transcript_changed") fetchPushed();
else fetchSoon();
});
return unsubscribe;
}, [sessionId, fetchSoon, onScreen]);
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages();
@@ -535,7 +560,7 @@ function ConversationViewPane({
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
if (!loading && shown.length > 0) {
scrollToBottom();
}
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -626,7 +651,7 @@ function ConversationViewPane({
>
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
) : shown.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -642,7 +667,7 @@ function ConversationViewPane({
</button>
)}
</div>
) : messages.length === 0 ? (
) : shown.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -651,9 +676,10 @@ function ConversationViewPane({
</div>
) : (
<MessageList
messages={messages}
messages={shown}
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}