# team chat: own chat store, named workers, mention wakes, cancel interrupt (OPE-99)

ChatStore = groups + append-only messages + per-member cursors; agent posts wake mentions only, user posts wake everyone; post_chat(record_on_item) also lands the answer as an item comment.
Leads name workers (the callname is the handle everywhere); worker digests auto-carry the roster; gate checkbox is the user's call; canceling an assigned item now interrupts an in-flight worker.
This commit is contained in:
Rohit C Prasad
2026-08-16 16:22:23 -07:00
committed by Rohit P
parent 844a6510a2
commit cf436d1069
20 changed files with 899 additions and 44 deletions
+55 -10
View File
@@ -565,6 +565,17 @@ export async function mockApi(page: import("@playwright/test").Page) {
// "plan the work" (the fake agent then files items; no draft state — the board only
// holds accepted work). Mutable so transitions round-trip through the real endpoints.
const boardItems: any[] = [];
// # team chat log — seeded with one lead question so mention highlighting renders.
const chatMessages: any[] = [
{
seq: 1,
ts: new Date().toISOString(),
author: "lead",
author_role: "lead",
text: "@nia does the api assume the assets bucket is public? quick check before you write it up.",
mentions: ["nia"],
},
];
const seedBoard = () => {
if (boardItems.length) return;
boardItems.push(
@@ -648,12 +659,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
if (/staff the team/i.test(msg.text)) {
send("team_proposed", {
members: [
{ persona: "swe-worker", model: "anthropic:claude-opus-4-8", reason: "implementation" },
{ persona: "design-worker", reason: "UI polish" },
{ persona: "test-worker", reason: "verifies against acceptance criteria" },
{ persona: "swe-worker", name: "nia", model: "anthropic:claude-opus-4-8", reason: "implementation" },
{ persona: "design-worker", name: "webb", reason: "UI polish" },
{ persona: "test-worker", name: "checks", reason: "verifies against acceptance criteria" },
],
enable_chat: false,
note: "Three workers cover the plan; test-worker verifies before anything closes.",
note: "Three workers cover the plan; checks verifies before anything closes.",
});
return; // suspended on the staffing decision
}
@@ -883,16 +894,22 @@ export async function mockApi(page: import("@playwright/test").Page) {
team: { role: "lead", team_id: "t1" },
};
if (!sessions.includes(lead)) sessions.unshift(lead);
for (const [actor, status, item] of [
["swe-worker", "in_progress", "#1 in progress"],
["design-worker", "idle", "idle"],
["test-worker", "blocked", "#4 blocked"],
lead.team = {
role: "lead",
team_id: "t1",
chat_enabled: !!msg.enable_chat,
chat_unread: msg.enable_chat ? 1 : 0,
};
for (const [actor, persona, status, item] of [
["nia", "swe-worker", "in_progress", "#1 in progress"],
["webb", "design-worker", "idle", "idle"],
["checks", "test-worker", "blocked", "#4 blocked"],
] as const) {
sessions.push({
session_id: `sess-${actor}`,
title: actor,
workspace: "/Users/test/OpenWorker/launch-note",
agent: actor,
agent: persona,
model: "m",
mode: "interactive",
updated_at: new Date().toISOString(),
@@ -908,7 +925,7 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
}
send("assistant_message", {
text: "Team created — swe-worker, design-worker and test-worker are standing by. Assigning items now.",
text: "Team created — nia, webb and checks are standing by. Assigning items now.",
});
} else {
send("assistant_message", { text: "Understood — tell me how to change the roster." });
@@ -1019,6 +1036,34 @@ export async function mockApi(page: import("@playwright/test").Page) {
return json(item);
}
if (/\/v1\/sessions\/[^/]+\/board$/.test(p)) return json(boardPayload());
// # team chat (OPE-99): one group, message log, user posts append.
if (/\/v1\/teams\/[^/]+\/chat$/.test(p)) {
if (m === "POST") {
const b = req.postDataJSON() || {};
chatMessages.push({
seq: chatMessages.length + 1,
ts: new Date().toISOString(),
author: "user",
author_role: "user",
text: String(b.text || ""),
mentions: ["nia", "webb", "checks", "lead"].filter((h) =>
String(b.text || "").includes(`@${h}`),
),
});
return json(chatMessages[chatMessages.length - 1]);
}
return json({
enabled: true,
team_id: "t1",
members: [
{ name: "nia", persona: "swe-worker", role: "worker" },
{ name: "webb", persona: "design-worker", role: "worker" },
{ name: "checks", persona: "test-worker", role: "worker" },
{ name: "lead", persona: "swe-lead", role: "lead" },
],
messages: chatMessages,
});
}
if (p.endsWith("/v1/teams/journal")) {
return json({
cases: boardItems.length
+47 -5
View File
@@ -41,18 +41,60 @@ test("declining the split returns feedback to the lead", async ({ page }) => {
await expect(page.getByText(/reworking the split/)).toBeVisible();
});
test("the staffing gate shows the roster and the grant sentence", async ({ page }) => {
test("the staffing gate shows named workers, the chat toggle, and the grant sentence", async ({
page,
}) => {
await proposeTeam(page);
const card = page.getByTestId("teamreq-card");
await expect(card).toContainText("Proposed team — 3 workers");
// callnames lead the rows; persona + reason follow
await expect(card).toContainText("nia");
await expect(card).toContainText("swe-worker");
await expect(card).toContainText("implementation");
await expect(card).toContainText("test-worker");
await expect(card).toContainText("checks");
// the chat checkbox defaults OFF — the user's call, not the lead's
await expect(card.getByTestId("teamreq-chat-toggle")).not.toBeChecked();
await expect(card).toContainText(
"Approving grants the lead create, assign & steer — this team only, revocable.",
);
});
test("enabling chat at the gate adds the # team chat row; posting works with mentions", async ({
page,
}) => {
await proposeTeam(page);
await page.getByTestId("teamreq-chat-toggle").check();
await page.getByTestId("teamreq-approve").click();
await expect(page.getByText(/Team created/)).toBeVisible();
await page.getByTestId("team-toggle-sess-lead").click();
const chatRow = page.getByTestId("team-chat-row-sess-lead");
await expect(chatRow).toBeVisible();
await expect(chatRow).toContainText("1"); // unread badge
await chatRow.click();
const view = page.getByTestId("teamchat-view");
await expect(view).toBeVisible();
await expect(view).toContainText("assets bucket is public");
await expect(view.locator(".chat-mention").first()).toHaveText("@nia");
await page.getByTestId("chat-input").fill("ship it current-month only @lead");
await page.getByTestId("chat-send").click();
await expect(view).toContainText("ship it current-month only");
await page.keyboard.press("Escape");
await expect(page.getByTestId("teamchat-view")).toHaveCount(0);
});
test("with chat declined at the gate, no chat row renders", async ({ page }) => {
await proposeTeam(page);
await page.getByTestId("teamreq-approve").click();
await expect(page.getByText(/Team created/)).toBeVisible();
await page.getByTestId("team-toggle-sess-lead").click();
await expect(page.getByTestId("team-children-sess-lead")).toBeVisible();
await expect(page.getByTestId("team-chat-row-sess-lead")).toHaveCount(0);
});
test("declining the roster returns the turn to the lead", async ({ page }) => {
await proposeTeam(page);
await page.getByRole("button", { name: "Not now" }).click();
@@ -76,9 +118,9 @@ test("approval creates the team; workers nest under the lead's expandable entry"
await page.getByTestId("team-toggle-sess-lead").click();
const children = page.getByTestId("team-children-sess-lead");
await expect(children).toBeVisible();
await expect(children).toContainText("swe-worker · #1 in progress");
await expect(children).toContainText("design-worker · idle");
await expect(children).toContainText("test-worker · #4 blocked");
await expect(children).toContainText("nia · #1 in progress");
await expect(children).toContainText("webb · idle");
await expect(children).toContainText("checks · #4 blocked");
// Collapse hides them again — the team is one entry, not a panel.
await page.getByTestId("team-toggle-sess-lead").click();
+7 -2
View File
@@ -78,6 +78,7 @@ import { PlanCard } from "./components/PlanCard";
import { BoardOverlay } from "./components/BoardPanel";
import { TeamRequestCard } from "./components/TeamRequestCard";
import { WorkItemsCard } from "./components/WorkItemsCard";
import { TeamChatView } from "./components/TeamChatView";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
const newId = () =>
@@ -276,6 +277,8 @@ export function App() {
// Agent teams (OPE-96): board for the current session's workspace space.
const [board, setBoard] = useState<Board | null>(null);
const [boardOpen, setBoardOpen] = useState(false);
// # team chat overlay — opened from the team entry's chat row.
const [chatTeam, setChatTeam] = useState<string | null>(null);
const [railHidden, setRailHidden] = useState(false);
// Left-nav collapse (⌘B): when collapsed the sidebar leaves the grid so content reclaims the
// width; hovering the left edge peeks it back as a floating overlay. Persisted per-device.
@@ -1043,10 +1046,10 @@ export function App() {
sessionRef.current?.respondPlan(approved, mode, feedback);
if (approved && mode) setMode(mode); // the server flips the live engine to this mode
};
const respondTeam = (approved: boolean, feedback?: string) => {
const respondTeam = (approved: boolean, feedback?: string, enableChat?: boolean) => {
setItems((p) => resolveLastTeam(p, approved ? "approved" : "rejected"));
dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item
sessionRef.current?.respondTeam(approved, feedback);
sessionRef.current?.respondTeam(approved, feedback, enableChat);
};
const respondItemsReq = (approved: boolean, feedback?: string) => {
setItems((p) => resolveLastItemsReq(p, approved ? "approved" : "rejected"));
@@ -1599,6 +1602,7 @@ export function App() {
sessions={sessions}
projects={projects}
activeSession={sessionId}
onOpenTeamChat={(teamId) => setChatTeam(teamId)}
onSwitchAgent={switchAgent}
onNewSession={startNewSession}
onSelectSession={selectSession}
@@ -2006,6 +2010,7 @@ export function App() {
{boardOpen && board && board.space && (
<BoardOverlay board={board} onClose={() => setBoardOpen(false)} onTransition={moveBoardItem} />
)}
{chatTeam && <TeamChatView teamId={chatTeam} onClose={() => setChatTeam(null)} />}
</div>
</div>
)}
+32 -1
View File
@@ -253,6 +253,36 @@ export async function boardTransition(
return res.json();
}
export interface ChatMessage {
seq: number;
ts: string;
author: string;
author_role: "user" | "lead" | "worker" | string;
text: string;
mentions: string[];
}
export interface TeamChat {
enabled: boolean;
team_id?: string;
members: { name: string; persona: string; role: string }[];
messages: ChatMessage[];
}
export async function getTeamChat(teamId: string): Promise<TeamChat> {
const res = await fetch(`${httpBase()}/v1/teams/${encodeURIComponent(teamId)}/chat`);
return res.json();
}
export async function postTeamChat(teamId: string, text: string): Promise<ChatMessage | { error: string }> {
const res = await fetch(`${httpBase()}/v1/teams/${encodeURIComponent(teamId)}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
return res.json();
}
export async function getJournalCases(): Promise<JournalCase[]> {
const res = await fetch(`${httpBase()}/v1/teams/journal`);
return (await res.json()).cases ?? [];
@@ -2189,11 +2219,12 @@ export class Session {
});
}
respondTeam(approved: boolean, feedback?: string) {
respondTeam(approved: boolean, feedback?: string, enableChat?: boolean) {
this.send({
type: "team_response",
approved,
...(feedback ? { feedback } : {}),
...(enableChat !== undefined ? { enable_chat: enableChat } : {}),
});
}
+15
View File
@@ -121,6 +121,8 @@ interface Props {
onSwitchAgent: (agent: string) => void;
onNewSession: (agent: string) => void;
onSelectSession: (id: string, workspace: string, agent: string) => void;
// Agent teams: opens the team's # team chat view (the row under the expandable entry).
onOpenTeamChat?: (teamId: string) => void;
onNewProject: (persona: string) => void;
onRenameSession: (id: string, title: string) => void;
onDeleteSession: (id: string) => void;
@@ -728,6 +730,19 @@ export function Sidebar(props: Props) {
<LiveDot state={w.liveness} />
</div>
))}
{s.team?.chat_enabled && props.onOpenTeamChat && (
<div
className="group flex items-center gap-2 px-2 py-1 rounded-lg cursor-pointer text-[12px] hover:bg-paper"
data-testid={`team-chat-row-${s.session_id}`}
onClick={() => props.onOpenTeamChat?.(s.team?.team_id || "")}
>
<span className="team-hash">#</span>
<span className="min-w-0 flex-1 truncate text-ink">team chat</span>
{(s.team?.chat_unread || 0) > 0 && (
<span className="team-chat-badge">{s.team?.chat_unread}</span>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,129 @@
// # team chat (agent teams, OPE-99): a minimal Slack-shaped exception channel over
// the session area — author-grouped messages, @mention highlighting, composer that
// posts as [User] (which wakes every member; agent posts wake mentions only).
// No derived board-event clusters (owner call, eighth pass): status lives on the
// board rail one click away — this surface is pure messages.
import { useEffect, useRef, useState } from "react";
import { getTeamChat, postTeamChat, type TeamChat } from "../api";
import { Icon } from "./Icon";
function mentionify(text: string, members: Set<string>) {
// Split on @word tokens; wrap known handles in a highlight span.
const parts = text.split(/(@[\w.-]+)/g);
return parts.map((part, i) =>
part.startsWith("@") && members.has(part.slice(1)) ? (
<span className="chat-mention" key={i}>
{part}
</span>
) : (
<span key={i}>{part}</span>
),
);
}
function clock(ts: string): string {
const d = new Date(ts);
return isNaN(d.getTime())
? ""
: d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
export function TeamChatView({ teamId, onClose }: { teamId: string; onClose: () => void }) {
const [chat, setChat] = useState<TeamChat | null>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const bottom = useRef<HTMLDivElement | null>(null);
const load = () => getTeamChat(teamId).then(setChat).catch(() => {});
useEffect(() => {
load();
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [teamId]);
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" });
}, [chat?.messages.length]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const send = async () => {
const text = draft.trim();
if (!text || busy) return;
setBusy(true);
try {
await postTeamChat(teamId, text);
setDraft("");
await load();
} finally {
setBusy(false);
}
};
const handles = new Set((chat?.members || []).map((m) => m.name));
const messages = chat?.messages || [];
return (
<div className="board-overlay" data-testid="teamchat-view" onClick={onClose}>
<div className="board-overlay-panel chat-panel" onClick={(e) => e.stopPropagation()}>
<div className="board-overlay-head">
<div className="board-overlay-title">
<span className="chat-hash">#</span>
<span>team chat</span>
<span className="board-overlay-space">questions &amp; consensus status lives on the board</span>
</div>
<button className="artifact-icon-btn" onClick={onClose} aria-label="Close chat" title="Close">
<Icon name="x" size={16} />
</button>
</div>
<div className="chat-scroll">
{messages.length === 0 && (
<div className="chat-empty">
No messages yet. Agents post here only when something needs a reply
@mention a coworker to reach it.
</div>
)}
{messages.map((m, i) => {
const grouped = i > 0 && messages[i - 1].author === m.author;
const label = m.author_role === "user" ? "You" : m.author;
return (
<div className={"chat-msg" + (grouped ? " grouped" : "")} key={m.seq}>
{!grouped && (
<div className="chat-who">
<span className={"chat-avatar " + m.author_role}>
{label.slice(0, 1).toUpperCase()}
</span>
<span className="chat-name">{label}</span>
<span className="chat-role">{m.author_role === "user" ? "" : m.author_role}</span>
<span className="chat-ts">{clock(m.ts)}</span>
</div>
)}
<div className="chat-text">{mentionify(m.text, handles)}</div>
</div>
);
})}
<div ref={bottom} />
</div>
<div className="chat-composer">
<input
className="chat-input"
data-testid="chat-input"
placeholder="Message # team chat… (posts as you — every member sees it)"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") send();
}}
/>
<button className="btn primary" data-testid="chat-send" disabled={busy || !draft.trim()} onClick={send}>
Send
</button>
</div>
</div>
</div>
);
}
@@ -1,7 +1,9 @@
// The staffing gate (agent teams, UX-030): a lead proposes its worker roster.
// Visible layer = the decisions (who, on what model, why); approving grants the lead
// create/assign/steer for this board — standing, revocable — and PRE-SPAWNS the
// worker sessions. No in-card reply surface: editing happens by replying.
// Visible layer = the decisions (who — by callname — on what model, why); approving
// grants the lead create/assign/steer for this board — standing, revocable — and
// PRE-SPAWNS the worker sessions. The chat checkbox is the USER's call (default
// OFF, ⓘ per mock); no in-card reply surface: editing happens by replying.
import { useState } from "react";
import type { Item } from "../types";
import { Icon } from "./Icon";
@@ -10,8 +12,9 @@ export function TeamRequestCard({
onRespond,
}: {
item: Extract<Item, { kind: "teamreq" }>;
onRespond: (approved: boolean, feedback?: string) => void;
onRespond: (approved: boolean, feedback?: string, enableChat?: boolean) => void;
}) {
const [chat, setChat] = useState(!!item.enable_chat);
return (
<div className="dirreq-card teamreq-card" data-testid="teamreq-card">
<div className="teamreq-head">
@@ -25,12 +28,31 @@ export function TeamRequestCard({
<div className="teamreq-row" key={i}>
<span className="teamreq-diamond"></span>
<span className="teamreq-body">
{m.name && <b className="teamreq-name">{m.name}</b>}
{m.name ? " — " : ""}
<code>{m.persona}</code>
{m.model && <span className="teamreq-model"> · {m.model}</span>}
{m.reason && <span className="teamreq-reason"> {m.reason}</span>}
</span>
</div>
))}
<label className="teamreq-chat">
<input
type="checkbox"
data-testid="teamreq-chat-toggle"
checked={chat}
onChange={(e) => setChat(e.target.checked)}
/>
<span>
Enable <b># team chat</b>
</span>
<span
className="teamreq-info"
title="A group channel for questions and consensus — @mentions wake the mentioned coworker. Status stays on the board either way."
>
i
</span>
</label>
<div className="dirreq-actions">
<span className="teamreq-grant">
Approving grants the lead create, assign &amp; steer this team only, revocable.
@@ -42,7 +64,7 @@ export function TeamRequestCard({
<button
className="btn primary"
data-testid="teamreq-approve"
onClick={() => onRespond(true)}
onClick={() => onRespond(true, undefined, chat)}
>
Create team &amp; start
</button>
+28
View File
@@ -1748,3 +1748,31 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.itemsreq-ac b { font-weight: 600; }
.itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; }
.itemsreq-grant { font-size: 11.5px; color: var(--faint); }
/* # team chat */
.teamreq-name { color: var(--ink); font-weight: 600; }
.teamreq-chat { display: flex; align-items: center; gap: 8px; padding: 9px 0 2px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--ink); cursor: pointer; }
.teamreq-info { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border: 1px solid var(--line-strong); border-radius: 50%; color: var(--faint); font-size: 9px; cursor: help; }
.chat-panel { max-width: 860px; }
.chat-hash { color: var(--faint); font-weight: 700; }
.chat-scroll { flex: 1; overflow: auto; padding: 14px 18px; display: flex; flex-direction: column; }
.chat-empty { color: var(--faint); font-size: 12.5px; margin: auto; max-width: 420px; text-align: center; }
.chat-msg { margin-top: 12px; }
.chat-msg.grouped { margin-top: 2px; padding-left: 34px; }
.chat-who { display: flex; align-items: baseline; gap: 8px; }
.chat-avatar { width: 26px; height: 26px; border-radius: 7px; background: var(--paper); border: 1px solid var(--line-strong); display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; align-self: center; }
.chat-avatar.lead { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.chat-avatar.user { background: var(--ok-soft); border-color: var(--ok-line); color: var(--ok); }
.chat-name { font-size: 12.5px; font-weight: 700; color: var(--ink); }
.chat-role { font-size: 11px; color: var(--faint); }
.chat-ts { font-size: 10.5px; color: var(--faint); }
.chat-text { font-size: 13px; color: var(--ink); margin-top: 1px; padding-left: 34px; }
.chat-msg.grouped .chat-text { padding-left: 0; }
.chat-mention { color: var(--accent); background: var(--accent-soft); border-radius: 4px; padding: 0 3px; }
.chat-composer { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); }
.chat-input { flex: 1; border: 1px solid var(--line); border-radius: 10px; padding: 9px 12px; font-size: 13px; background: var(--paper); color: var(--ink); outline: none; }
.chat-input:focus { border-color: var(--accent); }
/* Sidebar chat row */
.team-hash { color: var(--faint); font-weight: 700; font-size: 12px; width: 10px; text-align: center; }
.team-chat-badge { margin-left: auto; background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; border-radius: 8px; padding: 0 6px; }
+3 -1
View File
@@ -95,6 +95,8 @@ export interface SessionInfo {
actor?: string;
current_item?: string;
status?: string;
chat_enabled?: boolean;
chat_unread?: number;
};
}
@@ -160,7 +162,7 @@ export type Item =
| {
// The staffing gate (agent teams): a lead proposes its worker roster.
kind: "teamreq";
members: { persona: string; model?: string; reason?: string }[];
members: { persona: string; name?: string; model?: string; reason?: string }[];
enable_chat?: boolean;
note?: string;
resolved?: "approved" | "rejected";