feat: Telegram V3 — Mini App cockpit (initData auth + /tg surface) (#554)

* feat(telegram): Mini App auth — initData validation mints the cloud-auth session cookie

* feat(panel): /tg Mini App cockpit — approvals, inbox, read-only board, A2A chat

* fix(telegram,panel): unconditional webapp-auth rate limit, future-dated initData rejection, anchored /tg matcher

* docs(map,rag): Telegram Mini App auth route, initData validator, (tg) surface

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 02:47:59 +02:00
committed by GitHub
co-authored by Renn F
parent 3b88c706dd
commit c40a7a39c3
33 changed files with 1725 additions and 27 deletions
@@ -25,7 +25,9 @@ import { useCreateCeoConversation } from "@/hooks/use-a2a-live";
// Self, plus every role that can't actually read/answer a DM: auditor and
// pr_reviewer carry no read_a2a on their manifests, prompter and secretary
// are human-only note/evidence roles — a DM to any of them is a black hole.
const EXCLUDE_NON_DM_ROLES = [
// Exported so other "start a fresh 1:1" surfaces (the /tg Mini App chat tab)
// share the exact same exclusion list instead of drifting out of sync.
export const EXCLUDE_NON_DM_ROLES = [
AgentRole.CEO,
AgentRole.AUDITOR,
AgentRole.PR_REVIEWER,
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
const { useTasks } = vi.hoisted(() => ({ useTasks: vi.fn() }));
vi.mock("@/hooks/use-tasks", () => ({ useTasks }));
import { MobileTaskBoard } from "../mobile-task-board";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Fix the thing",
description: "",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
parent_task_id: null,
assigned_to: "be-dev-1",
...overrides,
} as unknown as Task;
}
describe("MobileTaskBoard", () => {
beforeEach(() => {
useTasks.mockReset();
});
it("renders skeletons while loading", () => {
useTasks.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<MobileTaskBoard />);
expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0);
});
it("shows an empty state when there are no tasks", () => {
useTasks.mockReturnValue({ data: [], isLoading: false });
render(<MobileTaskBoard />);
expect(screen.getByText("No tasks")).toBeInTheDocument();
});
it("groups tasks into per-status collapsible sections with title/assignee rows", () => {
useTasks.mockReturnValue({
data: [
buildTask({ id: "a", title: "In progress task", status: TaskStatus.IN_PROGRESS, assigned_to: "be-dev-1" }),
buildTask({ id: "b", title: "Another in-progress task", status: TaskStatus.IN_PROGRESS, assigned_to: "be-dev-2" }),
buildTask({ id: "c", title: "Done task", status: TaskStatus.COMPLETED, assigned_to: null }),
],
isLoading: false,
});
render(<MobileTaskBoard />);
// in_progress is open-by-default: its 2 rows are immediately visible.
expect(screen.getByText("In progress task")).toBeInTheDocument();
expect(screen.getByText("Another in-progress task")).toBeInTheDocument();
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
expect(screen.getByText("Backend Dev 2")).toBeInTheDocument();
// Section header text is split across nested spans ("in progress" + a
// separately-styled "(2)"), so query by the trigger button's accessible
// name (which aggregates descendant text) rather than getByText, which
// doesn't match text broken up across multiple elements.
expect(
screen.getByRole("button", { name: /in progress \(2\)/i }),
).toBeInTheDocument();
// completed is collapsed by default: the section header shows, the row doesn't.
expect(
screen.getByRole("button", { name: /completed \(1\)/i }),
).toBeInTheDocument();
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
// expanding it reveals the row and its "Unassigned" fallback.
fireEvent.click(screen.getByRole("button", { name: /completed \(1\)/i }));
expect(screen.getByText("Done task")).toBeInTheDocument();
expect(screen.getByText("Unassigned")).toBeInTheDocument();
});
});
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { useTasks } from "@/hooks/use-tasks";
import { TaskStatus, type Task } from "@/types";
import { TaskStatusBadge } from "@/components/tasks/task-status-badge";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronDown, ListTodo } from "lucide-react";
import { cn } from "@/lib/utils";
// Active-work-first display order (mirrors the lifecycle doc's left-to-right
// flow) rather than the enum's declaration order.
const STATUS_ORDER: TaskStatus[] = [
TaskStatus.IN_PROGRESS,
TaskStatus.BLOCKED,
TaskStatus.NEEDS_REVISION,
TaskStatus.VERIFYING,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.PAUSED,
TaskStatus.CLAIMED,
TaskStatus.PENDING,
TaskStatus.BACKLOG,
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
];
// Open by default: the actionable half of the lifecycle. Terminal and
// not-yet-started sections start collapsed to keep the first scroll short.
const DEFAULT_OPEN = new Set<TaskStatus>([
TaskStatus.IN_PROGRESS,
TaskStatus.BLOCKED,
TaskStatus.NEEDS_REVISION,
TaskStatus.AWAITING_CEO_APPROVAL,
]);
function TaskRow({ task }: { task: Task }) {
return (
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 first:border-t-0">
<div className="min-w-0">
<p className="truncate text-sm">{task.title}</p>
<p className="truncate text-xs text-muted-foreground">
{getAgentDisplayName(task.assigned_to)}
</p>
</div>
<TaskStatusBadge status={task.status} />
</div>
);
}
function StatusSection({
status,
tasks,
defaultOpen,
}: {
status: TaskStatus;
tasks: Task[];
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-lg border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left">
<span className="text-sm font-medium">
{status.replace(/_/g, " ")}{" "}
<span className="text-muted-foreground">({tasks.length})</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
{tasks.map((t) => (
<TaskRow key={t.id} task={t} />
))}
</CollapsibleContent>
</Collapsible>
);
}
/**
* Read-only phone-cockpit task board: every task grouped by status into
* collapsible sections, compact rows (title, assignee, status pill). No
* drag-and-drop — that's the desktop kanban columns' job; this is a
* glance-and-tap surface for the /tg Mini App.
*/
export function MobileTaskBoard() {
const { data, isLoading } = useTasks({ limit: 200 });
const grouped = useMemo(() => {
const byStatus = new Map<TaskStatus, Task[]>();
for (const task of data ?? []) {
const list = byStatus.get(task.status);
if (list) list.push(task);
else byStatus.set(task.status, [task]);
}
return STATUS_ORDER.filter((s) => byStatus.has(s)).map((s) => ({
status: s,
tasks: byStatus.get(s)!,
}));
}, [data]);
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
);
}
if (grouped.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
<ListTodo className="h-8 w-8 opacity-50" />
<p className="text-sm">No tasks</p>
</div>
);
}
return (
<div className="space-y-2">
{grouped.map(({ status, tasks }) => (
<StatusSection
key={status}
status={status}
tasks={tasks}
defaultOpen={DEFAULT_OPEN.has(status)}
/>
))}
</div>
);
}
@@ -0,0 +1,30 @@
import { describe, it, expect, vi } from "vitest";
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()} />);
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");
expect(
screen.getByRole("button", { name: /approvals/i }),
).not.toHaveAttribute("aria-current");
});
it("calls onChange with the tapped tab's id", () => {
const onChange = vi.fn();
render(<TgTabBar active="approvals" onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: /chat/i }));
expect(onChange).toHaveBeenCalledWith("chat");
fireEvent.click(screen.getByRole("button", { name: /board/i }));
expect(onChange).toHaveBeenCalledWith("board");
});
});
@@ -0,0 +1,24 @@
"use client";
import { ReleaseProposalCard } from "@/components/dashboard/release-proposal-card";
import { XPostQueue } from "@/components/dashboard/x-post-queue";
import { VideoPostQueue } from "@/components/dashboard/video-post-queue";
import { RoadmapReviewQueue } from "@/components/dashboard/roadmap-review-queue";
/**
* The CEO's held-artifact stack, vertically stacked for a single thumb
* scroll column. Every card is the exact same self-contained
* dashboard-layout-independent component the desktop dashboard renders —
* each already fetches its own data and no-ops (renders nothing useful) when
* empty, so there's nothing to compose here beyond stacking them.
*/
export function TgApprovalsTab() {
return (
<div className="space-y-4">
<ReleaseProposalCard />
<XPostQueue />
<VideoPostQueue />
<RoadmapReviewQueue />
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { MobileTaskBoard } from "@/components/tasks/mobile-task-board";
/** Cockpit Board tab thin wrapper so every tab has its own file under
* components/tg/ (per the per-tab-file convention); the board itself lives
* in components/tasks since it's a general read-only task view, not
* Mini-App-specific. */
export function TgBoardTab() {
return <MobileTaskBoard />;
}
+271
View File
@@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import {
useA2AConversations,
useA2AMessages,
useCreateCeoConversation,
useSendCeoMessage,
} from "@/hooks/use-a2a-live";
import { CEO_SLUG } from "@/components/a2a/a2a-utils";
import { AgentSelector } from "@/components/agents/agent-selector";
import { EXCLUDE_NON_DM_ROLES } from "@/components/a2a/a2a-new-dm-dialog";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowLeft, MessageSquarePlus, Send } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
/** Thread polling cadence the /tg cockpit has no WS wiring (unlike the
* desktop A2A page), so the actively-viewed thread polls instead. */
const THREAD_POLL_MS = 10_000;
function ConversationList({
onSelect,
onCompose,
}: {
onSelect: (id: string, peerLabel: string) => void;
onCompose: () => void;
}) {
const { data, isLoading } = useA2AConversations(50);
return (
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2"
onClick={onCompose}
>
<MessageSquarePlus className="h-4 w-4" />
New chat
</Button>
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))
) : !data?.items.length ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No conversations yet
</p>
) : (
data.items.map((c) => {
const peer = c.agent_a === CEO_SLUG ? c.agent_b : c.agent_a;
const peerLabel = getAgentDisplayName(peer);
return (
<button
key={c.id}
type="button"
onClick={() => onSelect(c.id, peerLabel)}
className="flex w-full flex-col gap-0.5 rounded-lg border p-3 text-left"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{peerLabel}</span>
{c.last_message_at && (
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatDistanceToNow(new Date(c.last_message_at))} ago
</span>
)}
</div>
{c.last_message_preview && (
<p className="truncate text-xs text-muted-foreground">
{c.last_message_preview}
</p>
)}
</button>
);
})
)}
</div>
);
}
function ComposeNewChat({
onCreated,
onCancel,
}: {
onCreated: (id: string, peerSlug: string) => void;
onCancel: () => void;
}) {
const [target, setTarget] = useState<string | null>(null);
const [message, setMessage] = useState("");
const create = useCreateCeoConversation();
const submit = () => {
const trimmed = message.trim();
if (!target || !trimmed || create.isPending) return;
const targetAgent = target;
create.mutate(
{ target_agent: targetAgent, initial_message: trimmed },
{
onSuccess: (conversation) => onCreated(conversation.id, targetAgent),
onError: (err) => toast.error(getErrorMessage(err)),
},
);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="icon" onClick={onCancel}>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium">New chat</span>
</div>
<AgentSelector
value={target}
onChange={setTarget}
excludeRoles={EXCLUDE_NON_DM_ROLES}
placeholder="Who do you want to message?"
allowClear={false}
/>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message…"
className="min-h-[90px] resize-none"
disabled={create.isPending}
/>
<Button
type="button"
className="w-full"
disabled={!target || !message.trim() || create.isPending}
onClick={submit}
>
<Send className="mr-2 h-4 w-4" />
Send
</Button>
</div>
);
}
function ThreadView({
conversationId,
peerLabel,
onBack,
}: {
conversationId: string;
peerLabel: string;
onBack: () => void;
}) {
const { data, isLoading } = useA2AMessages(conversationId, {
refetchInterval: THREAD_POLL_MS,
});
const [draft, setDraft] = useState("");
const send = useSendCeoMessage();
const submit = () => {
const trimmed = draft.trim();
if (!trimmed || send.isPending) return;
send.mutate(
{ conversationId, content: trimmed },
{
onSuccess: () => setDraft(""),
onError: (err) => toast.error(getErrorMessage(err)),
},
);
};
// max-h (not flex-1/h-full) deliberately: the page root has no fixed
// height (other tabs need the outer layout scroll, not a clipped one), so
// a flex height chain here would have nothing definite to inherit. A
// capped, independently-scrolling message region is the simplest thing
// that actually scrolls regardless of ancestor height.
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 border-b pb-2">
<Button type="button" variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="font-medium">{peerLabel}</span>
</div>
<div className="max-h-[60dvh] space-y-2 overflow-y-auto py-1">
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : !data?.items.length ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No messages yet
</p>
) : (
data.items.map((m) => (
<div
key={m.id}
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
m.from_agent === CEO_SLUG
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{m.content}
</div>
))
)}
</div>
<div className="flex items-end gap-2 border-t pt-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Message…"
className="min-h-[44px] resize-none"
disabled={send.isPending}
/>
<Button
type="button"
size="icon"
disabled={!draft.trim() || send.isPending}
onClick={submit}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
);
}
type ChatView =
| { mode: "list" }
| { mode: "compose" }
| { mode: "thread"; id: string; peer: string };
/**
* A2A chat for the CEO's phone: a conversation list, a compose-new-DM
* picker, and a polled thread view the mobile-scoped equivalent of the
* desktop A2A admin page, built fresh rather than reusing its WS-wired,
* switchboard-heavy components (not a fit for a single thumb column).
*/
export function TgChatTab() {
const [view, setView] = useState<ChatView>({ mode: "list" });
if (view.mode === "compose") {
return (
<ComposeNewChat
onCreated={(id, peerSlug) =>
setView({ mode: "thread", id, peer: getAgentDisplayName(peerSlug) })
}
onCancel={() => setView({ mode: "list" })}
/>
);
}
if (view.mode === "thread") {
return (
<ThreadView
conversationId={view.id}
peerLabel={view.peer}
onBack={() => setView({ mode: "list" })}
/>
);
}
return (
<ConversationList
onSelect={(id, peerLabel) => setView({ mode: "thread", id, peer: peerLabel })}
onCompose={() => setView({ mode: "compose" })}
/>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import {
useNotifications,
useAcknowledgeNotification,
} from "@/hooks/use-notifications";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import type { Notification } from "@/types";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Bell, Check } from "lucide-react";
import { formatDistanceToNow } 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;
return (
<div
className={cn(
"rounded-lg border p-3",
notification.is_read ? "opacity-70" : "border-l-4 border-l-primary",
)}
>
<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>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
{notification.body}
</p>
<p className="mt-1.5 text-[11px] text-muted-foreground">
{getAgentDisplayName(notification.from_agent)} ·{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
</p>
</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.
*/
export function TgInboxTab() {
const { data, isLoading } = useNotifications();
if (isLoading) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
if (!data?.items.length) {
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>
);
}
return (
<div className="space-y-2">
{data.items.map((n) => (
<TgNotificationRow key={n.id} notification={n} />
))}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
export type TgTab = "approvals" | "inbox" | "board" | "chat";
const TABS: ReadonlyArray<{
id: TgTab;
label: string;
icon: typeof CheckSquare;
}> = [
{ id: "approvals", label: "Approvals", icon: CheckSquare },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "board", label: "Board", icon: Kanban },
{ id: "chat", label: "Chat", icon: MessageSquare },
];
interface TgTabBarProps {
active: TgTab;
onChange: (tab: TgTab) => void;
}
/**
* 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.
*/
export function TgTabBar({ active, onChange }: TgTabBarProps) {
return (
<nav
aria-label="Cockpit"
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-background pb-[env(safe-area-inset-bottom)]"
>
{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 flex-1 flex-col items-center gap-1 py-2.5 text-xs font-medium transition-colors",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
<tab.icon className="h-6 w-6" />
{tab.label}
</button>
);
})}
</nav>
);
}