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
14 changes: 14 additions & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@
</section>
<section id="identity" class="identity-wrap"></section>
<section id="remote" class="remote-wrap"></section>
<section id="chat" class="chat-wrap">
<div class="chat-head">
<span class="chat-title">Agent chat</span>
<span class="chat-conn" id="chat-conn">disconnected</span>
</div>
<div id="chat-log" class="chat-log"></div>
<form id="chat-form" class="chat-input">
<textarea id="chat-text" class="chat-text" rows="2" placeholder="Message the agent… (Enter to send · Shift+Enter for a newline)"></textarea>
<div class="chat-actions">
<button type="button" id="chat-stop" class="chat-btn chat-stop" hidden>Stop</button>
<button type="submit" id="chat-send" class="chat-btn chat-send">Send</button>
</div>
</form>
</section>
<section class="logs">
<nav class="tabs" id="tabs">
<button class="tab is-active" data-target="log">Activity</button>
Expand Down
141 changes: 140 additions & 1 deletion console/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"preview": "vite preview"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.10.0",
"typescript": "^5.6.3",
"vite": "^6.0.7",
Expand All @@ -22,6 +23,8 @@
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"codemirror": "^6.0.2"
"codemirror": "^6.0.2",
"dompurify": "^3.4.13",
"markdown-it": "^15.0.0"
}
}
109 changes: 109 additions & 0 deletions console/src/chat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import {
mdToHtml,
transcriptHtml,
turnHtml,
appendUser,
appendChunk,
endTurn,
type ChatTurn,
} from "./chat";

function agent(partial: Partial<ChatTurn>): ChatTurn {
return { id: 1, role: "agent", text: "", streaming: false, ...partial };
}

describe("mdToHtml", () => {
it("renders basic markdown", () => {
const html = mdToHtml("**bold** and `code`");
expect(html).toContain("<strong>bold</strong>");
expect(html).toContain("<code>code</code>");
});

it("escapes raw HTML in agent output (html: false)", () => {
const html = mdToHtml("<img src=x onerror=alert(1)>");
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});

it("does not emit a javascript: anchor (markdown-it validateLink blocks it)", () => {
const html = mdToHtml("[click](javascript:alert(1))");
expect(html).not.toContain("<a ");
expect(html).not.toContain('href="javascript:');
});
});

describe("transcript reducers", () => {
it("appendUser adds a non-streaming user turn", () => {
const turns = appendUser([], 1, "hello");
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({ role: "user", text: "hello", streaming: false });
});

it("appendChunk opens an agent turn on the first chunk, appends after", () => {
let turns = appendUser([], 1, "hi");
turns = appendChunk(turns, 2, "he");
expect(turns).toHaveLength(2);
expect(turns[1]).toMatchObject({ role: "agent", text: "he", streaming: true, id: 2 });
turns = appendChunk(turns, 99, "llo");
// still one agent turn, id preserved from the opening chunk
expect(turns).toHaveLength(2);
expect(turns[1]).toMatchObject({ text: "hello", id: 2 });
});

it("endTurn finalizes the open agent turn with its stop reason", () => {
let turns = appendChunk([], 1, "done");
turns = endTurn(turns, 2, "end_turn");
expect(turns[0]).toMatchObject({ streaming: false, stopReason: "end_turn", text: "done" });
});

it("endTurn with no open turn synthesizes an empty finalized turn", () => {
const turns = endTurn([], 1, "cancelled");
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({ role: "agent", text: "", streaming: false, stopReason: "cancelled" });
});

it("reducers do not mutate the input array", () => {
const start: ChatTurn[] = [];
appendUser(start, 1, "x");
expect(start).toHaveLength(0);
});
});

describe("turnHtml / transcriptHtml", () => {
it("escapes user text (never markdown, never raw HTML)", () => {
const html = turnHtml(
{ id: 1, role: "user", text: "<b>hi</b> & bye", streaming: false },
mdToHtml,
);
expect(html).toContain("&lt;b&gt;hi&lt;/b&gt; &amp; bye");
expect(html).not.toContain("<b>hi</b>");
});

it("shows a spinner while streaming and no copy button", () => {
const html = turnHtml(agent({ text: "partial", streaming: true }), mdToHtml);
expect(html).toContain("chat-spinner");
expect(html).not.toContain("chat-copy");
});

it("renders markdown and a copy button once final", () => {
const html = turnHtml(agent({ id: 7, text: "**hi**", streaming: false }), mdToHtml);
expect(html).toContain("<strong>hi</strong>");
expect(html).toContain('data-copy="7"');
expect(html).not.toContain("chat-spinner");
});

it("shows a non-ordinary stop reason but hides end_turn", () => {
expect(turnHtml(agent({ stopReason: "cancelled" }), mdToHtml)).toContain("cancelled");
expect(turnHtml(agent({ stopReason: "end_turn" }), mdToHtml)).not.toContain("chat-reason");
});

it("uses the injected agent renderer (sanitizer hook)", () => {
const html = transcriptHtml([agent({ text: "x", streaming: false })], () => "SANITIZED");
expect(html).toContain("SANITIZED");
});

it("renders an empty-state message for no turns", () => {
expect(transcriptHtml([])).toContain("chat-empty");
});
});
Loading
Loading