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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user