Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1a40d44
Hermes: an agent is told its role at every turn, beside its name
futurepaul Oct 30, 2026
6013b00
shell: New chat with its agent, two-line chat rows, ⌘K up top
futurepaul Oct 30, 2026
89f13af
Settings: Add agent (a name and a role), and each agent's role
futurepaul Oct 30, 2026
827abb0
e2e: a row whose words changed may be drawn again
futurepaul Oct 30, 2026
14f2791
Merge branch 'claude/shell-agents-threads' into claude/settings-add-a…
futurepaul Oct 30, 2026
4194934
The stub carries the fragment CLI, and its agents ask each other
futurepaul Oct 30, 2026
bde93fd
shell: delegation threads, a line each over the chat an agent asked from
futurepaul Oct 31, 2026
e2e0c7d
The stub's CLI: its dependencies a layer of their own, unoptimized
futurepaul Oct 31, 2026
d6955c6
Merge branch 'claude/stub-asks' into claude/shell-threads
futurepaul Oct 31, 2026
1b14f3c
docs: the stub's CLI, as it is built now
futurepaul Oct 31, 2026
d075a6a
Merge branch 'claude/stub-asks' into claude/shell-threads
futurepaul Oct 31, 2026
ea17a8f
threads: read again on any change of a row; check each newly ended th…
futurepaul Oct 31, 2026
22610b2
Add agent: a reload finishes an Add it cut short
futurepaul Oct 31, 2026
dce4c23
Merge branch 'claude/settings-add-agent' into claude/stub-asks
futurepaul Oct 31, 2026
3bff24a
Merge branch 'claude/stub-asks' into claude/shell-threads
futurepaul Oct 31, 2026
c58cd3e
Merge branch 'sat-778' into sat-779
futurepaul Oct 31, 2026
75625fd
Merge branch 'claude/stub-asks' into claude/shell-threads
futurepaul Oct 31, 2026
6a61bf1
Merge branch 'claude/stub-asks' into claude/shell-threads
futurepaul Oct 31, 2026
019df47
Merge remote-tracking branch 'origin/master' into claude/shell-threads
futurepaul Oct 31, 2026
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
141 changes: 141 additions & 0 deletions cell/shell-tests/threads.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// cell/shell/threads.js against a world of its own: two agents of one
// person, the chat the first asks from and their pair chat, each row's
// listed records published only when the test says, as the list coalesces
// them (docs/api.md, Listed records: each poster's newest record, sent at
// most once in 3 s), and the public API's newest records read from it.
// Run by `cargo xtask check` on the pinned Node (xtask/src/js_syntax.rs).

import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { createThreads, pairLabel } from "../shell/threads.js";

const ANN = { identity: "npub1ann", fragment: "ann--abcd" };
const BOB = { identity: "npub1bob", fragment: "bob--efgh" };
const ASKING = "ann-chat--wxyz";
const PAIR = "ann-bob--k2m3";

