mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): Mini App V6 — premium overhaul (#609)
* feat(tg): Mini App V6 — premium overhaul (design system, Chat parity, Metrics drilldown, CEO verbs) Design system: native type with tabular-numeral heroes (mono demoted to the wordmark), borderless elevated cards, floating dock, Telegram window-chrome painting via the theme bridge; Inbox moves behind a header bell with humanized notifications (UUIDs resolve to task names). Chat: honest Mine/Fleet split — participant-scoped CEO threads with real unread counts and mark-read, watched fleet threads with reply-as-CEO on task-linked conversations (watch-only otherwise), markdown transcripts, live pulse flashes, and a pinned Secretary live chat on the panel's SSE session runtime. Metrics: new tab with period-segmented spend hero, by-agent/team/model breakdowns, delivery + efficiency health, and a per-agent drilldown over usage time-series (agent_slug) + member scorecard. Board: tg-native grouped pipeline replacing the MobileTaskBoard wrapper; task sheet gains the CEO decide verbs (approve / request changes / unblock). Security: /api/dashboard router now require_panel_token-gated at router level (mirrors /api/usage), closing unauthenticated metrics exposure. * fix(tg): restore Share Tech Mono brand voice, Phosphor icon set, borderless avatars The mono returns as the numeral/brand voice (.tg-display — heroes, stat values, wordmark) while labels stay native sentence case. The hand-drawn duotone glyphs and lucide feature icons are replaced by Phosphor (MIT): duotone at rest via an IconContext at the shell, filled weight on the dock's active tab; row glyph maps (board statuses, inbox kinds, approval kinds, quick actions) all move over. Team avatar tiles drop their borders — tint-only squircles. * fix(tg): fleet avatar strip breathes — spaced tiles instead of overlap * polish(tg): taste-skill audit pass — em-dash purge, one icon family, separator rationing Applied the design-taste audit against the cockpit: every em-dash in visible UI copy rewritten (periods/commas/colons), the remaining lucide chrome (carets, arrows, send, close, spinners) moved to Phosphor so the tg tree ships one icon family (send is the native paper-plane, carets bold), the hand-rolled chevron SVG deleted, and metadata lines rationed to a single middle-dot separator. * polish(tg): pipeline chip strip scrolls without a visible scrollbar * fix(tests): metrics observability fixture uses a relative timestamp The hardcoded _T0 (2026-06-20) aged out of the service's 30-day window exactly 30 days later, detonating the suite on every branch. Two days back from now() stays inside every window (30d metrics, 7d scorecards) permanently. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TgChatTab } from "../tg-chat-tab";
|
||||
|
||||
const { mineItems, fleetItems, messages, sendMock, replyMock, markReadMock } =
|
||||
vi.hoisted(() => ({
|
||||
mineItems: { current: [] as Array<Record<string, unknown>> },
|
||||
fleetItems: { current: [] as Array<Record<string, unknown>> },
|
||||
messages: { current: [] as Array<Record<string, unknown>> },
|
||||
sendMock: vi.fn(),
|
||||
replyMock: vi.fn(),
|
||||
markReadMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
a2aLiveKeys: {
|
||||
all: ["a2a-live"],
|
||||
conversations: ["a2a-live", "conversations"],
|
||||
ceoConversations: ["a2a-live", "ceo-conversations"],
|
||||
pairs: ["a2a-live", "pairs"],
|
||||
messages: (id: string) => ["a2a-live", "messages", id],
|
||||
},
|
||||
useCeoConversations: () => ({
|
||||
data: { items: mineItems.current, total: mineItems.current.length },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useA2AConversations: () => ({
|
||||
data: { items: fleetItems.current, total: fleetItems.current.length },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useA2AMessages: () => ({
|
||||
data: {
|
||||
items: messages.current,
|
||||
total: messages.current.length,
|
||||
has_more: false,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
useSendCeoMessage: () => ({ mutate: sendMock, isPending: false }),
|
||||
useReplyAsCeo: () => ({ mutate: replyMock, isPending: false }),
|
||||
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useMarkConversationRead: () => ({ mutate: markReadMock, isPending: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-websocket", () => ({
|
||||
useA2ALiveStream: () => ({ lastMessage: null, isConnected: true }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useTasks: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("@/components/agents/agent-selector", () => ({
|
||||
AgentSelector: () => <div data-testid="agent-selector" />,
|
||||
}));
|
||||
vi.mock("@/components/a2a/a2a-new-dm-dialog", () => ({
|
||||
EXCLUDE_NON_DM_ROLES: [],
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TgChatTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mineRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: "c1",
|
||||
other_agent: "main-pm",
|
||||
topic: null,
|
||||
task_id: null,
|
||||
status: "active",
|
||||
message_count: 2,
|
||||
unread_count: 3,
|
||||
last_message_at: new Date().toISOString(),
|
||||
last_message_preview:
|
||||
"**Wave 2** shipped for 33333333-3333-4333-8333-333333333333",
|
||||
...over,
|
||||
});
|
||||
|
||||
const fleetRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: "f1",
|
||||
agent_a: "be-dev-1",
|
||||
agent_b: "be-qa",
|
||||
topic: "QA handoff",
|
||||
task_id: "t-1",
|
||||
status: "active",
|
||||
message_count: 5,
|
||||
last_message_at: new Date().toISOString(),
|
||||
last_message_preview: "Suite is green.",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
...over,
|
||||
});
|
||||
|
||||
const msg = (over: Record<string, unknown> = {}) => ({
|
||||
id: `m-${Math.random()}`,
|
||||
conversation_id: "c1",
|
||||
from_agent: "main-pm",
|
||||
content: "Hello **there**",
|
||||
message_kind: "text",
|
||||
response_to_id: null,
|
||||
requires_response: false,
|
||||
read_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
edited_at: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mineItems.current = [];
|
||||
fleetItems.current = [];
|
||||
messages.current = [];
|
||||
sendMock.mockReset();
|
||||
replyMock.mockReset();
|
||||
markReadMock.mockReset();
|
||||
});
|
||||
|
||||
describe("TgChatTab — list", () => {
|
||||
it("shows the CEO's own threads with unread badge and a groomed preview", () => {
|
||||
mineItems.current = [mineRow()];
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText("Main PM")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
// Markdown stripped, UUID shortened — never 36 raw chars.
|
||||
const preview = screen.getByText(/Wave 2 shipped for #33333333/);
|
||||
expect(preview.textContent).not.toContain("**");
|
||||
expect(preview.textContent).not.toContain("-3333-");
|
||||
});
|
||||
|
||||
it("Fleet scope lists agent↔agent threads and hides CEO pairs", async () => {
|
||||
fleetItems.current = [
|
||||
fleetRow(),
|
||||
fleetRow({ id: "f2", agent_a: "ceo", agent_b: "main-pm" }),
|
||||
];
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Fleet" }));
|
||||
expect(screen.getByText(/QA handoff/)).toBeInTheDocument();
|
||||
// The CEO pair is Mine-only — never duplicated into Fleet.
|
||||
expect(screen.queryByText(/Main PM/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TgChatTab — threads", () => {
|
||||
it("opens a Mine thread, renders agent markdown, clears unread", async () => {
|
||||
mineItems.current = [mineRow()];
|
||||
messages.current = [msg(), msg({ from_agent: "ceo", content: "Thanks" })];
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByText("Main PM"));
|
||||
expect(markReadMock).toHaveBeenCalledWith("c1");
|
||||
// Agent message renders markdown (bold survives as <strong>).
|
||||
expect(screen.getByText("there").tagName).toBe("STRONG");
|
||||
// CEO bubble is plain text.
|
||||
expect(screen.getByText("Thanks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends into a Mine thread via the plain CEO send", async () => {
|
||||
mineItems.current = [mineRow()];
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByText("Main PM"));
|
||||
await userEvent.type(screen.getByPlaceholderText("Message…"), "On it");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ conversationId: "c1", content: "On it" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("task-linked Fleet thread interjects via replyAsCeo with a recipient", async () => {
|
||||
fleetItems.current = [fleetRow()];
|
||||
messages.current = [msg({ conversation_id: "f1", from_agent: "be-dev-1" })];
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Fleet" }));
|
||||
await userEvent.click(screen.getByText(/QA handoff/));
|
||||
// Default recipient = last non-CEO sender.
|
||||
const chip = screen.getByRole("button", { name: /tap to switch/i });
|
||||
expect(chip.textContent).toContain("Backend Dev 1");
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("Message…"), "Status?");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(replyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "f1",
|
||||
to_agent: "be-dev-1",
|
||||
content: "Status?",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("Fleet thread without a task link is watch-only", async () => {
|
||||
fleetItems.current = [fleetRow({ task_id: null })];
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Fleet" }));
|
||||
await userEvent.click(screen.getByText(/QA handoff/));
|
||||
|
||||
expect(screen.getByText(/Watch-only/)).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Message…")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user