Redesign web UI with console-style squircle message cards
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:
+114
-38
@@ -111,6 +111,8 @@ function escapeHtml(text: string): string {
|
||||
.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 {
|
||||
const tab = (id: string, href: string, label: string) =>
|
||||
`<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">
|
||||
<title>agentchat</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||
nav { display: flex; gap: 1rem; align-items: baseline; margin-bottom: 1.5rem; }
|
||||
nav h1 { font-size: 1.3rem; margin: 0 auto 0 0; }
|
||||
nav a { text-decoration: none; opacity: 0.7; }
|
||||
nav a.active { opacity: 1; font-weight: 600; border-bottom: 2px solid currentColor; }
|
||||
code, pre { font-family: ui-monospace, monospace; background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 4px; }
|
||||
code { padding: 0.1rem 0.3rem; }
|
||||
pre { padding: 0.6rem 0.8rem; overflow-x: auto; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li { margin-bottom: 0.75rem; }
|
||||
li p { margin: 0.1rem 0 0; white-space: pre-wrap; }
|
||||
.meta { font-size: 0.8rem; opacity: 0.65; }
|
||||
h2 { margin-top: 2rem; font-size: 1.1rem; }
|
||||
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; }
|
||||
.copy-row { display: flex; gap: 0.5rem; align-items: stretch; }
|
||||
.copy-row pre { flex: 1; margin: 0; display: flex; align-items: center; }
|
||||
form#send { display: grid; gap: 0.5rem; margin: 1rem 0; }
|
||||
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; }
|
||||
form#send textarea { min-height: 4rem; resize: vertical; }
|
||||
.row { display: flex; gap: 0.5rem; justify-content: space-between; align-items: center; }
|
||||
#clear { opacity: 0.75; }
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f2f4f5; --card: #ffffff; --ink: #16191b; --muted: #6d757b; --accent: #2f8f83;
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #0e1113; --card: #171c1f; --ink: #e6eaec; --muted: #8a9299; --shadow: 0 0 0 1px rgb(255 255 255 / 0.06); }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
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; }
|
||||
nav { display: flex; gap: 1.25rem; align-items: baseline; margin-bottom: 2rem; }
|
||||
nav h1 { font-size: 1.15rem; margin: 0 auto 0 0; letter-spacing: -0.02em; font-family: ui-monospace, monospace; }
|
||||
nav a { text-decoration: none; color: var(--muted); font-size: 0.95rem; }
|
||||
nav a.active { color: var(--accent); font-weight: 600; }
|
||||
nav a:focus-visible, button:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.1rem; gap: 0.75rem; }
|
||||
.meta { font-family: ui-monospace, monospace; font-size: 0.78rem; color: var(--muted); overflow-wrap: anywhere; }
|
||||
h2 { margin-top: 2rem; font-size: 1.05rem; }
|
||||
code { font-family: ui-monospace, monospace; background: color-mix(in srgb, currentColor 8%, transparent); border-radius: 5px; padding: 0.1rem 0.3rem; }
|
||||
a { color: var(--accent); }
|
||||
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; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -162,7 +199,7 @@ function renderSetup(base: string): string {
|
||||
<h2>Install the skill on a machine</h2>
|
||||
<div class="copy-row">
|
||||
<pre id="install">${escapeHtml(install)}</pre>
|
||||
<button id="copy">Copy</button>
|
||||
<button id="copy-install">Copy</button>
|
||||
</div>
|
||||
<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.
|
||||
@@ -171,9 +208,9 @@ See <a href="/skill.md">skill.md</a> for the API the skill teaches.</p>
|
||||
<h2>How it works</h2>
|
||||
<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.
|
||||
Post as <b>user</b> from the <a href="/chat">Chat</a> tab.</p>`,
|
||||
`document.getElementById("copy").addEventListener("click", async () => {
|
||||
const btn = document.getElementById("copy");
|
||||
Post from the <a href="/chat">Chat</a> tab under any name you like.</p>`,
|
||||
`document.getElementById("copy-install").addEventListener("click", async () => {
|
||||
const btn = document.getElementById("copy-install");
|
||||
await navigator.clipboard.writeText(document.getElementById("install").textContent.trim());
|
||||
btn.textContent = "Copied!";
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
|
||||
@@ -186,25 +223,51 @@ function renderChat(): string {
|
||||
"chat",
|
||||
`<div class="row">
|
||||
<span id="agents" class="meta">loading agents…</span>
|
||||
<button id="clear">Clear chat</button>
|
||||
<button id="clear" type="button">Clear chat</button>
|
||||
</div>
|
||||
<ul id="messages"></ul>
|
||||
<form id="send">
|
||||
<input id="to" placeholder="to (blank = everyone)">
|
||||
<textarea id="body" placeholder="Send a message as user…" required></textarea>
|
||||
<div class="row"><span class="meta">posts as <b>user</b></span><button type="submit">Send</button></div>
|
||||
<select id="to"><option value="">to: everyone</option></select>
|
||||
<textarea id="body" placeholder="Send a message…" required></textarea>
|
||||
<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>`,
|
||||
`const list = document.getElementById("messages");
|
||||
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) {
|
||||
const li = document.createElement("li");
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "meta";
|
||||
meta.textContent = m.ts + " \\u00b7 " + m.from + " \\u2192 " + (m.to ?? "everyone");
|
||||
li.className = "msg";
|
||||
const av = document.createElement("div");
|
||||
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");
|
||||
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);
|
||||
lastId = Math.max(lastId, m.id);
|
||||
}
|
||||
@@ -216,19 +279,32 @@ async function poll() {
|
||||
|
||||
async function loadAgents() {
|
||||
const res = await fetch("/api/agents");
|
||||
const names = (await res.json()).map((a) => a.name).join(", ");
|
||||
document.getElementById("agents").textContent = names ? "agents: " + names : "no agents yet";
|
||||
const names = (await res.json()).map((a) => a.name);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
const to = document.getElementById("to").value.trim();
|
||||
const body = document.getElementById("body").value.trim();
|
||||
if (!body) return;
|
||||
await fetch("/api/messages", {
|
||||
method: "POST",
|
||||
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 = "";
|
||||
poll();
|
||||
|
||||
@@ -114,6 +114,8 @@ describe("web pages", () => {
|
||||
const chat = await (await handle(new Request("http://chat.example.com/chat"))).text();
|
||||
expect(chat).toContain('id="send"');
|
||||
expect(chat).toContain('id="clear"');
|
||||
expect(chat).toContain('id="to"');
|
||||
expect(chat).toContain('id="myname"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user