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
+31
View File
@@ -0,0 +1,31 @@
import Script from "next/script";
/**
* Slim shell for the Telegram Mini App surface (`/tg`) — no Sidebar/Header/
* BottomTabBar, just a full-height scroll region. QueryClient/Theme/Toaster
* already come from the root layout's <Providers>, so nothing new is
* provided here.
*
* `beforeInteractive` is root-layout-only (Next.js throws outside
* app/layout.tsx), so this loads the Telegram bridge script with the default
* `afterInteractive` strategy instead — `waitForTelegramWebApp` (in
* lib/telegram/webapp.ts) briefly polls for `window.Telegram.WebApp` to
* absorb the resulting load race rather than assuming it's present on mount.
*/
export default function TelegramLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
<Script
src="https://telegram.org/js/telegram-web-app.js"
strategy="afterInteractive"
/>
<main className="flex-1 overflow-auto pt-[env(safe-area-inset-top)]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const { waitForTelegramWebApp } = vi.hoisted(() => ({
waitForTelegramWebApp: vi.fn(),
}));
vi.mock("@/lib/telegram/webapp", () => ({ waitForTelegramWebApp }));
const { post } = vi.hoisted(() => ({ post: vi.fn() }));
vi.mock("@/lib/api/client", () => ({
default: { post },
getErrorMessage: (err: unknown) =>
(err as { message?: string } | undefined)?.message ?? "Unknown error",
}));
// The cockpit tabs each fetch their own data (queue cards, tasks,
// notifications, A2A) — stubbed out here since this test only exercises the
// bootstrap state machine, not tab content (each tab gets its own coverage).
vi.mock("@/components/tg/tg-tab-bar", () => ({
TgTabBar: () => <div data-testid="tg-tab-bar" />,
}));
vi.mock("@/components/tg/tg-approvals-tab", () => ({
TgApprovalsTab: () => <div data-testid="tg-approvals-tab" />,
}));
vi.mock("@/components/tg/tg-inbox-tab", () => ({
TgInboxTab: () => <div data-testid="tg-inbox-tab" />,
}));
vi.mock("@/components/tg/tg-board-tab", () => ({
TgBoardTab: () => <div data-testid="tg-board-tab" />,
}));
vi.mock("@/components/tg/tg-chat-tab", () => ({
TgChatTab: () => <div data-testid="tg-chat-tab" />,
}));
import TelegramMiniAppPage from "../page";
function mockWebApp(initData = "abc123") {
return { ready: vi.fn(), expand: vi.fn(), initData };
}
describe("TelegramMiniAppPage — auth bootstrap", () => {
beforeEach(() => {
waitForTelegramWebApp.mockReset();
post.mockReset();
});
it("shows a spinner while validating", () => {
waitForTelegramWebApp.mockReturnValue(new Promise(() => {}));
render(<TelegramMiniAppPage />);
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
});
it("renders the not-inside-Telegram screen when no WebApp object exists", async () => {
waitForTelegramWebApp.mockResolvedValue(null);
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByText(/open from telegram/i)).toBeInTheDocument(),
);
expect(post).not.toHaveBeenCalled();
});
it("calls ready/expand, posts initData, and renders the cockpit on success", async () => {
const webApp = mockWebApp("real-init-data");
waitForTelegramWebApp.mockResolvedValue(webApp);
post.mockResolvedValue({ data: { ok: true } });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByTestId("tg-tab-bar")).toBeInTheDocument(),
);
expect(webApp.ready).toHaveBeenCalledTimes(1);
expect(webApp.expand).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", {
init_data: "real-init-data",
});
// Default tab is Approvals.
expect(screen.getByTestId("tg-approvals-tab")).toBeInTheDocument();
});
it("renders an error screen with the server's message when auth is refused", async () => {
waitForTelegramWebApp.mockResolvedValue(mockWebApp());
post.mockRejectedValue({ message: "Mini App disabled" });
render(<TelegramMiniAppPage />);
await waitFor(() =>
expect(screen.getByText(/couldn.t sign in/i)).toBeInTheDocument(),
);
expect(screen.getByText("Mini App disabled")).toBeInTheDocument();
expect(screen.queryByTestId("tg-tab-bar")).not.toBeInTheDocument();
});
});
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import api, { getErrorMessage } from "@/lib/api/client";
import { waitForTelegramWebApp } from "@/lib/telegram/webapp";
import { TgTabBar, type TgTab } from "@/components/tg/tg-tab-bar";
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";
type BootstrapState =
| { kind: "validating" }
| { kind: "ready" }
| { kind: "not_in_telegram" }
| { kind: "error"; message: string };
function CenteredMessage({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-[70dvh] flex-col items-center justify-center gap-3 p-6 text-center">
{children}
</div>
);
}
/**
* `/tg` — the CEO's phone cockpit. On mount: resolve the Telegram WebApp
* bridge, then POST its initData to the auth route unconditionally (the
* route is idempotent — it just re-mints the session cookie on every call)
* before rendering the tabbed cockpit. There's no way to read the resulting
* httponly session cookie client-side to skip this on a warm reload, so it
* always runs; it's cheap and the backend contract says so explicitly.
*/
export default function TelegramMiniAppPage() {
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
const [tab, setTab] = useState<TgTab>("approvals");
useEffect(() => {
let cancelled = false;
void (async () => {
const webApp = await waitForTelegramWebApp();
if (cancelled) return;
if (!webApp) {
setState({ kind: "not_in_telegram" });
return;
}
webApp.ready();
webApp.expand();
try {
await api.post("/telegram/webapp-auth", {
init_data: webApp.initData ?? "",
});
if (!cancelled) setState({ kind: "ready" });
} catch (err) {
if (!cancelled) {
setState({ kind: "error", message: getErrorMessage(err) });
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (state.kind === "validating") {
return (
<CenteredMessage>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Connecting</p>
</CenteredMessage>
);
}
if (state.kind === "not_in_telegram") {
return (
<CenteredMessage>
<ExternalLink className="h-10 w-10 text-muted-foreground" />
<h1 className="text-lg font-semibold">Open from Telegram</h1>
<p className="text-sm text-muted-foreground">
This cockpit only runs inside Telegram. Open it from the bot&apos;s
menu button.
</p>
</CenteredMessage>
);
}
if (state.kind === "error") {
return (
<CenteredMessage>
<AlertTriangle className="h-10 w-10 text-destructive" />
<h1 className="text-lg font-semibold">Couldn&apos;t sign in</h1>
<p className="text-sm text-muted-foreground">{state.message}</p>
</CenteredMessage>
);
}
return (
<div className="p-3 pb-20">
{tab === "approvals" && <TgApprovalsTab />}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
<TgTabBar active={tab} onChange={setTab} />
</div>
);
}
@@ -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>
);
}
+8 -1
View File
@@ -38,12 +38,19 @@ export function useA2AAdminPairs() {
// Transcript for one conversation. WS frames for the selected conversation
// invalidate this key; full bodies always come from REST (excerpts are capped).
export function useA2AMessages(conversationId: string | null) {
// `refetchInterval` defaults to off (the desktop A2A page relies on WS
// invalidation instead) — the /tg Mini App chat tab has no WS wiring, so it
// passes a ~10s interval to poll the thread it's actively viewing.
export function useA2AMessages(
conversationId: string | null,
options?: { refetchInterval?: number | false },
) {
return useQuery({
queryKey: a2aLiveKeys.messages(conversationId || ""),
queryFn: () => a2aApi.listAdminMessages(conversationId!),
enabled: !!conversationId,
staleTime: 30_000,
refetchInterval: options?.refetchInterval ?? false,
});
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Telegram Mini App WebApp bridge.
*
* Thin wrapper over the global `window.Telegram.WebApp` object injected by
* https://telegram.org/js/telegram-web-app.js (loaded by the `(tg)` layout).
* Only the handful of fields/methods the cockpit actually needs are typed —
* the real object carries far more (haptics, theme params, main button,
* etc.) that nothing here uses yet.
*/
export interface TelegramWebApp {
/** Signals the Mini App is ready to be displayed — hides Telegram's own
* loading placeholder. Safe to call more than once. */
ready: () => void;
/** Expands the Mini App to full height (past the default half-screen). */
expand: () => void;
/** Opaque, HMAC-signed payload proving this session came from Telegram —
* forwarded verbatim to `POST /api/telegram/webapp-auth`. Empty string
* when the WebApp object exists but wasn't launched with real init data
* (e.g. a bare browser tab pointed at the URL). */
initData: string;
}
declare global {
interface Window {
Telegram?: {
WebApp?: TelegramWebApp;
};
}
}
/** The live WebApp object, or null outside Telegram (or during SSR). */
export function getTelegramWebApp(): TelegramWebApp | null {
if (typeof window === "undefined") return null;
return window.Telegram?.WebApp ?? null;
}
/** Convenience accessor — "" when there's no WebApp (never null, so callers
* don't need a separate not-in-Telegram branch just to read this). */
export function getInitData(): string {
return getTelegramWebApp()?.initData ?? "";
}
const POLL_INTERVAL_MS = 100;
/**
* Resolves the WebApp object, waiting briefly for the CDN script to finish
* loading (it's fetched with `next/script`'s `afterInteractive` strategy, so
* it can still be in flight when this runs on mount). Resolves null once
* `timeoutMs` elapses with no `window.Telegram.WebApp` — the caller then
* knows for certain this isn't a Telegram launch, not just a slow network.
*
* ponytail: a plain poll loop, not a script `onLoad` event — the script tag
* lives in a layout the caller doesn't render, so there's no ref to hang a
* listener off; polling a global is the shortest correct thing here.
*/
export function waitForTelegramWebApp(
timeoutMs = 1500,
): Promise<TelegramWebApp | null> {
const existing = getTelegramWebApp();
if (existing) return Promise.resolve(existing);
if (typeof window === "undefined") return Promise.resolve(null);
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const timer = setInterval(() => {
const webApp = getTelegramWebApp();
if (webApp) {
clearInterval(timer);
resolve(webApp);
} else if (Date.now() >= deadline) {
clearInterval(timer);
resolve(null);
}
}, POLL_INTERVAL_MS);
});
}
+5 -2
View File
@@ -61,8 +61,11 @@ export const config = {
// Everything except the login page itself (avoids a redirect loop), API
// routes (nginx routes /api/* straight to the orchestrator in prod — this
// never sees them there; excluded defensively for a bare `next start`),
// Next's internal asset paths, and the static icon files at the app root.
// the Telegram Mini App surface (/tg authenticates via Telegram initData,
// not the password-login cookie — redirecting it to /login would strand a
// phone session that can never reach that page), Next's internal asset
// paths, and the static icon files at the app root.
matcher: [
"/((?!login|api|_next/static|_next/image|favicon.ico|apple-icon.png|icon.png).*)",
"/((?!login|api|tg(?:/|$)|_next/static|_next/image|favicon.ico|apple-icon.png|icon.png).*)",
],
};