Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,12 @@
"@chenglou/pretext": "^0.0.6",
"@echohello/client": "workspace:*",
"@echohello/protocol": "workspace:*",
"@pierre/diffs": "^1.2.12",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@types/markdown-it": "^14.1.2",
"diff": "^9.0.0",
"markdown-it": "^14.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^3.23.8",
Expand Down
92 changes: 87 additions & 5 deletions packages/web/src/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
import { useEffect, useState } from "react";
import { SupaplaneClient, type SupaplaneClient as SupaplaneClientType } from "@echohello/client";

import { ConnectionBanner } from "./components/ConnectionBanner.js";
import { WorkspaceSidebar } from "./components/WorkspaceSidebar.js";
import { AgentTranscript } from "./components/AgentTranscript.js";
import { Composer } from "./components/Composer.js";
import { ConnectionBanner } from "./components/ConnectionBanner.js";
import { DiffView } from "./components/DiffView.js";
import { WorkspaceSidebar } from "./components/WorkspaceSidebar.js";

const DAEMON_WS = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ws`;
const CLIENT_ID = `web-${Math.random().toString(36).slice(2, 10)}`;

interface OpenDiff {
name: string;
before: string;
after: string;
prevName?: string;
source: "command" | "demo";
}

export function App() {
const [client, setClient] = useState<SupaplaneClientType | null>(null);
const [helloAck, setHelloAck] = useState<Awaited<
ReturnType<SupaplaneClientType["connect"]>
> | null>(null);
const [error, setError] = useState<string | null>(null);
const [openDiff, setOpenDiff] = useState<OpenDiff | null>(null);

useEffect(() => {
const c = new SupaplaneClient({
Expand All@@ -41,12 +51,84 @@ export function App() {
error={error}
/>
<div className="flex flex-1 overflow-hidden">
<WorkspaceSidebar client={client} />
<WorkspaceSidebar
client={client}
onDemoDiff={(wsId) =>
setOpenDiff({
name: `${wsId}/README.md`,
before: SAMPLE_BEFORE,
after: SAMPLE_AFTER,
source: "demo",
})
}
/>
<main className="flex flex-1 flex-col">
<AgentTranscript client={client} />
<Composer client={client} />
{openDiff ? (
<DiffPanel diff={openDiff} onClose={() => setOpenDiff(null)} />
) : (
<AgentTranscript client={client} />
)}
<Composer
client={client}
diff={
openDiff && openDiff.source === "command"
? {
name: openDiff.name,
before: openDiff.before,
after: openDiff.after,
...(openDiff.prevName ? { prevName: openDiff.prevName } : {}),
}
: null
}
/>
</main>
</div>
</div>
);
}

function DiffPanel({ diff, onClose }: { diff: OpenDiff; onClose: () => void }) {
return (
<section className="flex min-h-0 flex-1 flex-col bg-neutral-950">
<header className="flex items-center justify-between border-b border-neutral-800 bg-neutral-900/60 px-4 py-2">
<div className="flex items-center gap-3">
<h2 className="font-mono text-sm text-neutral-200">{diff.name}</h2>
{diff.prevName ? (
<span className="text-xs text-neutral-500">from {diff.prevName}</span>
) : null}
<span className="rounded bg-neutral-800 px-2 py-0.5 text-[10px] uppercase text-neutral-400">
{diff.source}
</span>
</div>
<button
onClick={onClose}
className="rounded border border-neutral-700 bg-neutral-950 px-3 py-1 text-xs text-neutral-200 hover:bg-neutral-900"
>
Close
</button>
</header>
<div className="min-h-0 flex-1">
<DiffView
name={diff.name}
before={diff.before}
after={diff.after}
{...(diff.prevName ? { prevName: diff.prevName } : {})}
/>
</div>
</section>
);
}

const SAMPLE_BEFORE = `export function greet(name: string) {
return "Hello, " + name;
}
`;

const SAMPLE_AFTER = `export function greet(name: string, title = "Hello"): string {
return \`\${title}, \${name}\`;
}

export function shout(greeting: string): string {
return greeting.toUpperCase();
}
`;
85 changes: 64 additions & 21 deletions packages/web/src/components/AgentTranscript.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,41 +2,71 @@ import { useEffect, useRef, useState } from "react";
import type { SupaplaneClient } from "@echohello/client";
import type { AgentEvent } from "@echohello/protocol";

import { MarkdownView } from "./MarkdownView.js";

interface Props {
client: SupaplaneClient | null;
}

/**
* The agent transcript renderer.
*
* TODO: integrate Pretext (`@chenglou/pretext`) for DOM-free text measurement
* so long-running transcripts stay performant — the prepare → layout API is
* expected to be wired in once the streaming path is finalised (see
* `docs/architecture.md` and the long-session perf notes).
* TODO: replace the simple list with a virtualised list (`@tanstack/react-virtual`)
* once transcript sizes start exceeding a few hundred events.
* Long message events stream through markdown-it (token-stream parser with
* safe defaults) for agent text, and small line entries cover the meta
* events (tool.start, status, error, permission_request).
*
* TODO: integrate Pretext for DOM-free text measurement so long-running
* transcripts stay performant — see docs/architecture.md.
* TODO: replace the simple list with a virtualised list
* (`@tanstack/react-virtual`) once transcript sizes start exceeding a few
* hundred events.
*/
export function AgentTranscript({ client }: Props) {
const [lines, setLines] = useState<Array<{ id: string; text: string; kind: AgentEvent["type"] }>>(
[],
);
const [lines, setLines] = useState<
Array<{
id: string;
text: string;
kind: AgentEvent["type"];
markdown?: boolean;
}>
>([]);
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!client) return;
const off = client.onAgentEvent((event) => {
setLines((prev) => [
...prev,
{
id: `${event.type}-${event.ts}-${Math.random().toString(36).slice(2, 6)}`,
text: describeEvent(event),
kind: event.type,
},
]);
setLines((prev) => {
// Coalesce streaming `message.delta` into the latest open message
// line so the transcript doesn't fragment on every token push.
const last = prev[prev.length - 1];
if (event.type === "message.delta" && last && last.kind === "message.delta") {
const next = prev.slice();
next[next.length - 1] = {
...last,
text: last.text + event.text,
};
return next;
}
return [
...prev,
{
id: `${event.type}-${event.ts}-${Math.random().toString(36).slice(2, 6)}`,
text: describeEvent(event),
kind: event.type,
markdown: isMarkdown(event),
},
];
});
});
return off;
}, [client]);

