Redesign web UI with console-style squircle message cards
Build and Push agentchat Image / quality (push) Successful in 35s
Build and Push agentchat Image / build-image (push) Failing after 24s

Chat messages render as soft-shadow squircle cards (true superellipse
corners where supported) with per-agent color avatars, a route pill,
and a per-message Copy button (hover on desktop, always visible on
touch). The 'to' field is a dropdown fed by the agents list, and the
posting name is editable and persisted in localStorage. Setup tab
matches the same look with a copyable install command.
This commit is contained in:
Gabriel Brown
2026-08-13 10:29:06 -04:00
parent e57e5f7192
commit a6e8bf7b28
2 changed files with 116 additions and 38 deletions
+114 -38
View File
@@ -111,6 +111,8 @@ function escapeHtml(text: string): string {
.replaceAll('"', """); .replaceAll('"', """);
} }
// "Console" look (mock C): soft-shadow squircle cards on a cool ground,
// per-agent color identity, mono for machine data, teal accent.
function page(active: "setup" | "chat", content: string, script = ""): string { function page(active: "setup" | "chat", content: string, script = ""): string {
const tab = (id: string, href: string, label: string) => const tab = (id: string, href: string, label: string) =>
`<a href="${href}"${active === id ? ' class="active"' : ""}>${label}</a>`; `<a href="${href}"${active === id ? ' class="active"' : ""}>${label}</a>`;
@@ -121,28 +123,63 @@ function page(active: "setup" | "chat", content: string, script = ""): string {
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>agentchat</title> <title>agentchat</title>
<style> <style>
:root { color-scheme: light dark; } :root {
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; } color-scheme: light dark;
nav { display: flex; gap: 1rem; align-items: baseline; margin-bottom: 1.5rem; } --bg: #f2f4f5; --card: #ffffff; --ink: #16191b; --muted: #6d757b; --accent: #2f8f83;
nav h1 { font-size: 1.3rem; margin: 0 auto 0 0; } --shadow: 0 1px 2px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04);
nav a { text-decoration: none; opacity: 0.7; } }
nav a.active { opacity: 1; font-weight: 600; border-bottom: 2px solid currentColor; } @media (prefers-color-scheme: dark) {
code, pre { font-family: ui-monospace, monospace; background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 4px; } :root { --bg: #0e1113; --card: #171c1f; --ink: #e6eaec; --muted: #8a9299; --shadow: 0 0 0 1px rgb(255 255 255 / 0.06); }
code { padding: 0.1rem 0.3rem; } }
pre { padding: 0.6rem 0.8rem; overflow-x: auto; } * { box-sizing: border-box; }
ul { list-style: none; padding: 0; } body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--ink); max-width: 46rem; margin: 0 auto; padding: 2rem 1rem 4rem; line-height: 1.55; }
li { margin-bottom: 0.75rem; } nav { display: flex; gap: 1.25rem; align-items: baseline; margin-bottom: 2rem; }
li p { margin: 0.1rem 0 0; white-space: pre-wrap; } nav h1 { font-size: 1.15rem; margin: 0 auto 0 0; letter-spacing: -0.02em; font-family: ui-monospace, monospace; }
.meta { font-size: 0.8rem; opacity: 0.65; } nav a { text-decoration: none; color: var(--muted); font-size: 0.95rem; }
h2 { margin-top: 2rem; font-size: 1.1rem; } nav a.active { color: var(--accent); font-weight: 600; }
button { font: inherit; padding: 0.35rem 0.9rem; border-radius: 6px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); background: color-mix(in srgb, currentColor 8%, transparent); color: inherit; cursor: pointer; } nav a:focus-visible, button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.copy-row { display: flex; gap: 0.5rem; align-items: stretch; } .row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.1rem; gap: 0.75rem; }
.copy-row pre { flex: 1; margin: 0; display: flex; align-items: center; } .meta { font-family: ui-monospace, monospace; font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
form#send { display: grid; gap: 0.5rem; margin: 1rem 0; } h2 { margin-top: 2rem; font-size: 1.05rem; }
form#send input, form#send textarea { font: inherit; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); background: transparent; color: inherit; } code { font-family: ui-monospace, monospace; background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 5px; padding: 0.1rem 0.3rem; }
form#send textarea { min-height: 4rem; resize: vertical; } a { color: var(--accent); }
.row { display: flex; gap: 0.5rem; justify-content: space-between; align-items: center; } button { font: inherit; font-size: 0.85rem; padding: 0.32rem 0.85rem; border-radius: 10px; border: none; background: var(--card); box-shadow: var(--shadow); color: var(--muted); cursor: pointer; }
#clear { opacity: 0.75; } button:hover { color: var(--ink); }
/* Squircle message cards — true superellipse corners where supported */
ul#messages { list-style: none; padding: 0; margin: 0; }
.msg { display: grid; grid-template-columns: 2.1rem 1fr; gap: 0.7rem; background: var(--card); box-shadow: var(--shadow); border-radius: 18px; padding: 0.85rem 1rem; margin-bottom: 0.75rem; position: relative; }
@supports (corner-shape: superellipse(1.5)) { .msg { border-radius: 24px; corner-shape: superellipse(1.5); } }
.avatar { width: 2.1rem; height: 2.1rem; border-radius: 50%; display: grid; place-items: center; font-family: ui-monospace, monospace; font-weight: 700; font-size: 0.95rem; color: #fff; }
.msg .head { display: flex; gap: 0.55rem; align-items: baseline; font-size: 0.8rem; flex-wrap: wrap; }
.msg .from { font-weight: 650; }
.msg .route { font-family: ui-monospace, monospace; color: var(--muted); background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 6px; padding: 0.05rem 0.4rem; }
.msg .time { margin-left: auto; font-family: ui-monospace, monospace; color: var(--muted); font-size: 0.75rem; }
.msg p { margin: 0.2rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.copy { position: absolute; top: 0.65rem; right: 0.7rem; opacity: 0; font-size: 0.73rem; padding: 0.14rem 0.55rem; transition: opacity 120ms; }
.msg:hover .copy, .msg:focus-within .copy { opacity: 1; }
.msg:hover .time { opacity: 0; }
.copy.done { color: var(--accent); }
form#send { display: grid; gap: 0.55rem; margin-top: 1.6rem; }
select, textarea { font: inherit; padding: 0.55rem 0.8rem; border-radius: 14px; border: none; background: var(--card); box-shadow: var(--shadow); color: inherit; }
select { width: fit-content; }
.posts-as input { font: inherit; width: 8ch; border: none; border-bottom: 1px dashed currentColor; background: none; color: inherit; padding: 0; }
textarea { min-height: 4rem; resize: vertical; }
.copy-row { display: flex; gap: 0.6rem; align-items: stretch; }
.copy-row pre { flex: 1; margin: 0; display: flex; align-items: center; font-family: ui-monospace, monospace; font-size: 0.85rem; background: var(--card); box-shadow: var(--shadow); border-radius: 14px; padding: 0.7rem 0.9rem; overflow-x: auto; }
@media (prefers-reduced-motion: reduce) { .copy { transition: none; } }
/* Touch devices have no hover: keep Copy always available, leave time visible */
@media (hover: none) {
.copy { opacity: 1; }
.msg:hover .time { opacity: 1; }
}
@media (max-width: 480px) {
body { padding: 1rem 0.7rem 3rem; }
nav { margin-bottom: 1.25rem; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -162,7 +199,7 @@ function renderSetup(base: string): string {
<h2>Install the skill on a machine</h2> <h2>Install the skill on a machine</h2>
<div class="copy-row"> <div class="copy-row">
<pre id="install">${escapeHtml(install)}</pre> <pre id="install">${escapeHtml(install)}</pre>
<button id="copy">Copy</button> <button id="copy-install">Copy</button>
</div> </div>
<p>That installs <code>~/.claude/skills/agentchat/SKILL.md</code>. The agent's name defaults to <p>That installs <code>~/.claude/skills/agentchat/SKILL.md</code>. The agent's name defaults to
the machine's hostname; set <code>AGENTCHAT_NAME</code> to override. the machine's hostname; set <code>AGENTCHAT_NAME</code> to override.
@@ -171,9 +208,9 @@ See <a href="/skill.md">skill.md</a> for the API the skill teaches.</p>
<h2>How it works</h2> <h2>How it works</h2>
<p>One shared log with addressing: messages go to one agent (<code>to</code>) or everyone. <p>One shared log with addressing: messages go to one agent (<code>to</code>) or everyone.
Messages are archived out of view after 24 hours; nothing is deleted. Messages are archived out of view after 24 hours; nothing is deleted.
Post as <b>user</b> from the <a href="/chat">Chat</a> tab.</p>`, Post from the <a href="/chat">Chat</a> tab under any name you like.</p>`,
`document.getElementById("copy").addEventListener("click", async () => { `document.getElementById("copy-install").addEventListener("click", async () => {
const btn = document.getElementById("copy"); const btn = document.getElementById("copy-install");
await navigator.clipboard.writeText(document.getElementById("install").textContent.trim()); await navigator.clipboard.writeText(document.getElementById("install").textContent.trim());
btn.textContent = "Copied!"; btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 1500); setTimeout(() => { btn.textContent = "Copy"; }, 1500);
@@ -186,25 +223,51 @@ function renderChat(): string {
"chat", "chat",
`<div class="row"> `<div class="row">
<span id="agents" class="meta">loading agents…</span> <span id="agents" class="meta">loading agents…</span>
<button id="clear">Clear chat</button> <button id="clear" type="button">Clear chat</button>
</div> </div>
<ul id="messages"></ul> <ul id="messages"></ul>
<form id="send"> <form id="send">
<input id="to" placeholder="to (blank = everyone)"> <select id="to"><option value="">to: everyone</option></select>
<textarea id="body" placeholder="Send a message as user…" required></textarea> <textarea id="body" placeholder="Send a message…" required></textarea>
<div class="row"><span class="meta">posts as <b>user</b></span><button type="submit">Send</button></div> <div class="row" style="margin:0"><span class="meta posts-as">posts as <input id="myname" aria-label="your name"></span><button type="submit" style="color:var(--ink)">Send</button></div>
</form>`, </form>`,
`const list = document.getElementById("messages"); `const list = document.getElementById("messages");
let lastId = 0; let lastId = 0;
function hueOf(name) {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 360;
return h;
}
function myName() {
return (localStorage.getItem("agentchat-name") || "user").trim() || "user";
}
function render(m) { function render(m) {
const li = document.createElement("li"); const li = document.createElement("li");
const meta = document.createElement("span"); li.className = "msg";
meta.className = "meta"; const av = document.createElement("div");
meta.textContent = m.ts + " \\u00b7 " + m.from + " \\u2192 " + (m.to ?? "everyone"); av.className = "avatar";
av.style.background = "oklch(0.62 0.13 " + hueOf(m.from) + ")";
av.textContent = m.from[0].toUpperCase();
const right = document.createElement("div");
const head = document.createElement("div");
head.className = "head";
const from = document.createElement("span"); from.className = "from"; from.textContent = m.from;
const route = document.createElement("span"); route.className = "route"; route.textContent = "\\u2192 " + (m.to ?? "everyone");
const time = document.createElement("span"); time.className = "time"; time.textContent = m.ts.slice(11, 16) + " UTC"; time.title = m.ts;
head.append(from, route, time);
const p = document.createElement("p"); const p = document.createElement("p");
p.textContent = m.body; p.textContent = m.body;
li.append(meta, p); right.append(head, p);
const copy = document.createElement("button");
copy.className = "copy"; copy.type = "button"; copy.textContent = "Copy";
copy.addEventListener("click", async () => {
await navigator.clipboard.writeText(m.body);
copy.textContent = "Copied"; copy.classList.add("done");
setTimeout(() => { copy.textContent = "Copy"; copy.classList.remove("done"); }, 1200);
});
li.append(av, right, copy);
list.append(li); list.append(li);
lastId = Math.max(lastId, m.id); lastId = Math.max(lastId, m.id);
} }
@@ -216,19 +279,32 @@ async function poll() {
async function loadAgents() { async function loadAgents() {
const res = await fetch("/api/agents"); const res = await fetch("/api/agents");
const names = (await res.json()).map((a) => a.name).join(", "); const names = (await res.json()).map((a) => a.name);
document.getElementById("agents").textContent = names ? "agents: " + names : "no agents yet"; document.getElementById("agents").textContent = names.length ? "agents: " + names.join(", ") : "no agents yet";
const sel = document.getElementById("to");
const current = sel.value;
while (sel.options.length > 1) sel.remove(1);
for (const n of names.filter((n) => n !== myName())) {
const o = document.createElement("option");
o.value = n; o.textContent = "to: " + n;
sel.append(o);
}
sel.value = current;
if (sel.selectedIndex < 0) sel.value = "";
} }
const nameInput = document.getElementById("myname");
nameInput.value = myName();
nameInput.addEventListener("input", () => localStorage.setItem("agentchat-name", nameInput.value));
document.getElementById("send").addEventListener("submit", async (e) => { document.getElementById("send").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const to = document.getElementById("to").value.trim();
const body = document.getElementById("body").value.trim(); const body = document.getElementById("body").value.trim();
if (!body) return; if (!body) return;
await fetch("/api/messages", { await fetch("/api/messages", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ from: "user", to: to || undefined, body }), body: JSON.stringify({ from: myName(), to: document.getElementById("to").value || undefined, body }),
}); });
document.getElementById("body").value = ""; document.getElementById("body").value = "";
poll(); poll();
+2
View File
@@ -114,6 +114,8 @@ describe("web pages", () => {
const chat = await (await handle(new Request("http://chat.example.com/chat"))).text(); const chat = await (await handle(new Request("http://chat.example.com/chat"))).text();
expect(chat).toContain('id="send"'); expect(chat).toContain('id="send"');
expect(chat).toContain('id="clear"'); expect(chat).toContain('id="clear"');
expect(chat).toContain('id="to"');
expect(chat).toContain('id="myname"');
}); });
}); });