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:
Renzo F
2026-07-20 17:29:22 +02:00
committed by GitHub
co-authored by Renn F
parent cd73ad6a74
commit 3c5ee46347
41 changed files with 4227 additions and 812 deletions
+1
View File
@@ -19,6 +19,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.19",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
+15
View File
@@ -20,6 +20,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.81.0(react@19.2.7))
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-alert-dialog':
specifier: ^1.1.19
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -692,6 +695,13 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@phosphor-icons/react@2.1.10':
resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
engines: {node: '>=10'}
peerDependencies:
react: '>= 16.8'
react-dom: '>= 16.8'
'@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -4373,6 +4383,11 @@ snapshots:
'@oxc-project/types@0.133.0': {}
'@phosphor-icons/react@2.1.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.5': {}
@@ -44,6 +44,14 @@ vi.mock("@/components/tg/tg-board-tab", () => ({
vi.mock("@/components/tg/tg-chat-tab", () => ({
TgChatTab: () => <div data-testid="tg-chat-tab" />,
}));
vi.mock("@/components/tg/tg-metrics-tab", () => ({
TgMetricsTab: () => <div data-testid="tg-metrics-tab" />,
}));
// The shell's bell badge count — stubbed so the bootstrap test needs no
// QueryClientProvider.
vi.mock("@/hooks/use-notifications", () => ({
useNotifications: () => ({ data: undefined }),
}));
import TelegramMiniAppPage from "../page";
@@ -140,9 +148,7 @@ describe("TelegramMiniAppPage — auth bootstrap", () => {
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
expect(
screen.queryByText(/open from telegram/i),
).not.toBeInTheDocument();
expect(screen.queryByText(/open from telegram/i)).not.toBeInTheDocument();
});
it("dev mock also engages when the CDN bridge loaded with empty initData", async () => {
+83 -24
View File
@@ -16,7 +16,18 @@ import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab";
import { TgInboxTab } from "@/components/tg/tg-inbox-tab";
import { TgBoardTab } from "@/components/tg/tg-board-tab";
import { TgChatTab } from "@/components/tg/tg-chat-tab";
import { Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import { TgMetricsTab } from "@/components/tg/tg-metrics-tab";
import { TgSubPage, TG_PRESS } from "@/components/tg/ui";
import { IconInbox } from "@/components/tg/tg-icons";
import { isTgDemoMode } from "@/lib/telegram/demo";
import { useNotifications } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
import { IconContext } from "@phosphor-icons/react";
import {
ArrowSquareOut,
CircleNotch,
Warning,
} from "@phosphor-icons/react";
type BootstrapState =
| { kind: "validating" }
@@ -47,13 +58,6 @@ function CenteredMessage({ children }: { children: React.ReactNode }) {
*/
export default function TelegramMiniAppPage() {
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
const [tab, setTab] = useState<TgTab>("today");
// Today's Ship action deep-focuses the release proposal in Approvals.
const [approvalsFocus, setApprovalsFocus] = useState<"release" | undefined>();
const navigate = (next: TgTab, intent?: "release") => {
setApprovalsFocus(next === "approvals" ? intent : undefined);
setTab(next);
};
useEffect(() => {
let cancelled = false;
@@ -112,7 +116,7 @@ export default function TelegramMiniAppPage() {
if (state.kind === "validating") {
return (
<CenteredMessage>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<CircleNotch weight="bold" className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Connecting</p>
</CenteredMessage>
);
@@ -121,7 +125,7 @@ export default function TelegramMiniAppPage() {
if (state.kind === "not_in_telegram") {
return (
<CenteredMessage>
<ExternalLink className="h-10 w-10 text-muted-foreground" />
<ArrowSquareOut weight="duotone" className="h-10 w-10 text-muted-foreground" />
<h1 className="text-lg font-semibold">Open from Telegram</h1>
<p className="text-sm text-muted-foreground">
This cockpit only runs inside Telegram. Open it from the bot&apos;s
@@ -134,7 +138,7 @@ export default function TelegramMiniAppPage() {
if (state.kind === "error") {
return (
<CenteredMessage>
<AlertTriangle className="h-10 w-10 text-destructive" />
<Warning weight="duotone" className="h-10 w-10 text-destructive" />
<h1 className="text-lg font-semibold">Couldn&apos;t sign in</h1>
<p className="text-sm text-muted-foreground">{state.message}</p>
</CenteredMessage>
@@ -143,19 +147,74 @@ export default function TelegramMiniAppPage() {
return (
<TgWebAppProvider webApp={state.webApp}>
<div className="p-3 pb-24">
{/* Keyed by tab so every switch replays the rise-in entrance. */}
<div key={tab} className="tg-tab-in">
{tab === "today" && <TgTodayTab onNavigate={navigate} />}
{tab === "approvals" && (
<TgApprovalsTab initialFocus={approvalsFocus} />
)}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
</div>
<TgTabBar active={tab} onChange={navigate} />
</div>
<CockpitShell />
</TgWebAppProvider>
);
}
/**
* The signed-in cockpit: brand header (wordmark + inbox bell), the active
* tab, and the floating dock. Lives below the auth gate so its data hooks
* only ever fire with a valid session.
*/
function CockpitShell() {
const [tab, setTab] = useState<TgTab>("today");
// Today's Ship action deep-focuses the release proposal in Approvals.
const [approvalsFocus, setApprovalsFocus] = useState<"release" | undefined>();
// Inbox is a pushed sub-page behind the header bell, not a tab.
const [inboxOpen, setInboxOpen] = useState(false);
const navigate = (next: TgTab, intent?: "release") => {
setApprovalsFocus(next === "approvals" ? intent : undefined);
setInboxOpen(false);
setTab(next);
};
const { data: notifications } = useNotifications();
// The live query doesn't run against fixtures, so demo shows a static
// badge rather than an empty bell.
const unread = isTgDemoMode() ? 3 : (notifications?.unread_count ?? 0);
return (
// Every Phosphor glyph inside the cockpit is duotone unless a wrapper
// pins an explicit weight (the dock's filled active state).
<IconContext.Provider value={{ weight: "duotone" }}>
<div className="p-3 pb-28">
<header className="flex items-center justify-between px-1 pb-3 pt-1">
<span className="tg-brand text-[13px] tracking-[0.3em] text-foreground">
ROBOCO<span className="tg-cursor text-primary">_</span>
</span>
<button
type="button"
aria-label={unread > 0 ? `Inbox, ${unread} unread` : "Inbox"}
onClick={() => setInboxOpen(true)}
className={cn(
"relative flex h-9 w-9 items-center justify-center rounded-full bg-card text-muted-foreground",
TG_PRESS,
)}
>
<IconInbox className="h-5 w-5" />
{unread > 0 && (
<span className="absolute right-1 top-1 flex h-2 w-2 rounded-full bg-primary" />
)}
</button>
</header>
{inboxOpen ? (
<TgSubPage title="Inbox" onBack={() => setInboxOpen(false)}>
<TgInboxTab />
</TgSubPage>
) : (
// Keyed by tab so every switch replays the rise-in entrance.
<div key={tab} className="tg-tab-in">
{tab === "today" && <TgTodayTab onNavigate={navigate} />}
{tab === "approvals" && (
<TgApprovalsTab initialFocus={approvalsFocus} />
)}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
{tab === "metrics" && <TgMetricsTab />}
</div>
)}
<TgTabBar active={tab} onChange={navigate} />
</div>
</IconContext.Provider>
);
}
+74 -22
View File
@@ -131,38 +131,57 @@
dashboard theme. Inside Telegram, the themeParams bridge inline-overrides
the SURFACE tokens (background/card/text/hint/border) to the user's own
Telegram palette, while the accent tokens stay RoboCo's: Telegram's
surfaces, RoboCo's voice. */
surfaces, RoboCo's voice. Cards read as elevation (surface contrast +
an inset top highlight), never as outlines — border is reserved for
hairline separators. */
#tg-shell {
color-scheme: dark;
--background: oklch(0.16 0.015 260);
--foreground: oklch(0.93 0.005 260);
--card: oklch(0.205 0.02 260);
--card-foreground: oklch(0.93 0.005 260);
--popover: oklch(0.205 0.02 260);
--popover-foreground: oklch(0.93 0.005 260);
/* Native type: SF on iOS, Roboto on Android — the webview's own voice,
which is what makes the surface feel like Telegram, not a website. */
font-family:
-apple-system, "SF Pro Text", system-ui, "Segoe UI", Roboto,
"Helvetica Neue", sans-serif;
--background: oklch(0.155 0.014 255);
--foreground: oklch(0.935 0.005 255);
--card: oklch(0.215 0.018 255);
--card-foreground: oklch(0.935 0.005 255);
--popover: oklch(0.215 0.018 255);
--popover-foreground: oklch(0.935 0.005 255);
--primary: oklch(0.8 0.13 78);
--primary-foreground: oklch(0.22 0.04 78);
--secondary: oklch(0.26 0.02 260);
--secondary-foreground: oklch(0.93 0.005 260);
--muted: oklch(0.26 0.02 260);
--muted-foreground: oklch(0.68 0.012 260);
--accent: oklch(0.26 0.02 260);
--accent-foreground: oklch(0.93 0.005 260);
--secondary: oklch(0.27 0.02 255);
--secondary-foreground: oklch(0.935 0.005 255);
--muted: oklch(0.27 0.02 255);
--muted-foreground: oklch(0.67 0.014 255);
--accent: oklch(0.27 0.02 255);
--accent-foreground: oklch(0.935 0.005 255);
--destructive: oklch(0.68 0.19 25);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--border: oklch(1 0 0 / 7%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.8 0.13 78);
}
/* Cockpit brand typography + motion. `.tg-display` is the Share Tech Mono
display voice (labels, numerals, wordmark — never body text); the
animation classes are the Mini App's only motion vocabulary: rise-in for
tab/section entrances, slide-up for bottom sheets, a draw-in for the
spend sparkline. All animate transform/opacity only and collapse to
instant under prefers-reduced-motion via the global rule below. */
#tg-shell .tg-display {
/* Cockpit typography + motion. `.tg-brand` is the Share Tech Mono wordmark
voice — the ROBOCO_ mark only, never labels or numerals. `.tg-display`
is the numeral voice: the native face at a heavier weight with tabular
figures, so big figures read like a wallet balance instead of terminal
output. The animation classes are the Mini App's only motion vocabulary:
rise-in for tab/section entrances, slide-in for pushed sub-pages,
slide-up for bottom sheets, a draw-in for the spend sparkline, a one-shot
flash for live-updated rows. Everything animates transform/opacity (the
flash animates a background tint once) and collapses to instant under
prefers-reduced-motion via the global rule below. */
#tg-shell .tg-brand {
font-family: var(--font-share-tech), ui-monospace, "SF Mono", monospace;
}
#tg-shell .tg-display {
/* The Share Tech Mono brand voice — hero numerals, stat values, the
wordmark. Single-weight face: strip synthesized bolding so the mark
stays crisp. Labels and body copy stay on the native stack. */
font-family: var(--font-share-tech), ui-monospace, "SF Mono", monospace;
font-weight: 400;
letter-spacing: 0.01em;
}
#tg-shell .tg-tab-in {
animation: tg-rise 0.24s cubic-bezier(0.21, 0.61, 0.35, 1) backwards;
}
@@ -197,6 +216,39 @@
#tg-shell .tg-cursor {
animation: tg-blink 1.1s steps(1) infinite;
}
/* Horizontal chip strips scroll without showing a bar — the native
mobile pattern (content itself signals the overflow). */
#tg-shell .tg-scroll-x {
overflow-x: auto;
scrollbar-width: none;
}
#tg-shell .tg-scroll-x::-webkit-scrollbar {
display: none;
}
#tg-shell .tg-slide-in {
animation: tg-slide 0.28s cubic-bezier(0.32, 0.72, 0, 1) backwards;
}
#tg-shell .tg-flash {
animation: tg-flash 1.4s ease-out 1;
}
@keyframes tg-slide {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tg-flash {
0% {
background-color: color-mix(in oklab, var(--primary) 14%, transparent);
}
100% {
background-color: transparent;
}
}
@keyframes tg-rise {
from {
opacity: 0;
@@ -131,7 +131,7 @@ describe("TgApprovalsTab", () => {
renderTab();
await userEvent.click(await screen.findByText(/^x+$/));
expect(screen.getByText("281/280")).toBeInTheDocument();
expect(screen.getByText("281 / 280")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /post to x/i })).toBeDisabled();
});
@@ -166,7 +166,9 @@ describe("TgApprovalsTab", () => {
renderTab();
await userEvent.click(await screen.findByText("Shipped a thing."));
expect(screen.getByRole("button", { name: /post to x/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /post to x/i }),
).toBeInTheDocument();
// Outside Telegram there's no native BackButton — the visible fallback
// arrow renders instead.
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TgBoardTab } from "../tg-board-tab";
import { TaskStatus, Team } from "@/types";
const { tasks } = vi.hoisted(() => ({
tasks: { current: [] as Array<Record<string, unknown>> },
}));
vi.mock("@/hooks/use-tasks", () => ({
useTasks: () => ({ data: tasks.current, isLoading: false }),
}));
vi.mock("@/components/tg/tg-task-sheet", () => ({
TgTaskSheet: () => null,
}));
function task(overrides: Record<string, unknown>) {
return {
team: Team.BACKEND,
assigned_to: null,
updated_at: "2026-07-19T00:00:00Z",
...overrides,
};
}
describe("TgBoardTab", () => {
it("groups tasks by lifecycle stage and collapses done by default", async () => {
tasks.current = [
task({ id: "t1", title: "Blocked task", status: TaskStatus.BLOCKED }),
task({ id: "t2", title: "QA task", status: TaskStatus.AWAITING_QA }),
task({ id: "t3", title: "Flight task", status: TaskStatus.IN_PROGRESS }),
task({ id: "t4", title: "Queued task", status: TaskStatus.PENDING }),
task({
id: "t5",
title: "Done task A",
status: TaskStatus.COMPLETED,
updated_at: "2026-07-19T02:00:00Z",
}),
task({
id: "t6",
title: "Done task B",
status: TaskStatus.COMPLETED,
updated_at: "2026-07-19T01:00:00Z",
}),
task({
id: "t7",
title: "Cancelled task",
status: TaskStatus.CANCELLED,
updated_at: "2026-07-19T00:30:00Z",
}),
];
render(<TgBoardTab />);
// Every non-empty group renders its section + its task.
expect(screen.getByText("Needs you")).toBeInTheDocument();
expect(screen.getByText("Blocked task")).toBeInTheDocument();
expect(screen.getByText("In review")).toBeInTheDocument();
expect(screen.getByText("QA task")).toBeInTheDocument();
expect(screen.getByText("In flight")).toBeInTheDocument();
expect(screen.getByText("Flight task")).toBeInTheDocument();
expect(screen.getByText("Queued")).toBeInTheDocument();
expect(screen.getByText("Queued task")).toBeInTheDocument();
// Done is collapsed to a tally — no task titles rendered yet.
expect(screen.getByText("Done")).toBeInTheDocument();
expect(screen.getByText(/2 completed · 1 cancelled/)).toBeInTheDocument();
expect(screen.queryByText("Done task A")).not.toBeInTheDocument();
// Expanding reveals the recent terminal tasks.
await userEvent.click(screen.getByText(/2 completed · 1 cancelled/));
expect(screen.getByText("Done task A")).toBeInTheDocument();
expect(screen.getByText("Cancelled task")).toBeInTheDocument();
});
it("shows a friendly empty state with no tasks", () => {
tasks.current = [];
render(<TgBoardTab />);
expect(screen.getByText(/no tasks yet/i)).toBeInTheDocument();
});
});
@@ -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();
});
});
@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TgInboxTab } from "../tg-inbox-tab";
import { NotificationPriority, NotificationType } from "@/types";
const { ackMock, items } = vi.hoisted(() => ({
ackMock: vi.fn(),
items: { current: [] as Array<Record<string, unknown>> },
}));
// No task in the shared index — every UUID falls back to the #id8 handle.
vi.mock("@/hooks/use-tasks", () => ({
useTasks: () => ({ data: [] }),
}));
vi.mock("@/hooks/use-notifications", () => ({
notificationKeys: { all: ["notifications"] },
useNotifications: () => ({
data: { items: items.current },
isLoading: false,
}),
useAcknowledgeNotification: () => ({ mutate: ackMock, isPending: false }),
}));
function notification(overrides: Record<string, unknown>) {
return {
id: "n1",
type: NotificationType.BROADCAST,
priority: NotificationPriority.NORMAL,
from_agent: "main-pm",
to_agents: ["ceo"],
subject: "A notification",
body: "body",
requires_ack: false,
is_acknowledged: false,
is_fully_acknowledged: false,
is_read: false,
related_task_id: null,
related_message_ids: [],
timestamp: new Date().toISOString(),
expires_at: null,
acked_by: [],
acked_at: {},
...overrides,
};
}
function renderTab() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<TgInboxTab />
</QueryClientProvider>,
);
}
describe("TgInboxTab", () => {
it("humanizes an unresolved uuid subject to a short id handle", () => {
items.current = [
notification({
id: "n1",
subject: "Task 123e4567-e89b-12d3-a456-426614174000 needs review",
}),
];
renderTab();
expect(screen.getByText(/#123e4567/)).toBeInTheDocument();
});
it("splits a bracketed prefix into its own chip", () => {
items.current = [
notification({
id: "n1",
subject: "[strategy engine] weekly digest ready",
}),
];
renderTab();
expect(screen.getByText("Strategy engine")).toBeInTheDocument();
expect(screen.getByText("Weekly digest ready")).toBeInTheDocument();
});
it("shows inbox zero when there is nothing", () => {
items.current = [];
renderTab();
expect(screen.getByText(/inbox zero/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TgMetricsTab } from "../tg-metrics-tab";
vi.mock("@/lib/telegram/demo", () => ({ isTgDemoMode: () => true }));
// Scorecard resolution needs the roster only outside demo mode (the demo
// scorecard fixture returns unconditionally) — an empty roster keeps this
// hook off the network without affecting anything the tests assert on.
vi.mock("@/hooks/use-agents", () => ({ useAgents: () => ({ data: [] }) }));
function renderTab() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<TgMetricsTab />
</QueryClientProvider>,
);
}
describe("TgMetricsTab", () => {
it("renders every hub section from the demo fixtures", async () => {
renderTab();
// Hero total — the demo agent/team/model/series slices all sum to $66.54.
expect(await screen.findByText("$66.54")).toBeInTheDocument();
expect(screen.getByText("By agent")).toBeInTheDocument();
expect(screen.getByText("By team")).toBeInTheDocument();
expect(screen.getByText("By model")).toBeInTheDocument();
expect(screen.getByText("Delivery")).toBeInTheDocument();
expect(screen.getByText("Efficiency")).toBeInTheDocument();
// Top agent by cost (be-dev-1, $18.42) renders first with its real name.
expect(await screen.findByText("Backend Dev 1")).toBeInTheDocument();
// A team row's exact label (distinct from "Backend Dev 1" above).
expect(await screen.findByText("Backend")).toBeInTheDocument();
});
it("switches the selected period on the segmented control", async () => {
renderTab();
await screen.findByText("$66.54");
const oneWeek = screen.getByRole("button", { name: "1W" });
const oneMonth = screen.getByRole("button", { name: "1M" });
expect(oneWeek).toHaveAttribute("aria-pressed", "true");
expect(oneMonth).toHaveAttribute("aria-pressed", "false");
await userEvent.click(oneMonth);
expect(oneMonth).toHaveAttribute("aria-pressed", "true");
expect(oneWeek).toHaveAttribute("aria-pressed", "false");
});
it("pushes the agent drilldown when a by-agent row is tapped", async () => {
renderTab();
await screen.findByText("$66.54");
await userEvent.click(await screen.findByText("Backend Dev 1"));
expect(
await screen.findByRole("heading", { name: "Backend Dev 1" }),
).toBeInTheDocument();
expect(screen.getByText("be-dev-1")).toBeInTheDocument();
});
it("shows the drilled-in agent's scorecard from the demo fixture", async () => {
renderTab();
await screen.findByText("$66.54");
await userEvent.click(await screen.findByText("Backend Dev 1"));
expect(await screen.findByText("Scorecard")).toBeInTheDocument();
expect(screen.getByText("14")).toBeInTheDocument(); // tasks_completed
});
it("returns to the hub from the drilldown's back button", async () => {
renderTab();
await screen.findByText("$66.54");
await userEvent.click(await screen.findByText("Backend Dev 1"));
await screen.findByRole("heading", { name: "Backend Dev 1" });
await userEvent.click(screen.getByRole("button", { name: "Back" }));
expect(await screen.findByText("By agent")).toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Backend Dev 1" }),
).not.toBeInTheDocument();
});
});
@@ -3,20 +3,30 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { TgTabBar } from "../tg-tab-bar";
describe("TgTabBar", () => {
it("renders all 4 tabs and marks the active one with aria-current", () => {
render(<TgTabBar active="inbox" onChange={vi.fn()} />);
it("renders all 5 tabs and marks the active one with aria-current", () => {
render(<TgTabBar active="metrics" onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: /approvals/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /today/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /approvals/i }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /board/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /chat/i })).toBeInTheDocument();
const inbox = screen.getByRole("button", { name: /inbox/i });
expect(inbox).toHaveAttribute("aria-current", "page");
const metrics = screen.getByRole("button", { name: /metrics/i });
expect(metrics).toHaveAttribute("aria-current", "page");
expect(
screen.getByRole("button", { name: /approvals/i }),
).not.toHaveAttribute("aria-current");
});
it("does not render Inbox as a tab (it lives behind the header bell)", () => {
render(<TgTabBar active="today" onChange={vi.fn()} />);
expect(
screen.queryByRole("button", { name: /inbox/i }),
).not.toBeInTheDocument();
});
it("calls onChange with the tapped tab's id", () => {
const onChange = vi.fn();
render(<TgTabBar active="approvals" onChange={onChange} />);
@@ -24,7 +34,7 @@ describe("TgTabBar", () => {
fireEvent.click(screen.getByRole("button", { name: /chat/i }));
expect(onChange).toHaveBeenCalledWith("chat");
fireEvent.click(screen.getByRole("button", { name: /board/i }));
expect(onChange).toHaveBeenCalledWith("board");
fireEvent.click(screen.getByRole("button", { name: /metrics/i }));
expect(onChange).toHaveBeenCalledWith("metrics");
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render as rtlRender, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TgTaskSheet } from "../tg-task-sheet";
import type { Task } from "@/types";
import type { TaskFindingsResponse } from "@/lib/api/tasks";
@@ -11,8 +12,19 @@ const { findings } = vi.hoisted(() => ({
}));
vi.mock("@/hooks/use-tasks", () => ({
useTaskFindings: findings,
taskKeys: { all: ["tasks"] },
}));
// The sheet's CEO action block mutates through react-query.
function render(ui: React.ReactElement) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return rtlRender(
<QueryClientProvider client={client}>{ui}</QueryClientProvider>,
);
}
function task(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
@@ -132,4 +144,32 @@ describe("TgTaskSheet", () => {
expect(screen.getByText("roboco/services/queue.py:42")).toBeInTheDocument();
expect(screen.queryByText(/dlq\.py/)).not.toBeInTheDocument();
});
it("offers Approve / Request changes on an awaiting-CEO task", () => {
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Request changes" }),
).toBeInTheDocument();
});
it("offers Unblock on a blocked task and no CEO verbs elsewhere", () => {
render(
<TgTaskSheet
task={task({ status: "blocked" as Task["status"] })}
onClose={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Unblock" })).toBeInTheDocument();
render(
<TgTaskSheet
task={task({ id: "t2", status: "in_progress" as Task["status"] })}
onClose={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: "Approve" }),
).not.toBeInTheDocument();
});
});
@@ -142,7 +142,11 @@ describe("TgTodayTab", () => {
it("renders the operations ring and deep-links Ship into the release", async () => {
get.mockResolvedValue({
data: brief({
ship: { version: "0.25.0", open_release_proposal: true, ci_fix_tasks: 0 },
ship: {
version: "0.25.0",
open_release_proposal: true,
ci_fix_tasks: 0,
},
}),
});
const onNavigate = vi.fn();
@@ -3,7 +3,7 @@
import { useMainButton } from "@/lib/telegram/hooks";
import { useTgWebApp } from "@/lib/telegram/hooks";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { CircleNotch } from "@phosphor-icons/react";
/**
* The focused card's one primary action. Inside Telegram it drives the
@@ -26,12 +26,10 @@ export function PrimaryAction({
useMainButton({ text, visible: true, disabled, loading, onClick });
if (webApp?.MainButton) return null;
return (
<Button
className="w-full"
disabled={disabled || loading}
onClick={onClick}
>
{loading && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
<Button className="w-full" disabled={disabled || loading} onClick={onClick}>
{loading && (
<CircleNotch weight="bold" className="mr-1.5 h-4 w-4 animate-spin" />
)}
{text}
</Button>
);
@@ -6,8 +6,10 @@ import { releaseApi, type ReleaseProposal } from "@/lib/api/release";
import { getErrorMessage } from "@/lib/api/client";
import { haptics } from "@/lib/telegram/webapp";
import { Badge } from "@/components/ui/badge";
import { TgSection } from "@/components/tg/ui";
import { PrimaryAction } from "./primary-action";
import { RejectForm } from "./reject-form";
import { cn } from "@/lib/utils";
const MIN_REJECT_CHARS = 10;
@@ -70,59 +72,59 @@ export function ReleaseDetail({
<div className="flex flex-wrap items-center gap-1.5">
<Badge>v{report.proposed_version}</Badge>
<Badge variant="secondary">{report.bump_kind}</Badge>
<Badge
variant={report.gate_state === "green" ? "secondary" : "destructive"}
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
report.gate_state === "green"
? "bg-emerald-500/15 text-emerald-300"
: "bg-rose-500/15 text-rose-300",
)}
>
gate: {report.gate_state}
</Badge>
</span>
</div>
{inFlight && (
<p className="rounded-md bg-primary/10 p-2 text-xs">
<p className="rounded-xl bg-primary/10 p-2 text-xs text-primary">
Execute is running in the background (~40 min). This card updates
itself.
</p>
)}
{executeFailed && (
<p className="rounded-md bg-destructive/10 p-2 text-xs text-destructive">
<p className="rounded-xl bg-rose-500/10 p-2 text-xs text-rose-300">
Last execute failed ({proposal.execute_status})
{proposal.execute_detail ? `: ${proposal.execute_detail}` : ""}
</p>
)}
{report.gaps.length > 0 && (
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Gaps
</h3>
{report.gaps.map((gap, i) => (
<p key={i} className="text-xs text-muted-foreground">
[{gap.category}] {gap.detail}
</p>
))}
</div>
<TgSection title="Gaps">
<div className="space-y-1">
{report.gaps.map((gap, i) => (
<p key={i} className="text-xs text-muted-foreground">
[{gap.category}] {gap.detail}
</p>
))}
</div>
</TgSection>
)}
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Changelog draft
</h3>
<pre className="whitespace-pre-wrap rounded-md bg-muted p-2 text-xs leading-relaxed">
<TgSection title="Changelog draft">
<pre className="whitespace-pre-wrap rounded-xl bg-muted p-2 text-xs leading-relaxed">
{report.drafted_changelog}
</pre>
</div>
</TgSection>
{report.migration_notes.length > 0 && (
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Migrations
</h3>
{report.migration_notes.map((note, i) => (
<p key={i} className="text-xs">
{note}
</p>
))}
</div>
<TgSection title="Migrations">
<div className="space-y-1">
{report.migration_notes.map((note, i) => (
<p key={i} className="text-xs">
{note}
</p>
))}
</div>
</TgSection>
)}
<PrimaryAction
@@ -6,6 +6,7 @@ import { roadmapApi, type RoadmapItem } from "@/lib/api/roadmap";
import { getErrorMessage } from "@/lib/api/client";
import { haptics } from "@/lib/telegram/webapp";
import { Badge } from "@/components/ui/badge";
import { TgSection } from "@/components/tg/ui";
import { PrimaryAction } from "./primary-action";
import { RejectForm } from "./reject-form";
@@ -74,23 +75,17 @@ export function RoadmapItemDetail({
<p className="text-sm leading-relaxed">{item.description}</p>
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Why
</h3>
<TgSection title="Why">
<p className="text-xs text-muted-foreground">{item.rationale}</p>
</div>
</TgSection>
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Acceptance criteria
</h3>
<TgSection title="Acceptance criteria">
<ul className="list-disc space-y-0.5 pl-4 text-xs">
{item.acceptance_criteria.map((ac, i) => (
<li key={i}>{ac}</li>
))}
</ul>
</div>
</TgSection>
<PrimaryAction
text="Approve → backlog"
@@ -29,7 +29,9 @@ const CUT_LABELS: Record<VideoCut, string> = {
*/
function CutPlayer({ post }: { post: VideoPost }) {
const paths = post.mp4_paths ?? {};
const [cut, setCut] = useState<VideoCut>(paths.vertical ? "vertical" : "square");
const [cut, setCut] = useState<VideoCut>(
paths.vertical ? "vertical" : "square",
);
// url === null means the fetch for that cut failed; a stale entry for a
// different cut is simply ignored in render, so no synchronous state
// reset is needed when the cut changes.
@@ -87,7 +89,9 @@ function CutPlayer({ post }: { post: VideoPost }) {
playsInline
className={cn(
"w-full rounded-md bg-black",
cut === "vertical" ? "aspect-[9/16] max-h-[60dvh]" : "aspect-square",
cut === "vertical"
? "aspect-[9/16] max-h-[60dvh]"
: "aspect-square",
)}
/>
) : (
@@ -134,15 +138,15 @@ function CaptionEditor({
disabled={!editing}
onChange={(e) => onChange(e.target.value)}
rows={3}
className={cn("text-sm", editing && overLimit && "border-destructive")}
className={cn("text-sm", editing && overLimit && "border-rose-400/60")}
/>
<p
className={cn(
"text-right text-[11px] tabular-nums",
editing && overLimit ? "text-destructive" : "text-muted-foreground",
editing && overLimit ? "text-rose-400" : "text-muted-foreground",
)}
>
{caption.length}/{maxChars}
{caption.length} / {maxChars}
</p>
</div>
);
@@ -196,7 +200,8 @@ export function VideoPostDetail({
const reject = useMutation({
mutationFn: (reason: string) => videoApi.reject(post.task_id, reason),
onSuccess: () => finish(true, "Draft rejected — feedback goes back to the author."),
onSuccess: () =>
finish(true, "Draft rejected. Feedback goes back to the author."),
onError: (err) => {
haptics.error();
toast.error(getErrorMessage(err));
@@ -213,7 +218,9 @@ export function VideoPostDetail({
</Badge>
))}
{post.render_status === "failed" && (
<Badge variant="destructive">render failed</Badge>
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-xs font-medium text-rose-300">
render failed
</span>
)}
</div>
@@ -91,15 +91,15 @@ export function XPostDetail({
value={body}
onChange={(e) => setEdited(e.target.value)}
rows={5}
className={cn("text-sm", overLimit && "border-destructive")}
className={cn("text-sm", overLimit && "border-rose-400/60")}
/>
<p
className={cn(
"text-right text-[11px] tabular-nums",
overLimit ? "text-destructive" : "text-muted-foreground",
overLimit ? "text-rose-400" : "text-muted-foreground",
)}
>
{body.length}/{MAX_TWEET_CHARS}
{body.length} / {MAX_TWEET_CHARS}
</p>
</div>
+85
View File
@@ -58,6 +58,91 @@ export function Sparkline({ values }: { values: number[] }) {
);
}
const AREA_W = 340;
const AREA_H = 128;
/**
* Full-size area chart for drilldowns — the wallet asset-chart archetype:
* a clean line + gradient fill with the series' min/max annotated on the
* right edge and start/end captions underneath. No axes, no grid; the two
* extremes plus the endpoints are the honest summary a phone needs.
* `values` oldest → newest; `format` renders the min/max annotations.
*/
export function TgAreaChart({
values,
format = (v: number) => v.toFixed(2),
startLabel,
endLabel,
}: {
values: number[];
format?: (v: number) => string;
startLabel?: string;
endLabel?: string;
}) {
const n = values.length;
const max = Math.max(...values, 0);
const min = Math.min(...values, 0);
const span = max - min || 1;
const gradId = "tg-area-grad";
const x = (i: number) => (n <= 1 ? 0 : (i / (n - 1)) * AREA_W);
const y = (v: number) => AREA_H - 8 - ((v - min) / span) * (AREA_H - 16);
const points = values.map((v, i) => [x(i), y(v)] as const);
const line = points.map(([px, py]) => `${px},${py}`).join(" ");
const area = `M0,${AREA_H} L${line.replace(/ /g, " L")} L${AREA_W},${AREA_H} Z`;
const [lastX, lastY] = points[points.length - 1] ?? [AREA_W, AREA_H / 2];
return (
<div>
<div className="relative">
<svg
viewBox={`0 0 ${AREA_W} ${AREA_H}`}
preserveAspectRatio="none"
className="h-32 w-full overflow-visible text-primary"
aria-hidden="true"
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.26" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${gradId})`} className="tg-backdrop" />
<polyline
points={line}
pathLength={1}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="tg-draw-line"
/>
<circle cx={lastX} cy={lastY} r="4" fill="currentColor" />
</svg>
{max > min && (
<>
<span className="absolute right-0 top-0 text-[10px] tabular-nums text-muted-foreground/70">
{format(max)}
</span>
<span className="absolute bottom-0 right-0 text-[10px] tabular-nums text-muted-foreground/70">
{format(min)}
</span>
</>
)}
</div>
{(startLabel || endLabel) && (
<div className="mt-1.5 flex justify-between text-[10px] text-muted-foreground/60">
<span>{startLabel}</span>
<span>{endLabel}</span>
</div>
)}
</div>
);
}
/** Compact day bars — the last bar (today) emphasized in the accent, the
* rest muted. `values` oldest → newest. */
export function DayBars({
+5 -7
View File
@@ -8,7 +8,7 @@
*/
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { X } from "@phosphor-icons/react";
import { haptics } from "@/lib/telegram/webapp";
import { useBackButton } from "@/lib/telegram/hooks";
@@ -80,20 +80,18 @@ export function TgSheet({
<div
role="dialog"
aria-modal="true"
className="tg-sheet absolute inset-x-0 bottom-0 mx-auto flex max-h-[85dvh] w-full max-w-[430px] flex-col rounded-t-2xl border-t bg-card text-card-foreground shadow-2xl"
className="tg-sheet absolute inset-x-0 bottom-0 mx-auto flex max-h-[85dvh] w-full max-w-[430px] flex-col rounded-t-[28px] bg-card text-card-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_-16px_48px_-16px_rgba(0,0,0,0.9)]"
>
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-muted-foreground/30" />
<div className="mx-auto mt-2.5 h-1 w-9 shrink-0 rounded-full bg-muted-foreground/30" />
<header className="flex items-center justify-between gap-2 px-4 pb-2 pt-3">
<h2 className="tg-display text-[12px] uppercase tracking-[0.16em] text-muted-foreground">
{title}
</h2>
<h2 className="text-[15px] font-semibold text-foreground">{title}</h2>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground transition-transform active:scale-90"
>
<X className="h-4 w-4" />
<X weight="bold" className="h-4 w-4" />
</button>
</header>
<div className="overflow-y-auto px-4 pb-[calc(1.25rem+env(safe-area-inset-bottom))]">
+36 -34
View File
@@ -13,25 +13,26 @@ import { useBackButton, useTgWebApp } from "@/lib/telegram/hooks";
import { haptics } from "@/lib/telegram/webapp";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { TgRow, TgRowIcon } from "@/components/tg/ui";
import { TgRow, TgRowIcon, TG_CARD } from "@/components/tg/ui";
import { cn } from "@/lib/utils";
import { ArrowLeft } from "@phosphor-icons/react";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Clapperboard,
Map as MapIcon,
MessageCircle,
Rocket,
} from "lucide-react";
ChatCircle,
CheckCircle,
FilmSlate,
MapTrifold,
RocketLaunch,
Warning,
} from "@phosphor-icons/react";
const KIND_META: Record<
ApprovalItem["kind"],
{ label: string; icon: typeof Rocket; tone: string }
{ label: string; icon: typeof RocketLaunch; tone: string }
> = {
release: { label: "Release", icon: Rocket, tone: "amber" },
x_post: { label: "X post", icon: MessageCircle, tone: "sky" },
video_post: { label: "Video", icon: Clapperboard, tone: "violet" },
roadmap: { label: "Roadmap", icon: MapIcon, tone: "emerald" },
release: { label: "Release", icon: RocketLaunch, tone: "amber" },
x_post: { label: "X post", icon: ChatCircle, tone: "sky" },
video_post: { label: "Video", icon: FilmSlate, tone: "violet" },
roadmap: { label: "Roadmap", icon: MapTrifold, tone: "emerald" },
};
function itemTitle(item: ApprovalItem): string {
@@ -47,16 +48,10 @@ function itemTitle(item: ApprovalItem): string {
}
}
function ItemRow({
item,
onOpen,
}: {
item: ApprovalItem;
onOpen: () => void;
}) {
function ItemRow({ item, onOpen }: { item: ApprovalItem; onOpen: () => void }) {
const meta = KIND_META[item.kind];
return (
<div className="rounded-xl border bg-card text-card-foreground">
<div className={cn(TG_CARD, "px-2 py-1 text-card-foreground")}>
<TgRow
leading={<TgRowIcon icon={meta.icon} tone={meta.tone} />}
title={itemTitle(item)}
@@ -112,8 +107,7 @@ export function TgApprovalsTab({
initialFocus && !initialConsumed && focusedId === null
? items.find((i) => i.kind === initialFocus)
: undefined;
const focused =
items.find((i) => i.id === focusedId) ?? autoTarget ?? null;
const focused = items.find((i) => i.id === focusedId) ?? autoTarget ?? null;
const back = () => {
setInitialConsumed(true);
setFocusedId(null);
@@ -133,7 +127,7 @@ export function TgApprovalsTab({
if (anyFailed && items.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<AlertTriangle className="h-8 w-8 opacity-50" />
<Warning className="h-8 w-8 opacity-50" />
<p className="text-sm">Couldn&apos;t load the queues</p>
</div>
);
@@ -145,14 +139,19 @@ export function TgApprovalsTab({
<div className="space-y-3">
<div className="flex items-center gap-1.5">
{!webApp?.BackButton && (
<Button variant="ghost" size="sm" className="-ml-1.5 px-1.5" onClick={back}>
<Button
variant="ghost"
size="sm"
className="-ml-1.5 px-1.5"
onClick={back}
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<meta.icon className="h-3.5 w-3.5 text-muted-foreground" />
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-2.5 py-1 text-xs font-medium text-muted-foreground">
<meta.icon className="h-3.5 w-3.5" />
{meta.label}
</p>
</span>
</div>
<Detail item={focused} onDone={back} />
</div>
@@ -162,18 +161,21 @@ export function TgApprovalsTab({
if (items.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<CheckCircle2 className="h-8 w-8 opacity-50" />
<CheckCircle className="h-8 w-8 opacity-50" />
<p className="text-sm">Queue is clear</p>
</div>
);
}
return (
<div className="space-y-2">
<div className="tg-stagger space-y-2">
<p className="px-1 text-[13px] font-semibold text-foreground/90">
{items.length} waiting for you
</p>
{anyFailed && (
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<AlertTriangle className="h-3.5 w-3.5" />
Some queues couldn&apos;t load this list may be incomplete.
<p className="flex items-center gap-1.5 rounded-2xl bg-rose-500/10 px-3 py-2 text-xs text-rose-300">
<Warning className="h-3.5 w-3.5 shrink-0" />
Some queues didn&apos;t load, so this list may be incomplete.
</p>
)}
{items.map((item) => (
+273 -7
View File
@@ -1,14 +1,223 @@
"use client";
import { useEffect, useState } from "react";
import { MobileTaskBoard } from "@/components/tasks/mobile-task-board";
import { useEffect, useMemo, useState } from "react";
import { useTasks } from "@/hooks/use-tasks";
import { TgTaskSheet } from "@/components/tg/tg-task-sheet";
import { isTgDemoMode } from "@/lib/telegram/demo";
import type { Task } from "@/types";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { TaskStatus, type Task } from "@/types";
import { TG_CARD, TgRow, TgRowIcon, TgSection } from "@/components/tg/ui";
import { Skeleton } from "@/components/ui/skeleton";
import { CaretDown } from "@phosphor-icons/react";
import {
ArrowCounterClockwise,
CheckCircle,
Circle,
CircleDashed,
ClipboardText,
Crown,
FileText,
GitPullRequest,
Hourglass,
ListChecks,
PauseCircle,
UsersThree,
Warning,
XCircle,
} from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
/** Cockpit Board tab — the shared read-only board plus the tap-through
* task sheet (status, ACs, open findings, PR link). Demo mode swaps in the
* canned fixture list, lazily imported so it stays out of the prod bundle. */
type GroupKey = "needs_you" | "in_review" | "in_flight" | "queued" | "done";
/** Grouping order doubles as render order — the actionable half of the
* lifecycle first, the collapsed archive last. */
const GROUP_ORDER: GroupKey[] = [
"needs_you",
"in_review",
"in_flight",
"queued",
"done",
];
const GROUP_STATUSES: Record<GroupKey, TaskStatus[]> = {
needs_you: [TaskStatus.AWAITING_CEO_APPROVAL, TaskStatus.BLOCKED],
in_review: [
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
],
in_flight: [
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
TaskStatus.VERIFYING,
TaskStatus.NEEDS_REVISION,
TaskStatus.PAUSED,
],
queued: [TaskStatus.PENDING, TaskStatus.BACKLOG],
done: [TaskStatus.COMPLETED, TaskStatus.CANCELLED],
};
const GROUP_LABELS: Record<GroupKey, string> = {
needs_you: "Needs you",
in_review: "In review",
in_flight: "In flight",
queued: "Queued",
done: "Done",
};
const GROUP_CHIP_LABEL: Record<GroupKey, string> = {
needs_you: "needs you",
in_review: "in review",
in_flight: "in flight",
queued: "queued",
done: "done",
};
const GROUP_TONE: Record<
GroupKey,
"rose" | "violet" | "sky" | "muted" | "emerald"
> = {
needs_you: "rose",
in_review: "violet",
in_flight: "sky",
queued: "muted",
done: "emerald",
};
const STATUS_ICON: Partial<Record<TaskStatus, typeof Circle>> = {
[TaskStatus.BACKLOG]: ListChecks,
[TaskStatus.PENDING]: Hourglass,
[TaskStatus.CLAIMED]: Circle,
[TaskStatus.IN_PROGRESS]: CircleDashed,
[TaskStatus.BLOCKED]: Warning,
[TaskStatus.PAUSED]: PauseCircle,
[TaskStatus.VERIFYING]: ClipboardText,
[TaskStatus.NEEDS_REVISION]: ArrowCounterClockwise,
[TaskStatus.AWAITING_QA]: ClipboardText,
[TaskStatus.AWAITING_DOCUMENTATION]: FileText,
[TaskStatus.AWAITING_PR_REVIEW]: GitPullRequest,
[TaskStatus.AWAITING_PM_REVIEW]: UsersThree,
[TaskStatus.AWAITING_CEO_APPROVAL]: Crown,
[TaskStatus.COMPLETED]: CheckCircle,
[TaskStatus.CANCELLED]: XCircle,
};
function groupTasks(tasks: Task[]): Record<GroupKey, Task[]> {
const out: Record<GroupKey, Task[]> = {
needs_you: [],
in_review: [],
in_flight: [],
queued: [],
done: [],
};
for (const task of tasks) {
const key = GROUP_ORDER.find((g) =>
GROUP_STATUSES[g].includes(task.status),
);
if (key) out[key].push(task);
}
return out;
}
function PipelineHeader({ groups }: { groups: Record<GroupKey, Task[]> }) {
const chips = GROUP_ORDER.filter((k) => groups[k].length > 0);
return (
<div className={cn(TG_CARD, "p-4")}>
<p className="text-[13px] font-semibold text-foreground/90">Pipeline</p>
<div className="tg-scroll-x mt-2 flex gap-1.5">
{chips.map((k) => (
<span
key={k}
className="shrink-0 rounded-full bg-muted px-2.5 py-1 text-xs font-medium tabular-nums text-muted-foreground"
>
{groups[k].length} {GROUP_CHIP_LABEL[k]}
</span>
))}
</div>
</div>
);
}
function TaskRow({
task,
tone,
onOpen,
}: {
task: Task;
tone: string;
onOpen: (task: Task) => void;
}) {
const Icon = STATUS_ICON[task.status] ?? Circle;
const assignee = task.assigned_to
? getAgentDisplayName(task.assigned_to)
: "Unassigned";
return (
<TgRow
leading={<TgRowIcon icon={Icon} tone={tone} />}
title={task.title}
meta={`${assignee} · ${task.team}`}
onPress={() => onOpen(task)}
/>
);
}
/** Done is collapsed by default — a completed/cancelled tally that expands
* into the 20 most recently updated, rather than every terminal task ever. */
function DoneSection({
tasks,
onOpen,
}: {
tasks: Task[];
onOpen: (task: Task) => void;
}) {
const [expanded, setExpanded] = useState(false);
const completed = tasks.filter(
(t) => t.status === TaskStatus.COMPLETED,
).length;
const cancelled = tasks.filter(
(t) => t.status === TaskStatus.CANCELLED,
).length;
const recent = useMemo(
() =>
[...tasks]
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
.slice(0, 20),
[tasks],
);
return (
<TgSection title={GROUP_LABELS.done}>
{!expanded ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex min-h-11 w-full items-center justify-between text-sm text-muted-foreground"
>
<span>
{completed} completed · {cancelled} cancelled
</span>
<CaretDown weight="bold" className="h-4 w-4" />
</button>
) : (
<div className="divide-y divide-white/[0.04]">
{recent.map((task) => (
<TaskRow
key={task.id}
task={task}
tone={GROUP_TONE.done}
onOpen={onOpen}
/>
))}
</div>
)}
</TgSection>
);
}
/** Cockpit Board tab — every task grouped by lifecycle stage, tapping any
* row opens the read-only task sheet. Demo mode swaps in the canned
* fixture list, lazily imported so it stays out of the prod bundle. */
export function TgBoardTab() {
const [selected, setSelected] = useState<Task | null>(null);
const [demoTasks, setDemoTasks] = useState<Task[] | undefined>(undefined);
@@ -20,9 +229,66 @@ export function TgBoardTab() {
);
}, []);
const { data: fetched, isLoading } = useTasks({ limit: 200 });
const tasks = useMemo(() => demoTasks ?? fetched ?? [], [demoTasks, fetched]);
const groups = useMemo(() => groupTasks(tasks), [tasks]);
if (isLoading && demoTasks === undefined && !isTgDemoMode()) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
);
}
if (tasks.length === 0) {
return (
<div
className={cn(
TG_CARD,
"flex flex-col items-center gap-2 p-8 text-center text-muted-foreground",
)}
>
<ListChecks className="h-8 w-8 opacity-50" />
<p className="text-sm">No tasks yet</p>
</div>
);
}
return (
<>
<MobileTaskBoard tasks={demoTasks} onTaskPress={setSelected} />
<div className="tg-stagger space-y-3">
<PipelineHeader groups={groups} />
{GROUP_ORDER.filter((k) => k !== "done" && groups[k].length > 0).map(
(k) => (
<TgSection
key={k}
title={GROUP_LABELS[k]}
trailing={
<span className="text-[11px] tabular-nums text-muted-foreground">
{groups[k].length}
</span>
}
>
<div className="divide-y divide-white/[0.04]">
{groups[k].map((task) => (
<TaskRow
key={task.id}
task={task}
tone={GROUP_TONE[k]}
onOpen={setSelected}
/>
))}
</div>
</TgSection>
),
)}
{groups.done.length > 0 && (
<DoneSection tasks={groups.done} onOpen={setSelected} />
)}
</div>
<TgTaskSheet task={selected} onClose={() => setSelected(null)} />
</>
);
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
"use client";
/**
* Content grooming for the cockpit the difference between a premium
* surface and a log viewer is that nothing raw ever reaches the screen:
* UUIDs become task names (or a short #id8), markdown noise is stripped
* from one-line previews, and figures render in compact wallet notation.
*/
import { useMemo } from "react";
import { useTasks } from "@/hooks/use-tasks";
const UUID_RE =
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
/** Replace every UUID in `text` via `resolve` (a task-name lookup); an
* unresolved id degrades to a short `#a1b2c3d4` handle, never 36 raw chars. */
export function humanizeIds(
text: string,
resolve?: (id: string) => string | undefined,
): string {
return text.replace(UUID_RE, (id) => {
const name = resolve?.(id.toLowerCase());
if (!name) return `#${id.slice(0, 8)}`;
return name.length > 48 ? `${name.slice(0, 47)}` : name;
});
}
/** Flatten a (possibly markdown) message body into one clean preview line. */
export function cleanPreview(
text: string,
resolve?: (id: string) => string | undefined,
): string {
const flat = text
.replace(/```[\s\S]*?```/g, " [code] ")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
// Inline emphasis pairs BEFORE structural prefixes — a leading "**bold"
// must lose its pair as a pair, or the opener gets eaten as a bullet
// marker and the closer survives mid-string.
.replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1")
.replace(/^[>#*\-\s]+/gm, "")
.replace(/\s+/g, " ")
.trim();
return humanizeIds(flat, resolve);
}
/** Compact dollar figure: $74.88 · $1.2k · $18k. */
export function fmtUsd(n: number): string {
if (!Number.isFinite(n)) return "$0";
if (Math.abs(n) >= 10_000) return `$${(n / 1000).toFixed(0)}k`;
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
return `$${n.toFixed(2)}`;
}
/** Compact token figure: 850 · 45.2k · 197.6M · 1.2B. */
export function fmtTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0";
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
return `${Math.round(n)}`;
}
/**
* Task-name lookup shared cockpit-wide. Rides the Board's own query (same
* key, same 200-task window) so it costs no extra request; ids outside the
* window simply stay #id8.
*/
export function useTaskNameIndex(): (id: string) => string | undefined {
const { data: tasks } = useTasks({ limit: 200 });
return useMemo(() => {
const index = new Map<string, string>();
for (const t of tasks ?? []) index.set(t.id.toLowerCase(), t.title);
return (id: string) => index.get(id);
}, [tasks]);
}
+49 -163
View File
@@ -1,175 +1,61 @@
/**
* RoboCo's own cockpit icon set hand-drawn duotone glyphs (a 45%-opacity
* body plus solid accents, everything currentColor) so the nav and action
* surfaces stop reading as stock-library linework. Utility chrome
* (chevrons, spinners, list-row glyphs) deliberately stays lucide; these
* cover the hero surfaces only.
* The cockpit's icon voice Phosphor (MIT), duotone at rest and filled
* when active, the weight language native mobile docks use. These wrappers
* pin the vocabulary per surface so no consumer picks weights ad hoc;
* utility chrome (chevrons, spinners, close) stays lucide.
*/
export type TgIconProps = { className?: string };
import {
BellSimple,
Broom,
ChartLineUp,
ChatCircleDots,
Checks,
Gauge,
Kanban,
Robot,
RocketLaunch,
SealCheck,
} from "@phosphor-icons/react";
function Svg({
className,
children,
}: TgIconProps & { children: React.ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
>
{children}
</svg>
);
export type TgIconProps = {
className?: string;
/** Active-state rendering (the dock's selected tab): filled, not duotone. */
filled?: boolean;
};
type PhosphorIcon = typeof BellSimple;
function wrap(Icon: PhosphorIcon) {
function TgIcon({ className, filled = false }: TgIconProps) {
return (
<Icon
className={className}
weight={filled ? "fill" : "duotone"}
aria-hidden="true"
/>
);
}
return TgIcon;
}
/** Speedometer — Today. */
export function IconToday({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
fillRule="evenodd"
opacity=".45"
d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 3.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Z"
/>
<rect
x="11.1"
y="5.2"
width="1.8"
height="7.2"
rx=".9"
transform="rotate(45 12 12)"
/>
<circle cx="12" cy="12" r="2" />
</Svg>
);
}
/** Octagon seal with a check — Approvals / approve actions. */
export function IconSeal({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M8.2 2.6h7.6a1 1 0 0 1 .7.3l4.6 4.6a1 1 0 0 1 .3.7v7.6a1 1 0 0 1-.3.7l-4.6 4.6a1 1 0 0 1-.7.3H8.2a1 1 0 0 1-.7-.3l-4.6-4.6a1 1 0 0 1-.3-.7V8.2a1 1 0 0 1 .3-.7l4.6-4.6a1 1 0 0 1 .7-.3Z"
/>
<path
d="m8.4 12.2 2.4 2.4 4.8-5"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
);
}
/** Bell with an unread dot — Inbox. */
export function IconInbox({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M12 3.2a6 6 0 0 0-6 6v3l-1.5 2.6c-.4.7.1 1.6.9 1.6h13.2c.8 0 1.3-.9.9-1.6L18 12.2v-3a6 6 0 0 0-6-6Z"
/>
<path d="M9.7 18.6a2.4 2.4 0 0 0 4.6 0H9.7Z" />
<circle cx="17.8" cy="5.4" r="2.6" />
</Svg>
);
}
export const IconToday = wrap(Gauge);
/** Seal with a check — Approvals / approve actions. */
export const IconSeal = wrap(SealCheck);
/** Bell — Inbox (the header bell). */
export const IconInbox = wrap(BellSimple);
/** Kanban columns — Board. */
export function IconBoard({ className }: TgIconProps) {
return (
<Svg className={className}>
<rect x="3.4" y="4" width="4.8" height="12.5" rx="1.7" opacity=".45" />
<rect x="9.6" y="4" width="4.8" height="16" rx="1.7" />
<rect x="15.8" y="4" width="4.8" height="9" rx="1.7" opacity=".45" />
</Svg>
);
}
/** Speech bubble carrying the brand cursor — Chat. */
export function IconChat({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M4 6.5A3.5 3.5 0 0 1 7.5 3h9A3.5 3.5 0 0 1 20 6.5v6a3.5 3.5 0 0 1-3.5 3.5H9.8l-4.1 3.4c-.7.5-1.7 0-1.7-.9V6.5Z"
/>
<rect x="8.2" y="10.6" width="6.2" height="2.2" rx="1.1" />
</Svg>
);
}
export const IconBoard = wrap(Kanban);
/** Speech bubble — Chat. */
export const IconChat = wrap(ChatCircleDots);
/** Rising trend — Metrics. */
export const IconMetrics = wrap(ChartLineUp);
/** Rocket — Ship. */
export function IconShip({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M12 2.3c3 1.8 4.8 5 4.8 8.6 0 1.9-.4 3.7-1.2 5.2H8.4a11.7 11.7 0 0 1-1.2-5.2c0-3.6 1.8-6.8 4.8-8.6Z"
/>
<circle cx="12" cy="9.6" r="2" />
<path d="M10.1 17.4h3.8c.3 1.7-.4 3.4-1.9 4.8-1.5-1.4-2.2-3.1-1.9-4.8Z" />
</Svg>
);
}
export const IconShip = wrap(RocketLaunch);
/** Double check — Ack all. */
export function IconAckAll({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="m3 12.9 4 4 7.6-8.8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="m10.4 13.8 3.1 3.1 7.5-8.7"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
);
}
export const IconAckAll = wrap(Checks);
/** Broom — the stale-branch sweep. */
export function IconSweep({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
d="M19.4 3.6 13 10.8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
fill="none"
/>
<path
opacity=".45"
d="M12.1 10.4 15 13c-.9 3.1-3.3 5.7-7 7-1.5.5-3.2-.1-3.9-1.6l-1-2c3.7-.5 6.8-2.5 9-6Z"
/>
</Svg>
);
}
export const IconSweep = wrap(Broom);
/** Robot head — the fleet. */
export function IconFleet({ className }: TgIconProps) {
return (
<Svg className={className}>
<rect x="11.1" y="2" width="1.8" height="3.4" rx=".9" />
<rect x="4" y="6.2" width="16" height="13" rx="4.2" opacity=".45" />
<circle cx="9" cy="12.7" r="1.8" />
<circle cx="15" cy="12.7" r="1.8" />
</Svg>
);
}
export const IconFleet = wrap(Robot);
+206 -51
View File
@@ -1,77 +1,171 @@
"use client";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
useNotifications,
notificationKeys,
useAcknowledgeNotification,
useNotifications,
} from "@/hooks/use-notifications";
import { notificationsApi } from "@/lib/api/notifications";
import { isTgDemoMode } from "@/lib/telegram/demo";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import type { Notification } from "@/types";
import { haptics } from "@/lib/telegram/webapp";
import { humanizeIds, useTaskNameIndex } from "@/components/tg/tg-format";
import { NotificationType, type Notification } from "@/types";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { TgAvatar } from "@/components/tg/ui";
import { Bell, Check } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { TG_CARD, TgRowIcon, TgSection } from "@/components/tg/ui";
import {
ArrowsDownUp,
At,
Check,
CheckSquare,
ClipboardText,
Eye,
FileText,
Lightbulb,
Megaphone,
Warning,
WarningCircle,
} from "@phosphor-icons/react";
import { formatDistanceToNow, format, isToday, isYesterday } from "date-fns";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
function TgNotificationRow({ notification }: { notification: Notification }) {
const acknowledge = useAcknowledgeNotification();
const needsAck = notification.requires_ack && !notification.is_acknowledged;
/** Best-effort tone per notification type the real enum has no
* escalation/completion/system taxonomy, so this maps each real type onto
* the cockpit's 5-tone language (danger/decision/info/positive/ambient). */
const NOTIF_TONE: Record<NotificationType, string> = {
[NotificationType.TASK_ASSIGNMENT]: "sky",
[NotificationType.PRIORITY_CHANGE]: "sky",
[NotificationType.BLOCKER_ESCALATION]: "rose",
[NotificationType.REVIEW_REQUEST]: "violet",
[NotificationType.DOCUMENTATION_REQUEST]: "sky",
[NotificationType.APPROVAL]: "violet",
[NotificationType.ALERT]: "rose",
[NotificationType.BROADCAST]: "muted",
[NotificationType.KNOWLEDGE_SHARE]: "emerald",
[NotificationType.MENTION]: "sky",
};
const NOTIF_ICON: Record<NotificationType, typeof Check> = {
[NotificationType.TASK_ASSIGNMENT]: ClipboardText,
[NotificationType.PRIORITY_CHANGE]: ArrowsDownUp,
[NotificationType.BLOCKER_ESCALATION]: Warning,
[NotificationType.REVIEW_REQUEST]: Eye,
[NotificationType.DOCUMENTATION_REQUEST]: FileText,
[NotificationType.APPROVAL]: CheckSquare,
[NotificationType.ALERT]: WarningCircle,
[NotificationType.BROADCAST]: Megaphone,
[NotificationType.KNOWLEDGE_SHARE]: Lightbulb,
[NotificationType.MENTION]: At,
};
function sentenceCase(s: string): string {
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
}
/** Strips a leading `[something]` off a subject into its own chip e.g.
* a system-authored "[strategy engine] weekly digest ready" reads as a
* "Strategy engine" chip plus a clean sentence. */
function splitBracketPrefix(subject: string): {
chip: string | null;
text: string;
} {
const m = subject.match(/^\[([^\]]+)]\s*/);
if (!m) return { chip: null, text: subject };
return {
chip: sentenceCase(m[1]),
text: sentenceCase(subject.slice(m[0].length)),
};
}
function dayLabel(iso: string): string {
const d = new Date(iso);
if (isToday(d)) return "Today";
if (isYesterday(d)) return "Yesterday";
return format(d, "MMM d");
}
/** Groups by day, preserving the feed's own (newest-first) order so day
* buckets surface in the same order the items already arrive in. */
function groupByDay(items: Notification[]): Array<[string, Notification[]]> {
const groups = new Map<string, Notification[]>();
for (const n of items) {
const label = dayLabel(n.timestamp);
const bucket = groups.get(label);
if (bucket) bucket.push(n);
else groups.set(label, [n]);
}
return Array.from(groups.entries());
}
function NotificationRow({
notification,
resolveTask,
onAck,
ackPending,
}: {
notification: Notification;
resolveTask: (id: string) => string | undefined;
onAck: () => void;
ackPending: boolean;
}) {
const sender = getAgentDisplayName(notification.from_agent);
const { chip, text } = splitBracketPrefix(
humanizeIds(notification.subject, resolveTask),
);
const needsAck = notification.requires_ack && !notification.is_acknowledged;
const tone = NOTIF_TONE[notification.type] ?? "muted";
const Icon = NOTIF_ICON[notification.type] ?? Check;
return (
<div
className={cn(
"flex gap-3 rounded-2xl border bg-card p-3 text-card-foreground",
notification.is_read
? "opacity-70"
: "border-primary/25 bg-primary/[0.04]",
"flex min-h-12 w-full items-center gap-3 rounded-xl px-1.5 py-2",
!notification.is_read && "bg-primary/[0.04]",
)}
>
<TgAvatar name={sender} />
<TgRowIcon icon={Icon} tone={tone} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug">
{notification.subject}
</p>
{needsAck && (
<Button
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={acknowledge.isPending}
onClick={() =>
acknowledge.mutate(notification.id, {
onError: (err) => toast.error(getErrorMessage(err)),
})
}
>
<Check className="mr-1 h-3.5 w-3.5" />
Ack
</Button>
<p className="line-clamp-2 text-[15px] font-medium leading-snug">
{chip && (
<span className="mr-1.5 inline-flex items-center rounded-full bg-violet-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">
{chip}
</span>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{notification.body}
{text}
</p>
<p className="mt-1.5 text-[11px] text-muted-foreground">
<p className="mt-0.5 truncate text-xs leading-tight text-muted-foreground">
{sender} · {formatDistanceToNow(new Date(notification.timestamp))} ago
</p>
</div>
{needsAck && (
<button
type="button"
disabled={ackPending}
onClick={onAck}
className="flex h-7 shrink-0 items-center gap-1 rounded-full bg-primary px-2.5 text-xs font-semibold text-primary-foreground disabled:opacity-60"
>
<Check className="h-3.5 w-3.5" />
Ack
</button>
)}
</div>
);
}
/**
* Notification inbox for the /tg cockpit every notification, newest
* first, with an Ack button on the ones that require it. Polling rides
* useNotifications' own 30s refetchInterval; no extra wiring needed here.
* Demo mode renders the canned fixtures instead (the live query still
* mounts dev-only noise, same trade as the Board tab).
* Notification inbox for the /tg cockpit grouped by day, newest first,
* with an Ack pill on the ones that require it and a batched "Ack all".
* Renders inside the page shell's own `TgSubPage`, so this is content
* only no title/back-button chrome here. Polling rides useNotifications'
* own 30s refetchInterval. Demo mode renders the canned fixtures instead
* (the live query still mounts dev-only noise, same trade as the Board
* tab).
*/
export function TgInboxTab() {
const queryClient = useQueryClient();
const { data: fetched, isLoading: fetchLoading } = useNotifications();
const [demoItems, setDemoItems] = useState<Notification[] | undefined>(
undefined,
@@ -82,32 +176,93 @@ export function TgInboxTab() {
setDemoItems(m.DEMO_NOTIFICATIONS),
);
}, []);
const data = demoItems ? { items: demoItems } : fetched;
const resolveTask = useTaskNameIndex();
const ack = useAcknowledgeNotification();
const [ackAllBusy, setAckAllBusy] = useState(false);
const items = demoItems ?? fetched?.items ?? [];
const isLoading = demoItems ? false : fetchLoading;
const unreadCount = items.filter((n) => !n.is_read).length;
const pendingAcks = items.filter((n) => n.requires_ack && !n.is_acknowledged);
const runAckAll = async () => {
haptics.tap();
setAckAllBusy(true);
const results = await Promise.allSettled(
pendingAcks.map((n) => notificationsApi.acknowledge(n.id)),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
await queryClient.invalidateQueries({ queryKey: notificationKeys.all });
setAckAllBusy(false);
if (ok === results.length) {
haptics.success();
toast.success(`Acknowledged ${ok} notification${ok === 1 ? "" : "s"}`);
} else {
haptics.error();
toast.warning(`Acknowledged ${ok} of ${results.length}`);
}
};
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
<Skeleton key={i} className="h-14 w-full rounded-xl" />
))}
</div>
);
}
if (!data?.items.length) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<Bell className="h-8 w-8 opacity-50" />
<p className="text-sm">No notifications</p>
<div
className={cn(
TG_CARD,
"flex flex-col items-center gap-2 p-8 text-center text-muted-foreground",
)}
>
<TgRowIcon icon={Check} tone="emerald" />
<p className="text-sm">Inbox zero.</p>
</div>
);
}
return (
<div className="space-y-2">
{data.items.map((n) => (
<TgNotificationRow key={n.id} notification={n} />
<div className="tg-stagger space-y-3">
<div className="flex items-center justify-between px-1">
<span className="text-xs text-muted-foreground">
{unreadCount} unread
</span>
{pendingAcks.length > 0 && (
<button
type="button"
disabled={ackAllBusy}
onClick={() => void runAckAll()}
className="text-xs font-medium text-primary disabled:opacity-60"
>
{ackAllBusy ? "Acking…" : "Ack all"}
</button>
)}
</div>
{groupByDay(items).map(([day, dayItems]) => (
<TgSection key={day} title={day}>
<div className="divide-y divide-white/[0.04]">
{dayItems.map((n) => (
<NotificationRow
key={n.id}
notification={n}
resolveTask={resolveTask}
ackPending={ack.isPending}
onAck={() =>
ack.mutate(n.id, {
onError: () =>
toast.error("Couldn't acknowledge. Try again."),
})
}
/>
))}
</div>
</TgSection>
))}
</div>
);
+280
View File
@@ -0,0 +1,280 @@
/**
* Metrics tab demo fixtures (`/tg?demo=1`) tg-only, so a plain static
* import is fine here (unlike demo-data.ts, which every other tab dynamic-
* imports to keep it out of the prod bundle: this file is smaller and only
* ever pulled in by tg-metrics-tab.tsx, itself already tg-scoped).
*
* Every number below is organic (no round figures) but internally
* consistent: the by-agent, by-team, and by-model cost slices all sum to
* the same TOTAL_COST, which is also the 7-point spend series' total and
* the hero summary's total_cost_usd.
*/
import type {
AgentUsageRow,
CacheEfficiencyResponse,
MemberScorecard,
ModelUsageSlice,
ReworkReport,
SpawnWasteResponse,
StageTiming,
TeamUsageRow,
UsageProjection,
UsageSummary,
UsageTimePoint,
} from "@/types";
// Rough $/token rate matching the ratio the desktop mock data already uses
// (usage.ts's mockTimeSeries etc.), so demo token counts read as plausible
// alongside demo dollar figures.
const TOKENS_PER_DOLLAR = 1 / 0.00003;
function round1(n: number): number {
return Math.round(n * 10) / 10;
}
function tokensFor(cost_usd: number) {
const total_tokens = Math.round(cost_usd * TOKENS_PER_DOLLAR);
return {
tokens_input: Math.round(total_tokens * 0.55),
tokens_output: Math.round(total_tokens * 0.35),
total_tokens,
};
}
function agentRow(
agent_slug: string,
cost_usd: number,
total: number,
): AgentUsageRow {
return {
agent_slug,
...tokensFor(cost_usd),
cost_usd,
pct_of_total: round1((cost_usd / total) * 100),
};
}
function teamRow(team: string, cost_usd: number, total: number): TeamUsageRow {
return {
team,
...tokensFor(cost_usd),
cost_usd,
pct_of_total: round1((cost_usd / total) * 100),
};
}
function modelRow(
model: string,
cost_usd: number,
total: number,
): ModelUsageSlice {
return {
model,
...tokensFor(cost_usd),
cost_usd,
pct_of_total: round1((cost_usd / total) * 100),
};
}
const AGENT_COSTS: Array<[string, number]> = [
["be-dev-1", 18.42],
["fe-dev-2", 14.07],
["main-pm", 11.63],
["ux-dev-1", 9.28],
["be-qa", 7.51],
["fe-pm", 5.63],
];
/** Source of truth for every other slice's total same grand total sliced
* three different ways (agent / team / model), the way real spend is. */
const TOTAL_COST = AGENT_COSTS.reduce((sum, [, cost]) => sum + cost, 0);
export const DEMO_AGENT_USAGE: AgentUsageRow[] = AGENT_COSTS.map(
([slug, cost]) => agentRow(slug, cost, TOTAL_COST),
);
export const DEMO_TEAM_USAGE: TeamUsageRow[] = (
[
["backend", 24.1],
["frontend", 19.35],
["ux_ui", 13.86],
["main_pm", 9.23],
] as Array<[string, number]>
).map(([team, cost]) => teamRow(team, cost, TOTAL_COST));
export const DEMO_MODEL_USAGE: ModelUsageSlice[] = (
[
["claude-opus-4-6", 42.1],
["glm-5.2:cloud", 16.8],
["grok-build", 7.64],
] as Array<[string, number]>
).map(([model, cost]) => modelRow(model, cost, TOTAL_COST));
// 7 daily points, oldest -> newest, ending today — happens to sum to the
// same TOTAL_COST as the agent/team/model slices above.
const SERIES_COSTS = [4.12, 9.87, 6.4, 12.3, 8.05, 14.6, 11.2];
function daysAgoIso(daysBack: number): string {
const d = new Date();
d.setDate(d.getDate() - daysBack);
d.setHours(12, 0, 0, 0);
return d.toISOString();
}
export const DEMO_USAGE_SERIES: UsageTimePoint[] = SERIES_COSTS.map(
(cost_usd, i) => ({
bucket: daysAgoIso(SERIES_COSTS.length - 1 - i),
...tokensFor(cost_usd),
cost_usd,
}),
);
export const DEMO_USAGE_SUMMARY: UsageSummary = {
...tokensFor(TOTAL_COST),
total_cost_usd: TOTAL_COST,
trend_pct: 8.4,
period: "7d",
};
export const DEMO_DELIVERY: { rework: ReworkReport; cycle: StageTiming[] } = {
rework: {
rate: 11 / 48,
total_completed: 48,
total_reworked: 11,
by_team: [
{ team: "backend", rate: 0.18 },
{ team: "frontend", rate: 0.26 },
{ team: "ux_ui", rate: 0.15 },
],
by_agent: [
{
agent_slug: "fe-dev-2",
rate: 0.31,
qa_fails: 3,
pr_fails: 1,
pm_rejects: 1,
ceo_rejects: 0,
},
{
agent_slug: "be-dev-1",
rate: 0.19,
qa_fails: 2,
pr_fails: 1,
pm_rejects: 0,
ceo_rejects: 0,
},
{
agent_slug: "ux-dev-1",
rate: 0.22,
qa_fails: 1,
pr_fails: 1,
pm_rejects: 1,
ceo_rejects: 0,
},
{
agent_slug: "main-pm",
rate: 0.05,
qa_fails: 0,
pr_fails: 0,
pm_rejects: 1,
ceo_rejects: 0,
},
],
rework_cost_usd: 9.47,
},
cycle: [
{
status: "awaiting_pm_review",
avg_seconds: 44_640,
median_seconds: 39_600,
p90_seconds: 72_000,
sample_size: 22,
},
{
status: "in_progress",
avg_seconds: 30_960,
median_seconds: 27_000,
p90_seconds: 54_000,
sample_size: 48,
},
{
status: "awaiting_qa",
avg_seconds: 12_600,
median_seconds: 10_800,
p90_seconds: 21_600,
sample_size: 44,
},
{
status: "awaiting_ceo_approval",
avg_seconds: 9_000,
median_seconds: 7_200,
p90_seconds: 16_200,
sample_size: 9,
},
],
};
export const DEMO_EFFICIENCY: {
cache: CacheEfficiencyResponse;
projection: UsageProjection;
spawnWaste: SpawnWasteResponse;
} = {
cache: {
cache_hit_rate: 0.334,
tokens_cache_read: 812_000,
tokens_cache_write: 145_000,
tokens_input: 402_000,
cost_saved_by_cache_usd: 7.62,
period: "7d",
},
projection: {
total_cost_7d: TOTAL_COST,
avg_daily_cost_usd: round1(TOTAL_COST / 7),
projected_monthly_cost_usd: round1((TOTAL_COST / 7) * 30),
basis_days: 7,
},
spawnWaste: {
total_spawns: 132,
unproductive_spawns: 41,
unproductive_pct: 31.1,
by_role: [
{
role: "developer",
spawns: 58,
unproductive: 22,
unproductive_pct: 37.9,
},
{ role: "cell_pm", spawns: 34, unproductive: 11, unproductive_pct: 32.4 },
{ role: "qa", spawns: 21, unproductive: 5, unproductive_pct: 23.8 },
{ role: "main_pm", spawns: 19, unproductive: 3, unproductive_pct: 15.8 },
],
respawn_strikes: [],
period: "7d",
},
};
export const DEMO_MEMBER_SCORECARD: MemberScorecard = {
scope: "member",
id: "demo-be-dev-1",
name: "Backend Dev 1",
member_kind: "agent",
tasks_completed: 14,
first_pass_yield: 0.786,
effort_throughput_per_hour: 1.9,
active_runtime_hours: 38.4,
turns: 212,
tool_calls: 963,
tokens: 614_000,
cost_usd: 18.42,
turns_per_task: 15.1,
tool_calls_per_task: 68.8,
revisions_caused: 0,
revisions_received: 3,
qa_pass_rate: 0.786,
escalations: 1,
blocked_others: 0,
idle_hours: 6.2,
utilization: 0.612,
includes_live_inflight: false,
};
+642
View File
@@ -0,0 +1,642 @@
"use client";
/**
* Metrics tab the spend & delivery drilldown. A period segmented control
* drives a wallet-style hero (total spend + area chart) plus by-agent /
* by-team / by-model breakdowns and delivery/efficiency health; tapping an
* agent row pushes a per-agent drilldown sub-page.
*
* Every query branches on isTgDemoMode() inside its own queryFn (the same
* shape use-approval-queue.ts and tg-today-tab.tsx already use) so demo
* mode never touches the network no live backend needed to style this.
*/
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { Warning } from "@phosphor-icons/react";
import { usageApi, type UsagePeriod } from "@/lib/api/usage";
import { observabilityApi } from "@/lib/api/observability";
import { isScorecardMemberId } from "@/hooks/use-observability";
import { useAgents } from "@/hooks/use-agents";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { isTgDemoMode } from "@/lib/telegram/demo";
import { fmtUsd, fmtTokens } from "@/components/tg/tg-format";
import { TgAreaChart } from "@/components/tg/charts";
import {
TgAvatar,
TgRow,
TgSection,
TgSegmented,
TgStat,
TgDeltaChip,
TgSubPage,
TG_CARD,
} from "@/components/tg/ui";
import { haptics } from "@/lib/telegram/webapp";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type {
AgentUsageRow,
CacheEfficiencyResponse,
MemberScorecard,
ModelUsageSlice,
ReworkReport,
StageTiming,
SpawnWasteResponse,
TeamUsageRow,
UsageProjection,
UsageSummary,
UsageTimePoint,
} from "@/types";
import {
DEMO_AGENT_USAGE,
DEMO_DELIVERY,
DEMO_EFFICIENCY,
DEMO_MEMBER_SCORECARD,
DEMO_MODEL_USAGE,
DEMO_TEAM_USAGE,
DEMO_USAGE_SERIES,
DEMO_USAGE_SUMMARY,
} from "@/components/tg/tg-metrics-demo";
type Period = UsagePeriod;
type ViewState =
{ kind: "hub" } | { kind: "agent"; slug: string; row: AgentUsageRow };
const PERIOD_OPTIONS: ReadonlyArray<{ value: Period; label: string }> = [
{ value: "24h", label: "1D" },
{ value: "7d", label: "1W" },
{ value: "30d", label: "1M" },
{ value: "90d", label: "3M" },
];
const PERIOD_DAYS: Record<Period, number> = {
"24h": 1,
"7d": 7,
"30d": 30,
"90d": 90,
};
const REFRESH_MS = 60_000;
// =============================================================================
// QUERIES — one per data need, each demo-gated inside its own queryFn.
// =============================================================================
function useSummary(period: Period) {
return useQuery<UsageSummary>({
queryKey: ["tg-metrics", "summary", period],
queryFn: () =>
isTgDemoMode() ? DEMO_USAGE_SUMMARY : usageApi.getUsageSummary(period),
refetchInterval: REFRESH_MS,
});
}
function useSeries(period: Period, slug?: string) {
return useQuery<UsageTimePoint[]>({
queryKey: ["tg-metrics", "series", period, slug ?? null],
queryFn: () => {
if (!isTgDemoMode()) return usageApi.getUsageTimeSeries(period, slug);
if (!slug) return DEMO_USAGE_SERIES;
const row = DEMO_AGENT_USAGE.find((a) => a.agent_slug === slug);
const scale = (row?.pct_of_total ?? 0) / 100;
return DEMO_USAGE_SERIES.map((p) => ({
...p,
cost_usd: parseFloat((p.cost_usd * scale).toFixed(2)),
}));
},
refetchInterval: REFRESH_MS * 2,
});
}
function useAgentRows(period: Period) {
return useQuery<AgentUsageRow[]>({
queryKey: ["tg-metrics", "by-agent", period],
queryFn: () =>
isTgDemoMode() ? DEMO_AGENT_USAGE : usageApi.getAgentUsage(period),
refetchInterval: REFRESH_MS,
});
}
function useTeamRows(period: Period) {
return useQuery<TeamUsageRow[]>({
queryKey: ["tg-metrics", "by-team", period],
queryFn: () =>
isTgDemoMode() ? DEMO_TEAM_USAGE : usageApi.getTeamUsage(period),
refetchInterval: REFRESH_MS,
});
}
function useModelRows(period: Period) {
return useQuery<ModelUsageSlice[]>({
queryKey: ["tg-metrics", "by-model", period],
queryFn: () =>
isTgDemoMode() ? DEMO_MODEL_USAGE : usageApi.getModelUsage(period),
refetchInterval: REFRESH_MS,
});
}
function useDelivery(days: number) {
return useQuery<{ rework: ReworkReport; cycle: StageTiming[] }>({
queryKey: ["tg-metrics", "delivery", days],
queryFn: async () => {
if (isTgDemoMode()) return DEMO_DELIVERY;
const [rework, cycle] = await Promise.all([
observabilityApi.getRework(days),
observabilityApi.getCycleTime(days),
]);
return { rework, cycle };
},
refetchInterval: REFRESH_MS,
});
}
function useEfficiency(period: Period) {
return useQuery<{
cache: CacheEfficiencyResponse;
projection: UsageProjection;
spawnWaste: SpawnWasteResponse;
}>({
queryKey: ["tg-metrics", "efficiency", period],
queryFn: async () => {
if (isTgDemoMode()) return DEMO_EFFICIENCY;
const [cache, projection, spawnWaste] = await Promise.all([
usageApi.getCacheEfficiency(period),
usageApi.getUsageProjection(),
usageApi.getSpawnWaste(period),
]);
return { cache, projection, spawnWaste };
},
refetchInterval: REFRESH_MS * 2,
});
}
/** Member scorecard needs a real agent UUID (the endpoint's path param is
* UUID-typed) `agentId` is undefined until the roster resolves the tapped
* slug. Skips (returns null, never throws) on an unresolved/placeholder id
* so the Scorecard section just hides instead of erroring. */
function useAgentScorecard(agentId: string | undefined, days: number) {
return useQuery<MemberScorecard | null>({
queryKey: ["tg-metrics", "scorecard", agentId ?? null, days],
queryFn: () => {
if (isTgDemoMode()) return DEMO_MEMBER_SCORECARD;
if (!agentId || !isScorecardMemberId(agentId)) return null;
return observabilityApi.getMemberScorecard(agentId, days);
},
enabled: isTgDemoMode() || Boolean(agentId),
});
}
// =============================================================================
// FORMATTING HELPERS
// =============================================================================
function bucketLabel(period: Period, iso: string): string {
return format(new Date(iso), period === "24h" ? "HH:mm" : "MMM d");
}
function humanizeHours(seconds: number): string {
return `${(seconds / 3600).toFixed(1)}h`;
}
function humanizeStatus(status: string): string {
const spaced = status.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function teamLabel(team: string): string {
if (team === "ux_ui") return "UX/UI";
if (team === "main_pm") return "Main PM";
return team.charAt(0).toUpperCase() + team.slice(1);
}
function pctOrDash(v: number | null): string {
return v === null ? "-" : `${(v * 100).toFixed(0)}%`;
}
// =============================================================================
// SHARED SUBCOMPONENTS
// =============================================================================
function ErrorCard({ onRetry }: { onRetry: () => void }) {
return (
<div
className={cn(
TG_CARD,
"flex flex-col items-center gap-3 p-6 text-center",
)}
>
<Warning className="h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Couldn&apos;t load metrics. Pull to retry.
</p>
<Button size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
function SkeletonBlocks() {
return (
<div className="space-y-3">
<div className="h-40 animate-pulse rounded-[20px] bg-card" />
<div className="h-28 animate-pulse rounded-[20px] bg-card" />
<div className="h-28 animate-pulse rounded-[20px] bg-card" />
</div>
);
}
function ThinBarRow({
label,
pct,
trailing,
}: {
label: React.ReactNode;
pct: number;
trailing: React.ReactNode;
}) {
return (
<div className="flex items-center gap-3 py-1.5">
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium leading-snug">{label}</p>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
</div>
<span className="tg-display shrink-0 text-sm">{trailing}</span>
</div>
);
}
function SpendHero({
cost,
tokensCaption,
pct,
values,
startLabel,
endLabel,
deltaPct,
}: {
cost: number;
tokensCaption?: string;
pct?: string;
values: number[];
startLabel?: string;
endLabel?: string;
deltaPct?: number | null;
}) {
return (
<div className={cn(TG_CARD, "p-4")}>
<p className="text-[13px] text-muted-foreground">Spend</p>
<div className="mt-1 flex items-end justify-between gap-3">
<span className="tg-display text-[40px] leading-none">
{fmtUsd(cost)}
</span>
{deltaPct !== undefined && <TgDeltaChip pct={deltaPct} />}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{pct ?? tokensCaption}
</p>
<div className="mt-3">
<TgAreaChart
values={values}
format={(v) => fmtUsd(v)}
startLabel={startLabel}
endLabel={endLabel}
/>
</div>
{cost === 0 && (
<p className="mt-1 text-xs text-muted-foreground">
No spend yet this period
</p>
)}
</div>
);
}
// =============================================================================
// HUB
// =============================================================================
function Hub({
period,
onSelectAgent,
}: {
period: Period;
onSelectAgent: (row: AgentUsageRow) => void;
}) {
const summaryQ = useSummary(period);
const seriesQ = useSeries(period);
const agentsQ = useAgentRows(period);
const teamsQ = useTeamRows(period);
const modelsQ = useModelRows(period);
const days = PERIOD_DAYS[period];
const deliveryQ = useDelivery(days);
const efficiencyQ = useEfficiency(period);
if (summaryQ.isLoading || seriesQ.isLoading) return <SkeletonBlocks />;
if (summaryQ.isError || !summaryQ.data) {
return <ErrorCard onRetry={() => summaryQ.refetch()} />;
}
const summary = summaryQ.data;
const series = seriesQ.data ?? [];
const first = series[0];
const last = series[series.length - 1];
const agentRows = [...(agentsQ.data ?? [])].sort(
(a, b) => b.cost_usd - a.cost_usd,
);
const topAgents = agentRows.slice(0, 8);
const teamRows = teamsQ.data ?? [];
const modelRows = modelsQ.data ?? [];
const rework = deliveryQ.data?.rework;
const cycle = deliveryQ.data?.cycle ?? [];
const worstStage = [...cycle].sort(
(a, b) => b.avg_seconds - a.avg_seconds,
)[0];
const bounced = (rework?.by_agent ?? [])
.map((a) => ({
...a,
bounces: a.qa_fails + a.pr_fails + a.pm_rejects + a.ceo_rejects,
}))
.sort((a, b) => b.bounces - a.bounces)
.slice(0, 3);
const efficiency = efficiencyQ.data;
return (
<div className="tg-stagger space-y-3">
<SpendHero
cost={summary.total_cost_usd}
tokensCaption={`${fmtTokens(summary.total_tokens)} tokens`}
values={series.map((p) => p.cost_usd)}
startLabel={first ? bucketLabel(period, first.bucket) : undefined}
endLabel={last ? bucketLabel(period, last.bucket) : undefined}
deltaPct={summary.trend_pct}
/>
<TgSection title="By agent">
{topAgents.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
No agent spend yet.
</p>
) : (
<>
<div className="-mx-1.5 divide-y divide-border/50">
{topAgents.map((row) => (
<TgRow
key={row.agent_slug}
leading={<TgAvatar name={row.agent_slug} />}
title={getAgentDisplayName(row.agent_slug)}
meta={`${row.pct_of_total.toFixed(0)}% of spend`}
trailing={
<span className="tg-display text-sm">
{fmtUsd(row.cost_usd)}
</span>
}
onPress={() => {
haptics.tap();
onSelectAgent(row);
}}
/>
))}
</div>
{agentRows.length > 8 && (
<p className="mt-1 px-1.5 text-xs text-muted-foreground">
+{agentRows.length - 8} more
</p>
)}
</>
)}
</TgSection>
<TgSection title="By team">
{teamRows.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
No team spend yet.
</p>
) : (
teamRows.map((row) => (
<ThinBarRow
key={row.team}
label={teamLabel(row.team)}
pct={row.pct_of_total}
trailing={fmtUsd(row.cost_usd)}
/>
))
)}
</TgSection>
<TgSection title="By model">
{modelRows.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
No model spend yet.
</p>
) : (
modelRows.map((row) => (
<ThinBarRow
key={row.model}
label={<span className="truncate">{row.model}</span>}
pct={row.pct_of_total}
trailing={fmtUsd(row.cost_usd)}
/>
))
)}
</TgSection>
<TgSection title="Delivery">
<div className="grid grid-cols-2 gap-3">
<TgStat
value={`${((rework?.rate ?? 0) * 100).toFixed(0)}%`}
caption="Rework rate"
tone={(rework?.rate ?? 0) > 0.2 ? "attention" : "default"}
/>
<TgStat value={rework?.total_completed ?? 0} caption="Completed" />
<TgStat
value={worstStage ? humanizeHours(worstStage.avg_seconds) : "-"}
caption={
worstStage ? humanizeStatus(worstStage.status) : "Slowest stage"
}
/>
<TgStat
value={fmtUsd(rework?.rework_cost_usd ?? 0)}
caption="Rework cost"
/>
</div>
{bounced.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{bounced.map((a) => (
<span
key={a.agent_slug}
className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
>
{getAgentDisplayName(a.agent_slug)} · {a.bounces} bounces
</span>
))}
</div>
)}
</TgSection>
<TgSection title="Efficiency">
<div className="grid grid-cols-2 gap-3">
<TgStat
value={`${((efficiency?.cache.cache_hit_rate ?? 0) * 100).toFixed(0)}%`}
caption="Cache hit rate"
/>
<TgStat
value={fmtUsd(efficiency?.cache.cost_saved_by_cache_usd ?? 0)}
caption="Saved by cache"
/>
<TgStat
value={fmtUsd(
efficiency?.projection.projected_monthly_cost_usd ?? 0,
)}
caption="Projected monthly"
/>
<TgStat
value={`${(efficiency?.spawnWaste.unproductive_pct ?? 0).toFixed(0)}%`}
caption="Spawn waste"
tone={
(efficiency?.spawnWaste.unproductive_pct ?? 0) > 25
? "attention"
: "default"
}
/>
</div>
</TgSection>
</div>
);
}
// =============================================================================
// AGENT DRILLDOWN
// =============================================================================
function AgentDrilldown({
slug,
row,
period,
agentId,
onBack,
}: {
slug: string;
row: AgentUsageRow;
period: Period;
agentId: string | undefined;
onBack: () => void;
}) {
const seriesQ = useSeries(period, slug);
const days = PERIOD_DAYS[period];
const scorecardQ = useAgentScorecard(agentId, days);
const name = getAgentDisplayName(slug);
if (seriesQ.isLoading) {
return (
<TgSubPage title={name} subtitle={slug} onBack={onBack}>
<SkeletonBlocks />
</TgSubPage>
);
}
if (seriesQ.isError) {
return (
<TgSubPage title={name} subtitle={slug} onBack={onBack}>
<ErrorCard onRetry={() => seriesQ.refetch()} />
</TgSubPage>
);
}
const series = seriesQ.data ?? [];
const first = series[0];
const last = series[series.length - 1];
const scorecard = scorecardQ.data;
return (
<TgSubPage title={name} subtitle={slug} onBack={onBack}>
<div className="tg-stagger space-y-3">
<SpendHero
cost={row.cost_usd}
pct={`${row.pct_of_total.toFixed(0)}% of org spend`}
values={series.map((p) => p.cost_usd)}
startLabel={first ? bucketLabel(period, first.bucket) : undefined}
endLabel={last ? bucketLabel(period, last.bucket) : undefined}
/>
{scorecard && (
<TgSection title="Scorecard">
<div className="grid grid-cols-2 gap-3">
<TgStat
value={scorecard.tasks_completed}
caption="Tasks completed"
/>
<TgStat
value={pctOrDash(scorecard.first_pass_yield)}
caption="First-pass yield"
/>
<TgStat
value={pctOrDash(scorecard.utilization)}
caption="Utilization"
/>
<TgStat value={fmtTokens(scorecard.tokens)} caption="Tokens" />
<TgStat
value={scorecard.revisions_received}
caption="Revisions received"
/>
<TgStat value={scorecard.escalations} caption="Escalations" />
</div>
</TgSection>
)}
</div>
</TgSubPage>
);
}
// =============================================================================
// ROOT
// =============================================================================
export function TgMetricsTab() {
const [period, setPeriod] = useState<Period>("7d");
const [view, setView] = useState<ViewState>({ kind: "hub" });
const { data: agents } = useAgents();
const agentId = useMemo(() => {
if (view.kind !== "agent") return undefined;
return agents?.find((a) => a.agent_id === view.slug)?.id;
}, [agents, view]);
if (view.kind === "agent") {
return (
<AgentDrilldown
slug={view.slug}
row={view.row}
period={period}
agentId={agentId}
onBack={() => setView({ kind: "hub" })}
/>
);
}
return (
<div className="space-y-3">
<TgSegmented
options={PERIOD_OPTIONS}
value={period}
onChange={setPeriod}
/>
<Hub
period={period}
onSelectAgent={(row) =>
setView({ kind: "agent", slug: row.agent_slug, row })
}
/>
</div>
);
}
+32 -29
View File
@@ -3,14 +3,14 @@
import {
IconBoard,
IconChat,
IconInbox,
IconMetrics,
IconSeal,
IconToday,
type TgIconProps,
} from "@/components/tg/tg-icons";
import { cn } from "@/lib/utils";
export type TgTab = "today" | "approvals" | "inbox" | "board" | "chat";
export type TgTab = "today" | "approvals" | "board" | "chat" | "metrics";
const TABS: ReadonlyArray<{
id: TgTab;
@@ -19,9 +19,9 @@ const TABS: ReadonlyArray<{
}> = [
{ id: "today", label: "Today", icon: IconToday },
{ id: "approvals", label: "Approvals", icon: IconSeal },
{ id: "inbox", label: "Inbox", icon: IconInbox },
{ id: "board", label: "Board", icon: IconBoard },
{ id: "chat", label: "Chat", icon: IconChat },
{ id: "metrics", label: "Metrics", icon: IconMetrics },
];
interface TgTabBarProps {
@@ -30,41 +30,44 @@ interface TgTabBarProps {
}
/**
* The cockpit's own bottom nav 4 thumb-sized tabs, controlled by page
* state (not routes, unlike the dashboard's BottomTabBar) since the whole
* Mini App lives on the single `/tg` route.
* The cockpit's bottom nav a floating dock inset from the screen edges
* (the wallet pattern), controlled by page state (not routes, unlike the
* dashboard's BottomTabBar) since the whole Mini App lives on the single
* `/tg` route. Inbox is not a tab: it lives behind the header bell.
*/
export function TgTabBar({ active, onChange }: TgTabBarProps) {
return (
<nav
aria-label="Cockpit"
className="fixed inset-x-0 bottom-0 z-40 mx-auto flex w-full max-w-[430px] border-t bg-background/90 pb-[env(safe-area-inset-bottom)] backdrop-blur"
className="fixed inset-x-0 bottom-0 z-40 mx-auto w-full max-w-[430px] px-3 pb-[max(env(safe-area-inset-bottom),0.75rem)]"
>
{TABS.map((tab) => {
const isActive = active === tab.id;
return (
<button
key={tab.id}
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => onChange(tab.id)}
className={cn(
"tg-display flex flex-1 flex-col items-center gap-0.5 pb-2 pt-1.5 text-[9px] uppercase tracking-[0.08em] transition-colors",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
>
<span
<div className="flex rounded-[26px] bg-card/90 shadow-[inset_0_1px_0_rgba(255,255,255,0.05),0_16px_40px_-16px_rgba(0,0,0,0.8)] ring-1 ring-white/[0.06] backdrop-blur-xl">
{TABS.map((tab) => {
const isActive = active === tab.id;
return (
<button
key={tab.id}
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => onChange(tab.id)}
className={cn(
"flex h-7 w-12 items-center justify-center rounded-full transition-all duration-200 ease-out",
isActive ? "bg-primary/15" : "bg-transparent",
"flex flex-1 flex-col items-center gap-0.5 pb-2 pt-1.5 text-[10px] font-medium transition-colors duration-200",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
>
<tab.icon className="h-5 w-5" />
</span>
{tab.label}
</button>
);
})}
<span
className={cn(
"flex h-7 w-12 items-center justify-center rounded-full transition-all duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
isActive ? "bg-primary/12" : "bg-transparent",
)}
>
<tab.icon className="h-[22px] w-[22px]" filled={isActive} />
</span>
{tab.label}
</button>
);
})}
</div>
</nav>
);
}
+202 -28
View File
@@ -1,14 +1,24 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { TgSheet } from "@/components/tg/motion";
import { TaskStatusBadge } from "@/components/tasks/task-status-badge";
import { useTaskFindings } from "@/hooks/use-tasks";
import { TG_PRESS, TgSection } from "@/components/tg/ui";
import { taskKeys, useTaskFindings } from "@/hooks/use-tasks";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { isTgDemoMode } from "@/lib/telegram/demo";
import type { Task } from "@/types";
import type { TaskFinding } from "@/lib/api/tasks";
import { CheckCircle2, ExternalLink } from "lucide-react";
import { haptics } from "@/lib/telegram/webapp";
import { TaskStatus, type Task } from "@/types";
import { tasksApi, type TaskFinding } from "@/lib/api/tasks";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowSquareOut,
CheckCircle,
CircleNotch,
} from "@phosphor-icons/react";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const FINDINGS_SHOWN = 5;
@@ -20,6 +30,54 @@ const SEVERITY_DOT: Record<TaskFinding["severity"], string> = {
nit: "bg-muted-foreground/60",
};
/** The cockpit's 5-tone status language (needs-you rose, review violet,
* active sky, done emerald, queued/idle muted) a deliberately smaller
* palette than the desktop's per-status badge, so a status reads as "what
* kind of wait is this" at a glance. */
const STATUS_TONE: Record<TaskStatus, string> = {
[TaskStatus.BACKLOG]: "muted",
[TaskStatus.PENDING]: "muted",
[TaskStatus.CLAIMED]: "sky",
[TaskStatus.IN_PROGRESS]: "sky",
[TaskStatus.BLOCKED]: "rose",
[TaskStatus.PAUSED]: "muted",
[TaskStatus.VERIFYING]: "sky",
[TaskStatus.NEEDS_REVISION]: "rose",
[TaskStatus.AWAITING_QA]: "violet",
[TaskStatus.AWAITING_DOCUMENTATION]: "violet",
[TaskStatus.AWAITING_PR_REVIEW]: "violet",
[TaskStatus.AWAITING_PM_REVIEW]: "violet",
[TaskStatus.AWAITING_CEO_APPROVAL]: "violet",
[TaskStatus.COMPLETED]: "emerald",
[TaskStatus.CANCELLED]: "muted",
};
const TONE_CLASSES: Record<string, string> = {
emerald: "bg-emerald-500/15 text-emerald-300",
rose: "bg-rose-500/15 text-rose-300",
sky: "bg-sky-500/15 text-sky-300",
violet: "bg-violet-500/15 text-violet-300",
muted: "bg-muted/70 text-muted-foreground",
};
function sentenceCase(s: string): string {
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
}
function StatusPill({ status }: { status: TaskStatus }) {
const tone = STATUS_TONE[status] ?? "muted";
return (
<span
className={cn(
"rounded-full px-2.5 py-1 text-xs font-semibold",
TONE_CLASSES[tone],
)}
>
{sentenceCase(status.replace(/_/g, " "))}
</span>
);
}
function FindingRow({ finding }: { finding: TaskFinding }) {
return (
<li className="flex gap-2 py-1.5">
@@ -31,7 +89,7 @@ function FindingRow({ finding }: { finding: TaskFinding }) {
/>
<div className="min-w-0">
{finding.file && (
<p className="tg-display truncate text-xs">
<p className="truncate text-xs font-medium">
{finding.file}
{finding.line !== null && `:${finding.line}`}
</p>
@@ -44,12 +102,130 @@ function FindingRow({ finding }: { finding: TaskFinding }) {
);
}
const MIN_REJECT = 10;
/**
* Read-only task detail for the Board tab's tap-through: status, meta,
* description, acceptance criteria, the open revision findings, and the PR
* link. Mutations stay on the desktop panel the cockpit is a
* glance-and-decide surface, and the decide verbs already live in
* Approvals.
* The CEO verbs a phone actually needs, right where "Needs you" points:
* approve / request-changes on a task awaiting CEO approval, and unblock on
* a blocked one. Everything else stays a desktop concern.
*/
function CeoActions({ task, onActed }: { task: Task; onActed: () => void }) {
const demo = isTgDemoMode();
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState("");
const done = (verb: string) => {
haptics.success();
toast.success(verb);
void queryClient.invalidateQueries({ queryKey: taskKeys.all });
onActed();
};
const failed = (err: unknown) => {
haptics.error();
toast.error(getErrorMessage(err));
};
const approve = useMutation({
mutationFn: () => tasksApi.ceoApprove(task.id),
onSuccess: () => done("Approved"),
onError: failed,
});
const reject = useMutation({
mutationFn: () => tasksApi.ceoReject(task.id, reason.trim()),
onSuccess: () => done("Sent back for revision"),
onError: failed,
});
const unblock = useMutation({
mutationFn: () => tasksApi.unblock(task.id),
onSuccess: () => done("Unblocked"),
onError: failed,
});
const busy = approve.isPending || reject.isPending || unblock.isPending;
if (task.status === TaskStatus.BLOCKED) {
return (
<button
type="button"
disabled={demo || busy}
onClick={() => unblock.mutate()}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-full bg-primary py-3 text-[15px] font-semibold text-primary-foreground disabled:opacity-40",
TG_PRESS,
)}
>
{unblock.isPending && (
<CircleNotch weight="bold" className="h-4 w-4 animate-spin" />
)}
Unblock
</button>
);
}
if (task.status !== TaskStatus.AWAITING_CEO_APPROVAL) return null;
return (
<div className="space-y-2">
<button
type="button"
disabled={demo || busy}
onClick={() => approve.mutate()}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-full bg-primary py-3 text-[15px] font-semibold text-primary-foreground disabled:opacity-40",
TG_PRESS,
)}
>
{approve.isPending && (
<CircleNotch weight="bold" className="h-4 w-4 animate-spin" />
)}
Approve
</button>
{rejecting ? (
<div className="space-y-2">
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={`What needs to change? (at least ${MIN_REJECT} characters)`}
className="min-h-[80px] resize-none rounded-2xl border-0 bg-muted/50 shadow-none focus-visible:ring-0"
disabled={demo || busy}
/>
<button
type="button"
disabled={demo || busy || reason.trim().length < MIN_REJECT}
onClick={() => reject.mutate()}
className={cn(
"flex w-full items-center justify-center gap-2 rounded-full bg-rose-500/15 py-3 text-[15px] font-semibold text-rose-300 disabled:opacity-40",
TG_PRESS,
)}
>
{reject.isPending && (
<CircleNotch weight="bold" className="h-4 w-4 animate-spin" />
)}
Send back for revision
</button>
</div>
) : (
<button
type="button"
disabled={demo || busy}
onClick={() => setRejecting(true)}
className={cn(
"w-full rounded-full bg-muted/60 py-3 text-[15px] font-medium text-muted-foreground disabled:opacity-40",
TG_PRESS,
)}
>
Request changes
</button>
)}
</div>
);
}
/**
* Task detail for the Board tab's tap-through: status, meta, description,
* acceptance criteria, the open revision findings, the PR link and the
* CEO's own decide verbs (approve / request changes / unblock) when the
* task is waiting on exactly those.
*/
export function TgTaskSheet({
task,
@@ -70,9 +246,9 @@ export function TgTaskSheet({
<div className="space-y-4 pb-1">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<TaskStatusBadge status={task.status} />
<StatusPill status={task.status} />
{(task.revision_count ?? 0) > 0 && (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] font-medium tabular-nums text-amber-300">
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium tabular-nums text-primary">
bounced ×{task.revision_count}
</span>
)}
@@ -82,7 +258,6 @@ export function TgTaskSheet({
</h3>
<p className="text-[11px] text-muted-foreground">
{[
task.team,
getAgentDisplayName(task.assigned_to),
task.updated_at &&
`${formatDistanceToNow(new Date(task.updated_at))} ago`,
@@ -99,26 +274,20 @@ export function TgTaskSheet({
)}
{task.acceptance_criteria.length > 0 && (
<section>
<h4 className="tg-display mb-1.5 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Acceptance criteria
</h4>
<TgSection title="Acceptance criteria">
<ul className="space-y-1.5">
{task.acceptance_criteria.map((criterion, i) => (
<li key={i} className="flex gap-2 text-sm leading-snug">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/50" />
<li key={i} className="flex gap-2 text-sm leading-relaxed">
<CheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/50" />
<span>{criterion}</span>
</li>
))}
</ul>
</section>
</TgSection>
)}
{openFindings.length > 0 && (
<section>
<h4 className="tg-display mb-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Open findings · {openFindings.length}
</h4>
<TgSection title={`Open findings · ${openFindings.length}`}>
<ul className="divide-y">
{openFindings.slice(0, FINDINGS_SHOWN).map((f) => (
<FindingRow key={f.id} finding={f} />
@@ -130,7 +299,7 @@ export function TgTaskSheet({
panel
</p>
)}
</section>
</TgSection>
)}
{task.pr_url && (
@@ -138,12 +307,17 @@ export function TgTaskSheet({
href={task.pr_url}
target="_blank"
rel="noreferrer"
className="flex items-center justify-center gap-2 rounded-xl bg-muted py-2.5 text-sm font-medium transition-transform active:scale-[0.98]"
className={cn(
"flex items-center justify-center gap-2 rounded-xl bg-muted py-2.5 text-sm font-medium",
TG_PRESS,
)}
>
<ExternalLink className="h-4 w-4" />
<ArrowSquareOut className="h-4 w-4" />
Open PR{task.pr_number !== null && ` #${task.pr_number}`}
</a>
)}
<CeoActions task={task} onActed={onClose} />
</div>
)}
</TgSheet>
+89 -107
View File
@@ -5,10 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import api, { getErrorMessage } from "@/lib/api/client";
import { isTgDemoMode } from "@/lib/telegram/demo";
import { useWebSocket } from "@/hooks/use-websocket";
import {
notificationKeys,
useNotifications,
} from "@/hooks/use-notifications";
import { notificationKeys, useNotifications } from "@/hooks/use-notifications";
import { notificationsApi } from "@/lib/api/notifications";
import { projectsApi } from "@/lib/api/projects";
import { gitApi } from "@/lib/api/git";
@@ -16,23 +13,28 @@ import { haptics } from "@/lib/telegram/webapp";
import type { TgTab } from "@/components/tg/tg-tab-bar";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { TgAvatar, TgCircleAction, TgRow, TgSection } from "@/components/tg/ui";
import {
TgAvatar,
TgCircleAction,
TgDeltaChip,
TgRow,
TgRowIcon,
TgSection,
TgStat,
TG_CARD,
TG_PRESS,
} from "@/components/tg/ui";
import { TgSheet, useCountUp } from "@/components/tg/motion";
import {
IconAckAll,
IconFleet,
IconSeal,
IconShip,
IconSweep,
} from "@/components/tg/tg-icons";
import { DayBars, Sparkline } from "@/components/tg/charts";
import {
AlertTriangle,
ArrowDownRight,
ArrowUpRight,
CheckSquare,
ChevronRight,
Rocket,
} from "lucide-react";
import { fmtTokens } from "@/components/tg/tg-format";
import { CaretRight, CheckCircle, Warning } from "@phosphor-icons/react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
@@ -89,13 +91,9 @@ const DRAFT_LABELS: Record<string, string> = {
const DAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"];
const compactNumber = new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
});
function taskMeta(task: TodayTaskItem): string {
const parts = [task.team ?? "—"];
const parts: string[] = [];
if (task.team) parts.push(task.team);
if (task.updated_at) {
parts.push(`${formatDistanceToNow(new Date(task.updated_at))} ago`);
}
@@ -111,44 +109,43 @@ function weekdayLabels(count: number): string[] {
);
}
function SpendHero({ spend }: { spend: TodayBrief["spend"] }) {
const delta = spend.delta_pct;
const up = (delta ?? 0) >= 0;
/** The spend hero a wallet-balance-style numeral that doubles as the
* drilldown into Metrics. */
function SpendHero({
spend,
onOpen,
}: {
spend: TodayBrief["spend"];
onOpen: () => void;
}) {
const cost = useCountUp(spend.cost_today_usd);
return (
<div className="overflow-hidden rounded-2xl border bg-gradient-to-b from-primary/[0.07] to-transparent p-4">
<p className="tg-display text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Spend today
</p>
<div className="mt-1 flex items-end justify-between gap-3">
<div className="flex items-baseline gap-2">
<span className="tg-display text-[40px] leading-none tabular-nums">
${cost.toFixed(2)}
</span>
{delta !== null && (
<span
className={cn(
"flex items-center gap-0.5 text-xs font-medium tabular-nums",
up ? "text-emerald-400" : "text-rose-400",
)}
>
{up ? (
<ArrowUpRight className="h-3.5 w-3.5" />
) : (
<ArrowDownRight className="h-3.5 w-3.5" />
)}
{Math.abs(delta)}%
</span>
)}
</div>
<span className="pb-1 text-[11px] tabular-nums text-muted-foreground">
{compactNumber.format(spend.tokens_today)} tokens
<button
type="button"
onClick={onOpen}
aria-label="Open metrics"
className={cn(TG_CARD, TG_PRESS, "w-full p-4 text-left")}
>
<div className="flex items-center justify-between">
<p className="text-[13px] text-muted-foreground">Spend today</p>
<CaretRight
weight="bold"
className="h-4 w-4 text-muted-foreground/40"
/>
</div>
<span className="tg-display block text-[44px] leading-none tabular-nums">
${cost.toFixed(2)}
</span>
<div className="mt-1.5 flex items-center gap-2">
<TgDeltaChip pct={spend.delta_pct} />
<span className="text-xs tabular-nums text-muted-foreground">
{fmtTokens(spend.tokens_today)} tokens
</span>
</div>
<div className="-mx-1 mt-2">
<Sparkline values={spend.series} />
</div>
</div>
</button>
);
}
@@ -166,13 +163,11 @@ function NeedsYouBanner({
);
if (needs.total === 0) {
return (
<div className="flex items-center gap-2.5 rounded-2xl border bg-card p-3.5">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-400">
<CheckSquare className="h-4.5 w-4.5" />
</span>
<div className={cn(TG_CARD, "flex items-center gap-3 p-3.5")}>
<TgRowIcon icon={CheckCircle} tone="emerald" />
<div>
<p className="text-sm font-medium">All clear</p>
<p className="text-[11px] text-muted-foreground">
<p className="text-xs text-muted-foreground">
Nothing is waiting on you.
</p>
</div>
@@ -180,20 +175,20 @@ function NeedsYouBanner({
);
}
return (
<div className="space-y-2 rounded-2xl border border-primary/30 bg-primary/[0.08] p-3.5">
<div className={cn(TG_CARD, "space-y-2 bg-primary/[0.06] p-3.5")}>
<button
type="button"
onClick={onApprovals}
className="flex w-full items-center justify-between"
className="flex min-h-11 w-full items-center justify-between"
>
<span className="tg-display text-[11px] uppercase tracking-[0.14em] text-primary">
<span className="text-[13px] font-semibold text-primary">
Needs you
</span>
<span className="flex items-center gap-1 text-primary">
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold tabular-nums text-primary-foreground">
{needs.total}
</span>
<ChevronRight className="h-4 w-4" />
<CaretRight weight="bold" className="h-4 w-4" />
</span>
</button>
{heldEntries.length > 0 && (
@@ -203,7 +198,7 @@ function NeedsYouBanner({
key={key}
type="button"
onClick={onApprovals}
className="rounded-full bg-primary/15 px-2.5 py-1 text-xs font-medium tabular-nums text-primary transition-colors active:bg-primary/25"
className="rounded-full bg-violet-500/15 px-2.5 py-1 text-xs font-medium tabular-nums text-violet-300 transition-colors active:bg-violet-500/25"
>
{DRAFT_LABELS[key] ?? key} · {count}
</button>
@@ -211,10 +206,11 @@ function NeedsYouBanner({
</div>
)}
{(needs.awaiting_ceo.length > 0 || needs.blocked.length > 0) && (
<div className="-mx-1.5 divide-y divide-primary/10">
<div className="-mx-1.5 divide-y divide-white/[0.04]">
{needs.awaiting_ceo.slice(0, 2).map((t) => (
<TgRow
key={t.id}
leading={<TgRowIcon icon={IconSeal} tone="sky" />}
title={t.title}
meta={taskMeta(t)}
onPress={onBoard}
@@ -223,6 +219,7 @@ function NeedsYouBanner({
{needs.blocked.slice(0, 2).map((t) => (
<TgRow
key={t.id}
leading={<TgRowIcon icon={Warning} tone="rose" />}
title={t.title}
meta={
<>
@@ -268,7 +265,7 @@ function FleetSheet({
<li key={agent.name} className="flex items-center gap-3 py-2.5">
<TgAvatar name={agent.name} active />
<div className="min-w-0 flex-1">
<p className="tg-display text-[13px]">{agent.name}</p>
<p className="text-[13px] font-semibold">{agent.name}</p>
<p className="truncate text-xs text-muted-foreground">
{agent.task_title ??
`${agent.role}${agent.team ? ` · ${agent.team}` : ""}`}
@@ -380,9 +377,9 @@ export function TgTodayTab({
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-32 w-full rounded-2xl" />
<Skeleton className="h-16 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-32 w-full rounded-[20px]" />
<Skeleton className="h-16 w-full rounded-[20px]" />
<Skeleton className="h-24 w-full rounded-[20px]" />
</div>
);
}
@@ -390,7 +387,7 @@ export function TgTodayTab({
if (isError || !data) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<AlertTriangle className="h-8 w-8 opacity-50" />
<Warning className="h-8 w-8 opacity-50" />
<p className="text-sm">Couldn&apos;t load the brief</p>
</div>
);
@@ -404,15 +401,15 @@ export function TgTodayTab({
};
const idle = fleet.by_status.idle ?? 0;
const active = fleet.by_status.active ?? Math.max(fleet.working.length, 0);
const shipMeta = ship.open_release_proposal
? "Release proposal waiting"
: ship.ci_fix_tasks > 0
? `${ship.ci_fix_tasks} CI fix open`
: "No release pending";
return (
<div className="tg-stagger space-y-3">
<header className="flex items-center justify-between px-1 pt-0.5">
<p className="tg-display text-[13px] tracking-[0.24em] text-muted-foreground">
ROBOCO<span className="tg-cursor text-primary">_</span>
</p>
</header>
<SpendHero spend={spend} />
<SpendHero spend={spend} onOpen={() => go("metrics")} />
{/* Operations, not navigation — the tab bar already navigates. */}
<div className="flex items-stretch gap-2 px-1">
@@ -476,7 +473,7 @@ export function TgTodayTab({
}}
className="w-full space-y-2 text-left"
>
<div className="flex -space-x-1.5 overflow-hidden">
<div className="flex gap-2 overflow-hidden">
{fleet.working.map((a) => (
<TgAvatar key={a.name} name={a.name} active />
))}
@@ -487,7 +484,7 @@ export function TgTodayTab({
key={agent.name}
className="flex items-baseline gap-2 text-[13px] leading-snug"
>
<span className="tg-display shrink-0 text-xs">
<span className="shrink-0 text-xs font-semibold">
{agent.name}
</span>
{agent.task_title && (
@@ -508,41 +505,26 @@ export function TgTodayTab({
</TgSection>
<div className="grid grid-cols-2 gap-2.5">
<TgSection
title="Shipped this week"
trailing={
<span className="text-sm font-semibold tabular-nums text-foreground">
{velocity.week_total}
</span>
}
>
<DayBars
values={velocity.series}
labels={weekdayLabels(velocity.series.length)}
<TgSection title="Velocity">
<TgStat
value={velocity.week_total}
caption="Shipped this week"
tone="attention"
/>
<div className="mt-2">
<DayBars
values={velocity.series}
labels={weekdayLabels(velocity.series.length)}
/>
</div>
</TgSection>
<TgSection icon={Rocket} title="Ship">
<button
type="button"
onClick={() => ship.open_release_proposal && go("approvals")}
className="w-full text-left"
>
<p
className={cn(
"tg-display text-[22px] leading-tight tabular-nums",
ship.open_release_proposal && "text-primary",
)}
>
v{ship.version}
</p>
<p className="mt-0.5 text-[11px] leading-tight text-muted-foreground">
{ship.open_release_proposal
? "Release proposal waiting"
: ship.ci_fix_tasks > 0
? `${ship.ci_fix_tasks} CI fix open`
: "No release pending"}
</p>
</button>
<TgSection title="Ship">
<TgRow
leading={<TgRowIcon icon={IconShip} tone="amber" />}
title={`v${ship.version}`}
meta={shipMeta}
onPress={() => go("approvals", "release")}
/>
</TgSection>
</div>
+229 -63
View File
@@ -1,25 +1,44 @@
"use client";
/**
* The cockpit's shared visual language (direction: Telegram palette under
* RoboCo's typographic voice). Every tab composes these three primitives so
* density, rhythm, and press-feedback stay identical everywhere:
* TgSection (a grouped card with a tracked micro-label header), TgRow (a
* tappable list row with a fixed 44px minimum target), TgStat (a big
* tabular-nums figure with a caption). Colors always come from the CSS
* variables inside Telegram those are the user's own theme (P0's
* themeParams bridge), so nothing here names a literal color.
* The cockpit's shared visual language V6 "soft cockpit": borderless
* elevated surfaces on a deep slate ground, native type with tabular
* numerals for figures, one amber accent, generous radii. Every tab
* composes these primitives so density, rhythm, and press-feedback stay
* identical everywhere. Colors always come from the CSS variables inside
* Telegram those are the user's own theme (the themeParams bridge), so
* nothing here names a literal surface color.
*/
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ArrowLeft, CaretRight } from "@phosphor-icons/react";
/** Any icon component — lucide or the cockpit's own duotone glyphs. */
export type TgAnyIcon = React.ComponentType<{ className?: string }>;
import {
getAgentInitials,
getAgentTeamColor,
isKnownAgent,
TEAM_COLOR_CLASSES,
} from "@/lib/agent-utils";
import { haptics } from "@/lib/telegram/webapp";
import { useBackButton, useTgWebApp } from "@/lib/telegram/hooks";
/** The press language every tappable surface shares: a soft spring-ish
* scale-down, transform-only. */
export const TG_PRESS =
"transition-[transform,background-color] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] active:scale-[0.97]";
/** The elevation language for cards: no outline, just surface contrast
* plus a hairline top highlight that reads as machined depth. */
export const TG_CARD =
"rounded-[20px] bg-card shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_10px_28px_-18px_rgba(0,0,0,0.8)]";
/**
* A circular icon action the cockpit's primary verbs (New task, Approve,
* Chat, Board), styled like a native wallet's Transfer/Deposit row. An
* optional badge count sits on the ring; `accent` fills the ring with the
* RoboCo amber for the one action that most wants attention.
* A quick-action tile the cockpit's primary verbs, styled like a native
* wallet's Transfer/Deposit row. An optional badge count sits on the tile;
* `accent` fills it with the RoboCo amber for the one action that most
* wants attention.
*/
export function TgCircleAction({
icon: Icon,
@@ -33,7 +52,7 @@ export function TgCircleAction({
label: string;
badge?: number;
accent?: boolean;
/** Disables the button and spins the icon while an operation runs. */
/** Disables the button and pulses the icon while an operation runs. */
busy?: boolean;
onPress: () => void;
}) {
@@ -46,10 +65,11 @@ export function TgCircleAction({
>
<span
className={cn(
"relative flex h-12 w-12 items-center justify-center rounded-full transition-all duration-200 ease-out active:scale-90",
"relative flex h-[52px] w-full items-center justify-center rounded-2xl",
TG_PRESS,
accent
? "bg-gradient-to-b from-primary to-primary/80 text-primary-foreground shadow-[0_8px_20px_-8px] shadow-primary/60"
: "bg-gradient-to-b from-muted to-muted/60 text-foreground ring-1 ring-inset ring-white/5",
? "bg-gradient-to-b from-primary to-primary/85 text-primary-foreground shadow-[0_10px_24px_-10px] shadow-primary/50"
: "bg-card text-primary shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]",
)}
>
<Icon className={cn("h-5 w-5", busy && "animate-pulse")} />
@@ -59,7 +79,7 @@ export function TgCircleAction({
</span>
)}
</span>
<span className="tg-display text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span className="text-[11px] font-medium text-muted-foreground">
{label}
</span>
</button>
@@ -74,30 +94,57 @@ const _AVATAR_HUES = [
"bg-rose-500/20 text-rose-300",
];
/** Initials avatar with a stable per-name hue and an optional live pulse
* dot the fleet strip's agent tokens. */
export function TgAvatar({ name, active }: { name: string; active?: boolean }) {
const initials = name
.split(/[-_\s]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join("");
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash + name.charCodeAt(i)) | 0;
const hue = _AVATAR_HUES[Math.abs(hash) % _AVATAR_HUES.length];
/**
* Agent avatar tile a rounded square carrying the agent's 3-letter code
* on their CELL's color (the same team-color identity the desktop
* Switchboard uses), so a list of agents scans by team at a glance. Names
* that aren't known agents fall back to initials on a stable per-name hue.
*/
export function TgAvatar({
name,
active,
size = "md",
}: {
name: string;
active?: boolean;
size?: "sm" | "md";
}) {
const dims = size === "sm" ? "h-7 w-7 text-[9px]" : "h-9 w-9 text-[10px]";
let face: string;
let code: string;
if (isKnownAgent(name)) {
// Tint only — the class map's border-* entries are inert without a
// border width, and the borderless tile is the point.
face = TEAM_COLOR_CLASSES[getAgentTeamColor(name)];
code = getAgentInitials(name);
} else {
let hash = 0;
for (let i = 0; i < name.length; i++)
hash = (hash + name.charCodeAt(i)) | 0;
face = _AVATAR_HUES[Math.abs(hash) % _AVATAR_HUES.length];
code =
name
.split(/[-_\s]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join("") || "?";
}
return (
<span className="relative inline-flex h-9 w-9 items-center justify-center">
<span
className={cn("relative inline-flex items-center justify-center", dims)}
>
<span
className={cn(
"flex h-9 w-9 items-center justify-center rounded-full text-[11px] font-semibold ring-1 ring-inset ring-white/10",
hue,
"flex items-center justify-center rounded-xl font-semibold",
dims,
face,
)}
>
{initials || "?"}
{code}
</span>
{active && (
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border-2 border-card bg-emerald-400" />
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-card bg-emerald-400" />
)}
</span>
);
@@ -110,27 +157,22 @@ export function TgSection({
children,
className,
}: {
icon?: LucideIcon;
icon?: TgAnyIcon;
title: string;
trailing?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={cn(
"rounded-xl border bg-card text-card-foreground",
className,
)}
>
<header className="flex items-center justify-between gap-2 px-3 pb-1 pt-2.5">
<h2 className="tg-display flex items-center gap-1.5 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
{Icon && <Icon className="h-3.5 w-3.5" />}
<section className={cn(TG_CARD, "text-card-foreground", className)}>
<header className="flex items-center justify-between gap-2 px-4 pb-1 pt-3">
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground/90">
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground" />}
{title}
</h2>
{trailing}
</header>
<div className="px-3 pb-2.5">{children}</div>
<div className="px-4 pb-3">{children}</div>
</section>
);
}
@@ -156,26 +198,29 @@ export function TgRow({
<button
type="button"
onClick={onPress}
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-1.5 py-2 text-left transition-colors active:bg-muted"
className="flex min-h-12 w-full items-center gap-3 rounded-xl px-1.5 py-2 text-left transition-colors duration-200 active:bg-white/[0.05]"
>
{leading}
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm font-medium leading-snug",
"text-[15px] font-medium leading-snug",
lines === 1 ? "truncate" : "line-clamp-2",
)}
>
{title}
</p>
{meta && (
<p className="mt-0.5 truncate text-[11px] leading-tight text-muted-foreground">
<p className="mt-0.5 truncate text-xs leading-tight text-muted-foreground">
{meta}
</p>
)}
</div>
{trailing ?? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground/50" />
<CaretRight
weight="bold"
className="h-4 w-4 shrink-0 text-muted-foreground/40"
/>
)}
</button>
);
@@ -185,32 +230,31 @@ export function TgRow({
* tints it per row kind so a list of mixed items reads as color-coded
* rather than a monochrome column. */
const _TILE_TONES: Record<string, string> = {
amber:
"bg-gradient-to-br from-amber-400/25 to-amber-500/5 text-amber-300 ring-1 ring-inset ring-amber-400/20",
sky: "bg-gradient-to-br from-sky-400/25 to-sky-500/5 text-sky-300 ring-1 ring-inset ring-sky-400/20",
amber: "bg-gradient-to-br from-amber-400/25 to-amber-500/5 text-amber-300",
sky: "bg-gradient-to-br from-sky-400/25 to-sky-500/5 text-sky-300",
violet:
"bg-gradient-to-br from-violet-400/25 to-violet-500/5 text-violet-300 ring-1 ring-inset ring-violet-400/20",
"bg-gradient-to-br from-violet-400/25 to-violet-500/5 text-violet-300",
emerald:
"bg-gradient-to-br from-emerald-400/25 to-emerald-500/5 text-emerald-300 ring-1 ring-inset ring-emerald-400/20",
rose: "bg-gradient-to-br from-rose-400/25 to-rose-500/5 text-rose-300 ring-1 ring-inset ring-rose-400/20",
muted: "bg-muted text-muted-foreground ring-1 ring-inset ring-white/5",
"bg-gradient-to-br from-emerald-400/25 to-emerald-500/5 text-emerald-300",
rose: "bg-gradient-to-br from-rose-400/25 to-rose-500/5 text-rose-300",
muted: "bg-muted/70 text-muted-foreground",
};
export function TgRowIcon({
icon: Icon,
tone = "muted",
}: {
icon: LucideIcon;
icon: TgAnyIcon;
tone?: keyof typeof _TILE_TONES | string;
}) {
return (
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px]",
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl",
_TILE_TONES[tone] ?? _TILE_TONES.muted,
)}
>
<Icon className="h-4.5 w-4.5" />
<Icon className="h-[18px] w-[18px]" />
</span>
);
}
@@ -228,15 +272,137 @@ export function TgStat({
<div>
<p
className={cn(
"tg-display text-[22px] leading-tight tabular-nums",
"tg-display text-[22px] leading-tight",
tone === "attention" && "text-primary",
)}
>
{value}
</p>
<p className="mt-0.5 text-[11px] leading-tight text-muted-foreground">
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
{caption}
</p>
</div>
);
}
/** Signed percent-change chip — emerald up, rose down, muted flat. */
export function TgDeltaChip({ pct }: { pct: number | null | undefined }) {
if (pct === null || pct === undefined) return null;
const up = pct > 0;
const flat = pct === 0;
return (
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
flat
? "bg-muted/60 text-muted-foreground"
: up
? "bg-emerald-500/15 text-emerald-300"
: "bg-rose-500/15 text-rose-300",
)}
>
{!flat && (up ? "↑" : "↓")}
{Math.abs(pct).toFixed(Math.abs(pct) >= 100 ? 0 : 1)}%
</span>
);
}
/**
* Segmented control the wallet-style range picker. Equal-width segments
* with a sliding thumb (transform-only). Options are stable per mount.
*/
export function TgSegmented<T extends string>({
options,
value,
onChange,
}: {
options: ReadonlyArray<{ value: T; label: string }>;
value: T;
onChange: (next: T) => void;
}) {
const idx = Math.max(
0,
options.findIndex((o) => o.value === value),
);
return (
<div className="relative grid auto-cols-fr grid-flow-col rounded-full bg-muted/50 p-1">
<span
aria-hidden="true"
className="absolute inset-y-1 left-1 rounded-full bg-card shadow-[0_2px_8px_-2px_rgba(0,0,0,0.5)] transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]"
style={{
width: `calc((100% - 0.5rem) / ${options.length})`,
transform: `translateX(${idx * 100}%)`,
}}
/>
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={o.value === value}
onClick={() => {
haptics.tap();
onChange(o.value);
}}
className={cn(
"relative z-10 rounded-full py-1.5 text-center text-[13px] font-medium transition-colors duration-200",
o.value === value ? "text-foreground" : "text-muted-foreground",
)}
>
{o.label}
</button>
))}
</div>
);
}
/**
* A pushed sub-page the wallet-style drilldown surface. Slides in from
* the right over the tab area; Telegram's native BackButton dismisses it
* while it's mounted, with a visible back chevron as the off-Telegram
* fallback. The parent renders it INSTEAD of the tab content.
*/
export function TgSubPage({
title,
subtitle,
onBack,
trailing,
children,
}: {
title: React.ReactNode;
subtitle?: React.ReactNode;
onBack: () => void;
trailing?: React.ReactNode;
children: React.ReactNode;
}) {
const webApp = useTgWebApp();
useBackButton(onBack);
return (
<div className="tg-slide-in">
<header className="mb-3 flex min-h-9 items-center gap-2">
{!webApp?.BackButton && (
<button
type="button"
aria-label="Back"
onClick={onBack}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-card text-muted-foreground",
TG_PRESS,
)}
>
<ArrowLeft weight="bold" className="h-4 w-4" />
</button>
)}
<div className="min-w-0 flex-1">
<h1 className="truncate text-[17px] font-semibold leading-tight">
{title}
</h1>
{subtitle && (
<p className="truncate text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
{trailing}
</header>
{children}
</div>
);
}
+31 -3
View File
@@ -10,6 +10,7 @@ import {
export const a2aLiveKeys = {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
ceoConversations: ["a2a-live", "ceo-conversations"] as const,
pairs: ["a2a-live", "pairs"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
@@ -17,11 +18,38 @@ export const a2aLiveKeys = {
// Conversation list — refreshed by WS `a2a.message` invalidation and the
// manual Refresh button; a short staleTime keeps remounts reasonably fresh.
export function useA2AConversations(limit?: number) {
export function useA2AConversations(limit?: number, enabled = true) {
return useQuery({
queryKey: [...a2aLiveKeys.conversations, limit ?? 50],
queryFn: () => a2aApi.listAdminConversations(limit),
staleTime: 30_000,
enabled,
});
}
// The CEO's own threads (participant-scoped) — resolved peer + per-thread
// unread count. The phone chat's "Mine" list.
export function useCeoConversations(limit?: number, enabled = true) {
return useQuery({
queryKey: [...a2aLiveKeys.ceoConversations, limit ?? 50],
queryFn: () => a2aApi.listCeoConversations(limit),
staleTime: 30_000,
enabled,
});
}
// Clear a thread's unread counter when it's opened. Invalidates the CEO
// list so its badge drops without waiting for the next WS frame.
export function useMarkConversationRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (conversationId: string) =>
a2aApi.markConversationRead(conversationId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.ceoConversations,
});
},
});
}
@@ -43,12 +71,12 @@ export function useA2AAdminPairs() {
// passes a ~10s interval to poll the thread it's actively viewing.
export function useA2AMessages(
conversationId: string | null,
options?: { refetchInterval?: number | false },
options?: { refetchInterval?: number | false; enabled?: boolean },
) {
return useQuery({
queryKey: a2aLiveKeys.messages(conversationId || ""),
queryFn: () => a2aApi.listAdminMessages(conversationId!),
enabled: !!conversationId,
enabled: !!conversationId && (options?.enabled ?? true),
staleTime: 30_000,
refetchInterval: options?.refetchInterval ?? false,
});
+66
View File
@@ -73,6 +73,28 @@ export interface AdminConversationSummary {
updated_at: string;
}
/**
* One row of the CEO's OWN conversation list (participant-scoped route)
* unlike the admin list this carries the resolved `other_agent` and a real
* `unread_count`, which is what a phone chat list wants.
*/
export interface CeoConversationSummary {
id: string;
other_agent: string;
topic: string | null;
task_id: string | null;
status: string;
message_count: number;
unread_count: number;
last_message_at: string | null;
last_message_preview: string | null;
}
export interface CeoConversationListResponse {
items: CeoConversationSummary[];
total: number;
}
/** One persisted A2A chat message (full body — WS frames only carry excerpts). */
export interface A2AChatMessage {
id: string;
@@ -525,4 +547,48 @@ export const a2aApi = {
);
return data;
},
/**
* The CEO's own conversation list (participant-scoped route) resolved
* `other_agent` plus a real `unread_count` per thread, which the admin
* list doesn't carry.
*/
listCeoConversations: async (
limit: number = 50,
): Promise<CeoConversationListResponse> => {
if (isMockMode()) {
const now = new Date().toISOString();
return {
items: [
{
id: "mock-ceo-conv-1",
other_agent: "main-pm",
topic: null,
task_id: null,
status: "active",
message_count: 4,
unread_count: 1,
last_message_at: now,
last_message_preview: "Wave 2 is queued behind the migration.",
},
],
total: 1,
};
}
const { data } = await api.get<CeoConversationListResponse>(
"/a2a/chat/conversations",
{ params: { limit }, headers: { "X-Agent-ID": "ceo" } },
);
return data;
},
/** Clear the CEO's unread counter on one of its own conversations. */
markConversationRead: async (conversationId: string): Promise<void> => {
if (isMockMode()) return;
await api.post(
`/a2a/chat/conversations/${conversationId}/read`,
undefined,
{ headers: { "X-Agent-ID": "ceo" } },
);
},
};
@@ -82,7 +82,13 @@ describe("useMainButton", () => {
);
rerender(
<TgWebAppProvider webApp={webApp}>
<MainButtonHarness text="Approve" visible loading disabled onClick={second} />
<MainButtonHarness
text="Approve"
visible
loading
disabled
onClick={second}
/>
</TgWebAppProvider>,
);
expect(mainButton.showProgress).toHaveBeenCalled();
+207
View File
@@ -8,6 +8,11 @@ import type { ReleaseProposal } from "@/lib/api/release";
import type { XPost } from "@/lib/api/x";
import type { VideoPost } from "@/lib/api/video";
import type { RoadmapCycle } from "@/lib/api/roadmap";
import type {
A2AChatMessage,
AdminConversationSummary,
CeoConversationSummary,
} from "@/lib/api/a2a";
import type { TodayBrief } from "@/components/tg/tg-today-tab";
import {
Complexity,
@@ -374,3 +379,205 @@ export const DEMO_NOTIFICATIONS: Notification[] = [
acked_at: {},
},
];
// ---------------------------------------------------------------------------
// Chat fixtures — the CEO's own DM threads plus watched agent↔agent threads,
// with full transcripts (markdown-flavored like real agent messages).
// ---------------------------------------------------------------------------
export const DEMO_CHAT_MINE: CeoConversationSummary[] = [
{
id: "demo-conv-mainpm",
other_agent: "main-pm",
topic: null,
task_id: null,
status: "active",
message_count: 4,
unread_count: 1,
last_message_at: _iso(12),
last_message_preview:
"Wave 2 is queued behind the metrics migration — ETA tomorrow.",
},
{
id: "demo-conv-fedev1",
other_agent: "fe-dev-1",
topic: null,
task_id: null,
status: "active",
message_count: 6,
unread_count: 0,
last_message_at: _iso(95),
last_message_preview: "Pushed the fix, PR checks are green.",
},
{
id: "demo-conv-beqa",
other_agent: "be-qa",
topic: null,
task_id: null,
status: "active",
message_count: 2,
unread_count: 0,
last_message_at: _iso(1440),
last_message_preview: "Flake was the sandbox port collision, not the test.",
},
];
export const DEMO_CHAT_FLEET: AdminConversationSummary[] = [
{
id: "demo-conv-fepair",
agent_a: "fe-pm",
agent_b: "fe-pr-reviewer",
topic: "PR gate — release docs",
task_id: "33333333-3333-4333-8333-333333333333",
status: "active",
message_count: 9,
last_message_at: _iso(41),
last_message_preview: "pr_pass recorded — CI green, per-AC walk attached.",
created_at: _iso(600),
updated_at: _iso(41),
},
{
id: "demo-conv-bepair",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-1111-4111-8111-111111111111",
status: "active",
message_count: 5,
last_message_at: _iso(160),
last_message_preview: "Re-ran the suite against the sandbox — green.",
created_at: _iso(900),
updated_at: _iso(160),
},
{
id: "demo-conv-uxpair",
agent_a: "ux-dev-2",
agent_b: "ux-pm",
topic: null,
task_id: null,
status: "resolved",
message_count: 3,
last_message_at: _iso(2900),
last_message_preview: "Frames verified, marking the render check done.",
created_at: _iso(3100),
updated_at: _iso(2900),
},
];
const _msg = (
id: string,
conversation_id: string,
from_agent: string,
content: string,
minsAgo: number,
): A2AChatMessage => ({
id,
conversation_id,
from_agent,
content,
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: _iso(minsAgo),
edited_at: null,
});
export const DEMO_CHAT_MESSAGES: Record<string, A2AChatMessage[]> = {
"demo-conv-mainpm": [
_msg(
"dm-1",
"demo-conv-mainpm",
"ceo",
"Where are we on the metrics drilldown wave?",
70,
),
_msg(
"dm-2",
"demo-conv-mainpm",
"main-pm",
"Wave 1 merged this morning:\n\n- `panel/src/components/metrics` — time-series + window selector\n- backend rollups untouched\n\nWave 2 (per-agent scorecards) is queued behind the metrics migration — ETA tomorrow.",
12,
),
],
"demo-conv-fedev1": [
_msg(
"df-1",
"demo-conv-fedev1",
"ceo",
"The tooltip clipping on the usage chart — yours?",
130,
),
_msg(
"df-2",
"demo-conv-fedev1",
"fe-dev-1",
"Yes — `usage-time-series-chart.tsx:84` was mounting the tooltip inside the overflow container. Pushed the fix, PR checks are green.\n\nPR: https://github.com/rennf93/roboco/pull/612",
95,
),
],
"demo-conv-beqa": [
_msg(
"db-1",
"demo-conv-beqa",
"ceo",
"That nightly flake on the sandbox suite — real bug?",
1500,
),
_msg(
"db-2",
"demo-conv-beqa",
"be-qa",
"Flake was the sandbox port collision, not the test. Two provisioners raced the same host port; the registry retry absorbs it now.",
1440,
),
],
"demo-conv-fepair": [
_msg(
"dp-1",
"demo-conv-fepair",
"fe-pm",
"Gate review is yours — assembled PR #609 targets the cell root. Per-AC walk required, docs deliverable included.",
120,
),
_msg(
"dp-2",
"demo-conv-fepair",
"fe-pr-reviewer",
"Walked the diff:\n\n1. **AC1** — release notes page `docs/releases/0.26.md:1` ✓\n2. **AC2** — nav entry `docs/mkdocs.yml:48` ✓\n\npr_pass recorded — CI green, per-AC walk attached.",
41,
),
],
"demo-conv-bepair": [
_msg(
"dq-1",
"demo-conv-bepair",
"be-dev-1",
"Branch is ready for QA — `feature/backend/A1B2C3D4`. Sandbox creds in the envelope.",
300,
),
_msg(
"dq-2",
"demo-conv-bepair",
"be-qa",
"Re-ran the suite against the sandbox — green. Passing to docs.",
160,
),
],
"demo-conv-uxpair": [
_msg(
"du-1",
"demo-conv-uxpair",
"ux-dev-2",
"Rendered both cuts, frames extracted to `.previews/` — every brief scene present.",
3000,
),
_msg(
"du-2",
"demo-conv-uxpair",
"ux-pm",
"Frames verified, marking the render check done.",
2900,
),
],
};
+16
View File
@@ -43,6 +43,11 @@ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
* skipped the panel's own theme shows through, which is the right
* degraded look.
*/
/** The #tg-shell default background as hex what Telegram's own window
* chrome is painted with when the theme doesn't hand us a bg_color.
* Keep in step with `--background` in globals.css' #tg-shell block. */
const SHELL_BG_HEX = "#14171c";
export function applyTelegramTheme(
webApp: TelegramWebApp,
root: HTMLElement,
@@ -55,6 +60,17 @@ export function applyTelegramTheme(
root.style.setProperty(cssVar, value);
}
}
// Paint Telegram's own window chrome (titlebar / app bg / bottom bar) to
// the shell background so the cockpit blends edge-to-edge into the client
// instead of sitting framed inside default chrome — the single biggest
// "native app, not website" tell.
const bg =
params.bg_color && HEX_COLOR.test(params.bg_color)
? params.bg_color
: SHELL_BG_HEX;
webApp.setHeaderColor?.(bg);
webApp.setBackgroundColor?.(bg);
webApp.setBottomBarColor?.(bg);
}
/**
+6
View File
@@ -71,6 +71,12 @@ export interface TelegramWebApp {
/** Bot API 7.7+ stops vertical swipes from minimizing the app so
* scrolling a list never accidentally dismisses the cockpit. */
disableVerticalSwipes?: () => void;
/** Bot API 6.1+/7.10+ paint Telegram's own window chrome (titlebar,
* app background, bottom bar) so the cockpit blends edge-to-edge into
* the client instead of sitting framed inside default chrome. */
setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void;
setBottomBarColor?: (color: string) => void;
HapticFeedback?: TelegramHapticFeedback;
MainButton?: TelegramMainButton;
BackButton?: TelegramBackButton;