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
@@ -112,7 +112,11 @@ import {
Search,
Globe,
Wrench,
Image as ImageIcon,
Code2,
Table2,
} from "lucide-react";
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
import { api } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
@@ -148,6 +152,8 @@ interface MessageListProps {
loading: boolean;
/** Opens a file named in the "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 session is running in a pane: show the step in progress. */
live?: boolean;
/** The CLI is asking the user something (a permission prompt): the live
@@ -604,30 +610,91 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
const ARTIFACT_ICON: Partial<Record<ArtifactKind, typeof FileText>> = {
html: Globe,
svg: ImageIcon,
markdown: FileText,
mermaid: Workflow,
react: Code2,
csv: Table2,
};
/** Claude Desktop's artifact card: what the model made, opened beside the chat. */
function ArtifactCard({ path, onOpen }: { path: string; onOpen?: (path: string) => void }) {
const { t } = useTranslation("sessions");
const kind = artifactKind(path);
const Icon = ARTIFACT_ICON[kind] ?? FileText;
const name = path.split(/[\\/]/).pop() || path;
return (
<button
type="button"
data-testid="artifact-card"
title={path}
disabled={!onOpen}
onClick={() => onOpen?.(path)}
draggable
onDragStart={(e) => dragAttachments(e, [projectAttachment(path)])}
className="flex w-full max-w-md items-center gap-3 rounded-xl border border-border bg-surface-1 px-3 py-2.5 text-left transition-colors enabled:hover:border-border-light enabled:hover:bg-surface-2"
>
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2">
<Icon className="h-4 w-4 text-fg-secondary" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg-primary">
{name.replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="block truncate text-xs text-fg-muted">
{t(`conversation.artifact.${kind}`, { defaultValue: kind })}
{onOpen ? ` · ${t("conversation.artifact.open")}` : ""}
</span>
</span>
</button>
);
}
function EditedFilesCard({
blocks,
toolResultMap,
onOpenFile,
onOpenArtifact,
pending = false,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
/** Opens an artifact (a page, document, diagram…) in the chat's own panel. */
onOpenArtifact?: (path: string) => void;
/** The turn is still working: only its artifact cards so far. */
pending?: boolean;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
// Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>();
for (const b of blocks) {
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
if (b.name === "Write") created.add(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
if (files.size === 0) return null;
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => (
<ArtifactCard key={p} path={p} onOpen={onOpenArtifact ?? onOpenFile} />
))}
</div>
);
if (pending) return cards || null;
for (const p of artifacts) files.delete(p);
if (files.size === 0) return cards || null;
let added = 0;
let removed = 0;
let counted = false;
@@ -643,7 +710,7 @@ function EditedFilesCard({
<span className="text-status-danger">−{r}</span>
</span>
);
return (
const list = (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div
draggable
@@ -673,11 +740,21 @@ function EditedFilesCard({
))}
</div>
);
return cards ? (
<div className="flex flex-col gap-2">
{cards}
{list}
</div>
) : (
list
);
}
const EditedFiles = memo(
EditedFilesCard,
(p, n) =>
p.onOpenFile === n.onOpenFile &&
p.onOpenArtifact === n.onOpenArtifact &&
p.pending === n.pending &&
sameBlocks(p.blocks, n.blocks) &&
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
@@ -688,6 +765,7 @@ function MessageListView({
messages,
loading,
onOpenFile,
onOpenArtifact,
live = false,
waiting = false,
onOpenTerminal,
@@ -780,14 +858,16 @@ function MessageListView({
/>
);
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
// The card closes a finished turn; one still working shows
// only the artifacts it made so far, as Claude Desktop does.
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
pending={!!pending && item === items[items.length - 1]}
/>
);
case "done":