function world() {
const records = new Map();
const rows = new Map([
[ASKING, { name: ASKING, role: "owner", kind: "chat", agents: [ANN.identity] }],
[PAIR, { name: PAIR, role: "owner", kind: "chat", agents: [ANN.identity, BOB.identity] }],
]);
let now = 1_000;
const post = (name, channel, principal, body) => {
const key = `${name}/${channel}`;
const list = records.get(key) ?? [];
records.set(key, list);
const r = { seq: list.length + 1, at: (now += 1_000), principal, body };
list.push(r);
return r;
};
// A row as the list sends it now: each member's newest `work` record,
// and when its newest message was said.
const publish = (name) => {
const work = records.get(`${name}/work`) ?? [];
const newest = new Map(work.map((r) => [r.principal, r]));
const said = records.get(`${name}/chat`) ?? [];
Object.assign(rows.get(name), {
listed: [...newest.values()].reverse().map((r) => ({ channel: "work", principal: r.principal, kind: r.body.kind, at: r.at })),
previewAt: said.at(-1)?.at,
});
};
const api = async (method, path) => {
const m = path.match(/^\/api\/f\/([^/]+)\/channels\/([a-z]+)\?before=\d+&limit=(\d+)$/);
assert.ok(method === "GET" && m, `${method} ${path}`);
return { records: [...(records.get(`${m[1]}/${m[2]}`) ?? [])].reverse().slice(0, Number(m[3])) };
};
const el = (tag, cls = "", text = "") => ({ tag, className: cls, textContent: text, dataset: {}, children: [], append(...c) { this.children.push(...c); } });
const strip = el("div");
const threads = createThreads({
$: (id) => (assert.equal(id, "threads"), strip),
el,
patch: (box, lines) => { box.children = lines; },
api,
seg: (s) => s,
avatar: () => el("span"),
data: {
byName: (name) => rows.get(name),
own: (f) => f.role === "owner",
chats: () => [...rows.values()],
titleOf: (name) => name,
labelOf: (name) => name.replace(/--[a-km-np-z2-9]{4}$/, ""),
agentFace: () => ({}),
ownAgents: () => [ANN, BOB],
current: () => ASKING,
page: () => null,
},
navigation: { openChat() {}, leaveSidebar() {} },
rendered: () => {},
});
// The asking chat's lines, as their states.
const lines = async () => {
await threads.refresh();
threads.render();
return strip.hidden ? [] : strip.children.map((line) => line.dataset.state);
};
// The first agent asks the second in their pair chat; the second's turn
// of it; the first's answer turn in the asking chat.
const ask = (text) => post(PAIR, "chat", ANN.identity, { text, to: [BOB.identity], return_to: ASKING });
const work = (name, who, kind, turn, more = {}) => post(name, "work", who.identity, { kind, turn, ...more });
const answer = (turn) => {
const a = post(ANN.fragment, "tasks", ANN.identity, { kind: "answer", turn, text: "found it" });
work(ASKING, ANN, "turn.start", `answer-${turn}`, { cause: { fragment: ANN.fragment, channel: "tasks", seq: a.seq } });
work(ASKING, ANN, "turn.end", `answer-${turn}`, { outcome: "idle" });
};
return { publish, lines, ask, work, answer };
}

test("the pair chat's label is the CLI's (cli/src/ask.rs pair_label)", () => {
assert.equal(pairLabel("bob", "ann"), "ann-bob");
assert.equal(pairLabel("a".repeat(30), "b".repeat(30)), `${"a".repeat(19)}-${"b".repeat(19)}`);
});

// Alex's first case (#779): the question read before its turn started,
// and the row's next send already past the start.
for (const [kind, state] of [["turn.step", "working"], ["turn.prompt", "you"]]) {
test(`a turn that started unseen, its row already at ${kind}, says ${state}`, async () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const w = world();
const q = w.ask("look it up");
w.publish(PAIR);
w.publish(ASKING);
assert.deepEqual(await w.lines(), ["asked"]);
w.work(PAIR, BOB, "turn.start", "t1", { cause: { channel: "chat", seq: q.seq } });
w.work(PAIR, BOB, kind, "t1");
w.publish(PAIR);
assert.deepEqual(await w.lines(), [state]);
} finally {
mock.timers.reset();
}
});
}

