mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user