mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): Mini App V6 — premium overhaul (#609)
* feat(tg): Mini App V6 — premium overhaul (design system, Chat parity, Metrics drilldown, CEO verbs) Design system: native type with tabular-numeral heroes (mono demoted to the wordmark), borderless elevated cards, floating dock, Telegram window-chrome painting via the theme bridge; Inbox moves behind a header bell with humanized notifications (UUIDs resolve to task names). Chat: honest Mine/Fleet split — participant-scoped CEO threads with real unread counts and mark-read, watched fleet threads with reply-as-CEO on task-linked conversations (watch-only otherwise), markdown transcripts, live pulse flashes, and a pinned Secretary live chat on the panel's SSE session runtime. Metrics: new tab with period-segmented spend hero, by-agent/team/model breakdowns, delivery + efficiency health, and a per-agent drilldown over usage time-series (agent_slug) + member scorecard. Board: tg-native grouped pipeline replacing the MobileTaskBoard wrapper; task sheet gains the CEO decide verbs (approve / request changes / unblock). Security: /api/dashboard router now require_panel_token-gated at router level (mirrors /api/usage), closing unauthenticated metrics exposure. * fix(tg): restore Share Tech Mono brand voice, Phosphor icon set, borderless avatars The mono returns as the numeral/brand voice (.tg-display — heroes, stat values, wordmark) while labels stay native sentence case. The hand-drawn duotone glyphs and lucide feature icons are replaced by Phosphor (MIT): duotone at rest via an IconContext at the shell, filled weight on the dock's active tab; row glyph maps (board statuses, inbox kinds, approval kinds, quick actions) all move over. Team avatar tiles drop their borders — tint-only squircles. * fix(tg): fleet avatar strip breathes — spaced tiles instead of overlap * polish(tg): taste-skill audit pass — em-dash purge, one icon family, separator rationing Applied the design-taste audit against the cockpit: every em-dash in visible UI copy rewritten (periods/commas/colons), the remaining lucide chrome (carets, arrows, send, close, spinners) moved to Phosphor so the tg tree ships one icon family (send is the native paper-plane, carets bold), the hand-rolled chevron SVG deleted, and metadata lines rationed to a single middle-dot separator. * polish(tg): pipeline chip strip scrolls without a visible scrollbar * fix(tests): metrics observability fixture uses a relative timestamp The hardcoded _T0 (2026-06-20) aged out of the service's 30-day window exactly 30 days later, detonating the suite on every branch. Two days back from now() stays inside every window (30d metrics, 7d scorecards) permanently. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+15
@@ -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 () => {
|
||||
|
||||
@@ -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'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'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
@@ -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>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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))]">
|
||||
|
||||
@@ -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'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'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't load, so this list may be incomplete.
|
||||
</p>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user