// Alex's second case (#779): two asks returning to one chat, the asking
// chat's row sent with both answers in before the pair's row says the
// second ended.
test("an answer that came before its thread's end was read still takes its line away", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
const w = world();
const q1 = w.ask("first");
w.work(PAIR, BOB, "turn.start", "t1", { cause: { channel: "chat", seq: q1.seq } });
w.work(PAIR, BOB, "turn.end", "t1", { outcome: "idle" });
const q2 = w.ask("second");
w.work(PAIR, BOB, "turn.start", "t2", { cause: { channel: "chat", seq: q2.seq } });
w.publish(PAIR);
w.publish(ASKING);
assert.deepEqual(await w.lines(), ["done", "working"]);
w.answer("t1");
w.work(PAIR, BOB, "turn.end", "t2", { outcome: "idle" });
w.answer("t2");
w.publish(ASKING);
assert.deepEqual(await w.lines(), ["working"], "the first's answer is in; the pair's row has not said the second ended");
w.publish(PAIR);
assert.deepEqual(await w.lines(), [], "the second's end read, its answer is found");
assert.deepEqual(await w.lines(), [], "and stays found");
} finally {
mock.timers.reset();
}
});
14 changes: 10 additions & 4 deletions cell/shell/chat-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const newestChat = (a, b) => (b.previewAt ?? Infinity) - (a.previewAt ??

export function createChatList({ api, ui, data, selection, menus, navigation, listLive, load, invites, setInvites }) {
const { $, el, patch, agentFace, mark, notice } = ui;
const { byName, own, chats, shown, titleOf, chatTitle, homeMark, isGroup, agentOfChat, agentsOf, agents, allFragments, computer, isGuest, personId } = data;
const { byName, own, chats, shown, titleOf, chatTitle, homeMark, isGroup, agentOfChat, agentsOf, agents, allFragments, computer, isGuest, personId, isThread, waitingOn, renderThreads } = data;
const { current, page, getFrame } = selection;
const { menuOwner, openMenu, shareItem, archiveItem } = menus;
const { newChat, rename, openChat, openApp, openScreen, leaveSidebar, seg } = navigation;
Expand Down Expand Up @@ -143,8 +143,11 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li
// one agent's role (its `meta.role`, on its row in the list), as a tag
const role = theirs.length === 1 ? byName(theirs[0].fragment)?.agentRole : null;
if (role) who.append(el("span", "role-tag", role));
// what it is doing here, while it is, in place of when
// what it is doing here, while it is, else whom it waits on (a thread
// asked from here: threads.js), in place of when
const waits = doing ? null : waitingOn(f.name);
if (doing) who.append(el("span", `agent-doing ${doing.doing}`, DOING_SAYS[doing.doing]));
else if (waits) who.append(el("span", "agent-doing waiting", `Waiting on ${titleOf(waits.fragment)}`));
else if (f.previewAt) who.append(saidAt(f.previewAt));
const what = el("span", "chat-what");
what.append(...marks);
Expand Down Expand Up @@ -218,7 +221,9 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li
}
const mine = new Map(ownAgents().map((a) => [a.identity, a]));
const seen = [];
const rows = shown(chats()).sort(newestChat).map((f) => {
// a delegation thread's chat is no chat of the list: its line, over the
// chat it was asked from, opens it (threads.js)
const rows = shown(chats()).filter((f) => !isThread(f)).sort(newestChat).map((f) => {
const theirs = agentsOf(f.name).map((id) => mine.get(id)).filter(Boolean);
const doing = doingIn(f, theirs.map((a) => a.identity));
seen.push(...doing);
Expand All @@ -227,6 +232,7 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li
doingAgain(seen);
dayAgain();
patch($("chats"), rows.length ? rows : [el("div", "empty-row", chats().length ? "Every chat is archived" : isGuest() ? "Chats shared with you show here" : "Your chats will appear here")]);
renderThreads();
}

// ---- invites: shared by email, and theirs only once they accept (decision 63) ----
Expand Down Expand Up @@ -364,5 +370,5 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li
archiveItem(name),
]);
}
return { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, markSeen };
return { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, ownAgents, markSeen };
}
2 changes: 2 additions & 0 deletions cell/shell/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ <h1 class="chat-heading" id="chat-heading" hidden>
<!-- the shell's own notice (shell.js notice), above a chat or
settings alike: never in #frames, which settings hide -->
<div class="notice" id="notice"><strong>Loading…</strong></div>
<!-- the open chat's delegation threads, a line each (threads.js) -->
<div class="threads" id="threads" hidden></div>
<div class="frames" id="frames"></div>
<section class="page" id="settings-page" hidden></section>
<section class="page" id="apps-page" hidden></section>
Expand Down
15 changes: 15 additions & 0 deletions cell/shell/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,21 @@ button.agent-pick:hover { background: var(--bg-hover); }
.chat-who .agent-doing { font-size: 11px; font-weight: 600; }
.chat-who .agent-doing.working { color: var(--success); }
.chat-who .agent-doing.you { color: var(--accent-blue); }
/* the open chat's delegation threads, a line each over it (threads.js) */
.threads { display: flex; flex: none; flex-direction: column; gap: 4px; padding: 0 16px 8px; }
.thread { display: flex; align-items: center; gap: 6px; min-width: 0; }
.thread-open { display: flex; flex: 1; align-items: center; gap: 8px; min-width: 0; height: 32px; padding: 0 12px 0 6px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-sidebar); color: var(--text-primary); font-size: 13px; text-align: left; }
.thread-open:hover { background: var(--bg-hover); }
.thread-open .agent-avatar { width: 20px; height: 20px; }
.thread-who { flex: none; font-weight: 600; }
.thread-what { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
.thread-state, .thread-took { flex: none; font-size: 11.5px; color: var(--text-tertiary); }
.thread-state { font-weight: 600; }
.thread-state.working { color: var(--success); }
.thread-state.you { color: var(--accent-blue); }
.thread-state.lost { color: var(--danger-text); }
.thread-stop { flex: none; height: 32px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 999px; background: none; color: var(--text-primary); font-size: 13px; }
.thread-stop:disabled { opacity: .55; }
/* one agent's role (its `meta.role`), a tag beside its name */
.role-tag { flex: 0 4 auto; min-width: 24px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 6px; border-radius: 999px; background: var(--bg-active); color: var(--text-secondary); font-size: 11px; font-weight: 500; line-height: 16px; }
.chat-row.active .role-tag { background: var(--bg-window); }
Expand Down
16 changes: 14 additions & 2 deletions cell/shell/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { createAgentSettings } from "./agent-settings.js";
import { createConnections, createAgentHomes, createSimplex } from "./connections.js";
import { el, section, line, actionLine, createSettings } from "./settings.js";
import { createLauncher } from "./launcher.js";
import { createThreads } from "./threads.js";

const $ = (id) => document.getElementById(id);
const CURRENT = "shell.chat.v1";
Expand Down Expand Up @@ -819,16 +820,26 @@ addEventListener("resize", () => closeMenu(true));

const watching = { on: false, socket: null, wait: 1000, timer: null, settle: null };
const listLive = () => watching.socket?.readyState === WebSocket.OPEN;
const { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, markSeen } = createChatList({
const { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, ownAgents, markSeen } = createChatList({
api, ui: { $, el, patch, agentFace, mark, notice },
data: { byName, own, chats, shown, titleOf, chatTitle: (name) => chatTitle(name), homeMark: (f) => homeMark(f), isGroup, agentOfChat: (name) => agentOfChat(name), agentsOf,
agents: () => state.agents, allFragments: () => state.fragments, computer: () => state.computer, isGuest: () => state.guest, personId: () => state.me?.id },
agents: () => state.agents, allFragments: () => state.fragments, computer: () => state.computer, isGuest: () => state.guest, personId: () => state.me?.id,
isThread: (f) => threads.isPair(f), waitingOn: (name) => threads.waitingOn(name), renderThreads: () => threads.render() },
selection: { current: () => state.current, page: () => state.page, getFrame: (name) => state.frames.get(name) },
menus: { menuOwner: () => menuFor, openMenu, shareItem, archiveItem },
navigation: { newChat, rename: (name) => rename(name), openChat, openApp, openScreen, leaveSidebar, seg },
listLive, load, invites: () => state.invites, setInvites: (v) => { state.invites = v; },
});

// Delegation threads: a line each over the chat an agent asked from, its
// pair chat its transcript (threads.js)
const threads = createThreads({
$, el, patch, api, seg, avatar,
data: { byName, own, chats, titleOf, labelOf, agentFace, ownAgents, current: () => state.current, page: () => state.page },
navigation: { openChat, leaveSidebar },
rendered: () => renderChats(),
});

// The identity of the person's own agent a direct chat is with (its row's
// one agent, or the agent its label is for while its row names none:
// `agentsOf`); null for a group, or a chat with someone else's agent.
Expand Down Expand Up @@ -1519,6 +1530,7 @@ async function load(changed = false) {
state.agents = new Map((state.computer?.agents ?? []).map((a) => [a.identity, a]));
}
renderChats();
threads.refresh().catch(() => {});
renderApps();
appsPage.listed().catch(() => {});
renderHeading();
Expand Down
Loading
Loading