mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-09 02:56:01 +00:00
# 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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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 & 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 & start
|
||||
</button>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user