useEffect(() => {
if (lines.length === 0) return;
const id = setTimeout(() => containerRef.current?.scrollTo({ top: 9e9 }), 50);
return () => clearTimeout(id);
}, [lines.length]);

return (
<section
ref={containerRef}
Expand All@@ -48,15 +78,28 @@ export function AgentTranscript({ client }: Props) {
</p>
)}
{lines.map((line) => (
<div key={line.id} className="whitespace-pre-wrap text-neutral-200">
<span className="mr-3 text-xs text-neutral-600">{line.kind}</span>
{line.text}
</div>
<article key={line.id} className="border-b border-neutral-900/60 pb-3">
<header className="mb-1 flex items-center gap-2 text-xs text-neutral-600">
<span>{line.kind}</span>
{!line.markdown && line.kind !== "message.delta" ? (
<span className="text-neutral-300">{line.text}</span>
) : null}
</header>
{line.markdown ? (
<MarkdownView source={line.text} />
) : line.kind === "message.delta" ? null : (
<pre className="whitespace-pre-wrap text-neutral-200">{line.text}</pre>
)}
</article>
))}
</section>
);
}

function isMarkdown(event: AgentEvent): boolean {
return event.type === "message.delta" || event.type === "message.final";
}

function describeEvent(event: AgentEvent): string {
switch (event.type) {
case "message.delta":
Expand Down
39 changes: 38 additions & 1 deletion packages/web/src/components/Composer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,9 +3,16 @@ import type { SupaplaneClient } from "@echohello/client";

interface Props {
client: SupaplaneClient | null;
/** Optional diff request opened from the workspace sidebar (or composer). */
diff?: {
name: string;
before: string;
after: string;
prevName?: string;
} | null;
}

export function Composer({ client }: Props) {
export function Composer({ client, diff }: Props) {
const [prompt, setPrompt] = useState("");
const [sessionId, setSessionId] = useState("");

Expand All@@ -20,6 +27,17 @@ export function Composer({ client }: Props) {
setPrompt("");
};

const openDiff = (): void => {
if (!client) return;
const session = sessionId.trim();
if (!session) return;
client.sendCommand({
type: "diff.open",
sessionId: session,
path: "(composer demo)",
});
};

return (
<footer className="border-t border-neutral-800 bg-neutral-900/60 px-6 py-3">
<div className="flex gap-3">
Expand All@@ -41,6 +59,14 @@ export function Composer({ client }: Props) {
}
}}
/>
<button
className="rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-200 transition hover:bg-neutral-900 disabled:opacity-40"
disabled={!client || !sessionId.trim()}
onClick={openDiff}
title="Send a diff.open command for this session"
>
diff.open
</button>
<button
className="rounded bg-supaplane-accent px-4 py-2 text-sm font-medium text-white transition hover:bg-supaplane-accent-soft disabled:opacity-40"
disabled={!client || !prompt.trim() || !sessionId.trim()}
Expand All@@ -49,6 +75,17 @@ export function Composer({ client }: Props) {
Send
</button>
</div>
{diff ? (
<p className="mt-2 truncate text-xs text-neutral-500">
Opened diff for <span className="font-mono text-neutral-300">{diff.name}</span>
{diff.prevName ? (
<>
{" "}
(renamed from <span className="font-mono">{diff.prevName}</span>)
</>
) : null}
</p>
) : null}
</footer>
);
}
Loading