Files
roboco/panel/src/components/tg/tg-inbox-tab.tsx
T
d1f9d21a68 fix(tg): mini-app responsiveness — safe-area activation, truncation, touch targets (#647)
Root cause first: no viewport export existed anywhere, so viewport-fit
was never 'cover' and every env(safe-area-inset-*) resolved to 0 on
notched iPhones — content under the status bar, dock without real
home-indicator clearance. The export lives on the (tg) group layout
(server component), NOT app-wide: the dashboard shell has no safe-area
padding and must not inherit cover.

Also: min-w-0 on four truncating flex children that overflowed their
justify-between rows (chat names/previews, fleet task titles);
object-contain on the approvals video (letterbox instead of distort on
short phones); break-words on the changelog pre / task description /
quoted mention; touch targets bumped to >=36px (sheet close, segmented
controls, ack button, chips, back button, bell, jump-to-latest,
cut-toggle); overflow-x-hidden backstop on the (tg) main scroller; fleet
avatar strip sliced to 3 with a +N badge instead of silent clipping.

Verified: pnpm typecheck clean, lint 0 errors, panel suite 870/870.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 20:29:57 +02:00

270 lines
8.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
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 { 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 { 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";
/** 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 min-h-12 w-full items-center gap-3 rounded-xl px-1.5 py-2",
!notification.is_read && "bg-primary/[0.04]",
)}
>
<TgRowIcon icon={Icon} tone={tone} />
<div className="min-w-0 flex-1">
<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>
)}
{text}
</p>
<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-9 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 — 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,
);
useEffect(() => {
if (!isTgDemoMode()) return;
void import("@/lib/telegram/demo-data").then((m) =>
setDemoItems(m.DEMO_NOTIFICATIONS),
);
}, []);
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-14 w-full rounded-xl" />
))}
</div>
);
}
if (items.length === 0) {
return (
<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="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>
);
}