mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): premium Mini App cockpit — spend hero, charts, avatars (#582)
* feat(tg): premium Today — spend hero, trend, quick actions, fleet avatars The cockpit home stops being flat cards and becomes a real app surface: - Spend HERO: the day's cost at 40px with a signed delta-vs-yesterday chip and a live 7-day amber area sparkline (hand-rolled inline SVG, no charting lib in the Mini App bundle). - Quick-action ring: circular Approve (amber + needs-you badge) / Board / Inbox / Chat, the wallet-style primary-verb row. - Needs-you as a rich amber gradient banner (top items + draft chips) instead of a plain section. - Fleet as live avatar tokens (stable per-name hue, pulse dot) over the working list. - "Shipped this week" day-bars (today emphasized) + week total. Backend: /telegram/today gains spend.series (7-day cost) + delta_pct and a velocity series (per-day completed tasks) — two cheap grouped-by-day queries, same DB-only ethos, degrading to zeros on error. * feat(tg): color-code approval rows by kind TgRowIcon gains a tone prop; the approvals list tints each tile per kind (amber Release / sky X post / violet Video / emerald Roadmap) so a mixed queue reads as color-coded instead of a monochrome column. * feat(tg): sender/peer avatars on Inbox + Chat Inbox notifications and Chat conversation rows adopt the fleet-avatar language: a per-name-hued initials token leads each card, unread inbox items carry a subtle primary tint, and both cards move to the rounded-2xl surface — so every tab now shares one visual system. Board keeps the shared MobileTaskBoard (already grouped/pill-styled, and reused outside the cockpit). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -34,8 +34,14 @@ function brief(overrides: Record<string, unknown> = {}) {
|
||||
roadmap_items: 0,
|
||||
},
|
||||
},
|
||||
fleet: { total: 3, by_status: { active: 3 }, working: [] },
|
||||
spend: { tokens_today: 1_234_000, cost_today_usd: 12.34 },
|
||||
fleet: { total: 3, by_status: { active: 3, idle: 0 }, working: [] },
|
||||
spend: {
|
||||
tokens_today: 1_234_000,
|
||||
cost_today_usd: 12.34,
|
||||
series: [1, 2, 3, 4, 5, 6, 12.34],
|
||||
delta_pct: 10,
|
||||
},
|
||||
velocity: { series: [1, 2, 0, 3, 1, 4, 2], week_total: 13 },
|
||||
ship: { version: "0.25.0", open_release_proposal: false, ci_fix_tasks: 0 },
|
||||
...overrides,
|
||||
};
|
||||
@@ -65,7 +71,7 @@ describe("TgTodayTab", () => {
|
||||
expect(screen.getByText(/1\.2M tokens/)).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.25.0")).toBeInTheDocument();
|
||||
expect(screen.getByText(/no release pending/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 agents/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 active/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders needs-you items and deep-links taps into the right tab", async () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Hand-rolled inline-SVG charts for the cockpit — no charting library in the
|
||||
* Mini App bundle. Both are theme-driven (stroke/fill ride `currentColor`,
|
||||
* so the caller sets the hue via a text color) and degrade to a flat
|
||||
* baseline for an all-zero series rather than dividing by zero.
|
||||
*/
|
||||
|
||||
const SPARK_W = 300;
|
||||
const SPARK_H = 72;
|
||||
|
||||
/** Smooth-ish area sparkline with a gradient fill and an emphasized last
|
||||
* point — the hero's spend trend. `values` oldest → newest. */
|
||||
export function Sparkline({ values }: { values: number[] }) {
|
||||
const n = values.length;
|
||||
const max = Math.max(...values, 0);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const gradId = "tg-spark-grad";
|
||||
|
||||
const x = (i: number) => (n <= 1 ? 0 : (i / (n - 1)) * SPARK_W);
|
||||
// Leave 6px headroom top/bottom so the stroke + endpoint dot never clip.
|
||||
const y = (v: number) => SPARK_H - 6 - ((v - min) / span) * (SPARK_H - 12);
|
||||
|
||||
const points = values.map((v, i) => [x(i), y(v)] as const);
|
||||
const line = points.map(([px, py]) => `${px},${py}`).join(" ");
|
||||
const area = `M0,${SPARK_H} L${line.replace(/ /g, " L")} L${SPARK_W},${SPARK_H} Z`;
|
||||
const [lastX, lastY] = points[points.length - 1] ?? [SPARK_W, SPARK_H / 2];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${SPARK_W} ${SPARK_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-16 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.28" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${gradId})`} />
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle cx={lastX} cy={lastY} r="3.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact day bars — the last bar (today) emphasized in the accent, the
|
||||
* rest muted. `values` oldest → newest. */
|
||||
export function DayBars({
|
||||
values,
|
||||
labels,
|
||||
}: {
|
||||
values: number[];
|
||||
labels?: string[];
|
||||
}) {
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-1.5" aria-hidden="true">
|
||||
{values.map((v, i) => {
|
||||
const isToday = i === values.length - 1;
|
||||
const pct = Math.round((v / max) * 100);
|
||||
return (
|
||||
<div key={i} className="flex flex-1 flex-col items-center gap-1">
|
||||
<div className="flex h-14 w-full items-end">
|
||||
<div
|
||||
className={`w-full rounded-sm ${
|
||||
isToday ? "bg-primary" : "bg-muted-foreground/25"
|
||||
}`}
|
||||
style={{ height: `${Math.max(pct, v > 0 ? 8 : 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{labels && (
|
||||
<span className="text-[9px] tabular-nums text-muted-foreground/60">
|
||||
{labels[i]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,12 +26,12 @@ import {
|
||||
|
||||
const KIND_META: Record<
|
||||
ApprovalItem["kind"],
|
||||
{ label: string; icon: typeof Rocket }
|
||||
{ label: string; icon: typeof Rocket; tone: string }
|
||||
> = {
|
||||
release: { label: "Release", icon: Rocket },
|
||||
x_post: { label: "X post", icon: MessageCircle },
|
||||
video_post: { label: "Video", icon: Clapperboard },
|
||||
roadmap: { label: "Roadmap", icon: MapIcon },
|
||||
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" },
|
||||
};
|
||||
|
||||
function itemTitle(item: ApprovalItem): string {
|
||||
@@ -58,7 +58,7 @@ function ItemRow({
|
||||
return (
|
||||
<div className="rounded-xl border bg-card text-card-foreground">
|
||||
<TgRow
|
||||
leading={<TgRowIcon icon={meta.icon} />}
|
||||
leading={<TgRowIcon icon={meta.icon} tone={meta.tone} />}
|
||||
title={itemTitle(item)}
|
||||
lines={2}
|
||||
meta={meta.label}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 { TgAvatar } from "@/components/tg/ui";
|
||||
import { ArrowLeft, MessageSquarePlus, Send } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
@@ -64,21 +65,24 @@ function ConversationList({
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(c.id, peerLabel)}
|
||||
className="flex w-full flex-col gap-0.5 rounded-xl border bg-card p-3 text-left text-card-foreground transition-colors active:bg-muted"
|
||||
className="flex w-full items-center gap-3 rounded-2xl border bg-card p-3 text-left text-card-foreground transition-colors active:bg-muted"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium">{peerLabel}</span>
|
||||
{c.last_message_at && (
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(c.last_message_at))} ago
|
||||
</span>
|
||||
<TgAvatar name={peerLabel} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium">{peerLabel}</span>
|
||||
{c.last_message_at && (
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(c.last_message_at))} ago
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c.last_message_preview && (
|
||||
<p className="truncate text-xs leading-snug text-muted-foreground">
|
||||
{c.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{c.last_message_preview && (
|
||||
<p className="truncate text-xs leading-snug text-muted-foreground">
|
||||
{c.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getErrorMessage } from "@/lib/api/client";
|
||||
import 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 { toast } from "sonner";
|
||||
@@ -17,41 +18,47 @@ import { cn } from "@/lib/utils";
|
||||
function TgNotificationRow({ notification }: { notification: Notification }) {
|
||||
const acknowledge = useAcknowledgeNotification();
|
||||
const needsAck = notification.requires_ack && !notification.is_acknowledged;
|
||||
const sender = getAgentDisplayName(notification.from_agent);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border bg-card p-3 text-card-foreground",
|
||||
notification.is_read ? "opacity-70" : "border-l-4 border-l-primary",
|
||||
"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]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug">
|
||||
{notification.subject}
|
||||
<TgAvatar name={sender} />
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{notification.body}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
{sender} ·{" "}
|
||||
{formatDistanceToNow(new Date(notification.timestamp))} ago
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,15 +8,26 @@ import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import type { TgTab } from "@/components/tg/tg-tab-bar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TgRow, TgSection, TgStat } from "@/components/tg/ui";
|
||||
import {
|
||||
TgAvatar,
|
||||
TgCircleAction,
|
||||
TgRow,
|
||||
TgSection,
|
||||
} from "@/components/tg/ui";
|
||||
import { DayBars, Sparkline } from "@/components/tg/charts";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
Bell,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
Kanban,
|
||||
MessageSquare,
|
||||
Rocket,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TodayTaskItem {
|
||||
id: string;
|
||||
@@ -45,7 +56,13 @@ export interface TodayBrief {
|
||||
task_title: string | null;
|
||||
}>;
|
||||
};
|
||||
spend: { tokens_today: number; cost_today_usd: number };
|
||||
spend: {
|
||||
tokens_today: number;
|
||||
cost_today_usd: number;
|
||||
series: number[];
|
||||
delta_pct: number | null;
|
||||
};
|
||||
velocity: { series: number[]; week_total: number };
|
||||
ship: {
|
||||
version: string;
|
||||
open_release_proposal: boolean;
|
||||
@@ -62,6 +79,8 @@ const DRAFT_LABELS: Record<string, string> = {
|
||||
roadmap_items: "Roadmap",
|
||||
};
|
||||
|
||||
const DAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
const compactNumber = new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
@@ -75,11 +94,142 @@ function taskMeta(task: TodayTaskItem): string {
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Day-of-week initials for the trailing window ending today. */
|
||||
function weekdayLabels(count: number): string[] {
|
||||
const today = new Date().getDay();
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => DAY_LABELS[(today - (count - 1 - i) + 7 * 2) % 7],
|
||||
);
|
||||
}
|
||||
|
||||
function SpendHero({ spend }: { spend: TodayBrief["spend"] }) {
|
||||
const delta = spend.delta_pct;
|
||||
const up = (delta ?? 0) >= 0;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border bg-gradient-to-b from-primary/[0.07] to-transparent p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] 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="text-[40px] font-semibold leading-none tracking-tight tabular-nums">
|
||||
${spend.cost_today_usd.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
|
||||
</span>
|
||||
</div>
|
||||
<div className="-mx-1 mt-2">
|
||||
<Sparkline values={spend.series} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsYouBanner({
|
||||
needs,
|
||||
onApprovals,
|
||||
onBoard,
|
||||
}: {
|
||||
needs: TodayBrief["needs_you"];
|
||||
onApprovals: () => void;
|
||||
onBoard: () => void;
|
||||
}) {
|
||||
const heldEntries = Object.entries(needs.held_drafts).filter(
|
||||
([, count]) => count > 0,
|
||||
);
|
||||
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>
|
||||
<p className="text-sm font-medium">All clear</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Nothing is waiting on you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2 rounded-2xl border border-primary/30 bg-primary/[0.08] p-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApprovals}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] 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" />
|
||||
</span>
|
||||
</button>
|
||||
{heldEntries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{heldEntries.map(([key, count]) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{DRAFT_LABELS[key] ?? key} · {count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(needs.awaiting_ceo.length > 0 || needs.blocked.length > 0) && (
|
||||
<div className="-mx-1.5 divide-y divide-primary/10">
|
||||
{needs.awaiting_ceo.slice(0, 2).map((t) => (
|
||||
<TgRow key={t.id} title={t.title} meta={taskMeta(t)} onPress={onBoard} />
|
||||
))}
|
||||
{needs.blocked.slice(0, 2).map((t) => (
|
||||
<TgRow
|
||||
key={t.id}
|
||||
title={t.title}
|
||||
meta={
|
||||
<>
|
||||
<span className="font-medium text-rose-400">blocked</span>
|
||||
{" · "}
|
||||
{taskMeta(t)}
|
||||
</>
|
||||
}
|
||||
onPress={onBoard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cockpit's home screen: one glance answering "does anything need me?"
|
||||
* — capped needs-you items, held-draft counts, fleet, today's spend, and
|
||||
* ship state, off the single aggregated `/telegram/today` round trip.
|
||||
* Row taps deep-link into the tab that acts on the item.
|
||||
* The cockpit home: a spend hero with a live 7-day trend, a quick-action
|
||||
* ring, the needs-you banner, the fleet as live avatars, and the week's
|
||||
* shipped-task velocity — off the single `/telegram/today` round trip.
|
||||
*/
|
||||
export function TgTodayTab({
|
||||
onNavigate,
|
||||
@@ -98,9 +248,6 @@ export function TgTodayTab({
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
|
||||
// Rides the shared /ws/system socket (ref-counted — no extra connection):
|
||||
// each USAGE_SNAPSHOT push refreshes the brief so the spend line tracks
|
||||
// the sweeper live; the poll above stays as the socket-down fallback.
|
||||
const { lastMessage } = useWebSocket<{ type?: string }>("/system");
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type !== "USAGE_SNAPSHOT") return;
|
||||
@@ -110,9 +257,9 @@ export function TgTodayTab({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
<Skeleton className="h-32 w-full rounded-2xl" />
|
||||
<Skeleton className="h-16 w-full rounded-2xl" />
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,134 +273,116 @@ export function TgTodayTab({
|
||||
);
|
||||
}
|
||||
|
||||
const { needs_you: needs, fleet, spend, ship } = data;
|
||||
const { needs_you: needs, fleet, spend, velocity, ship } = data;
|
||||
const go = (tab: TgTab) => {
|
||||
haptics.tap();
|
||||
onNavigate(tab);
|
||||
};
|
||||
const heldEntries = Object.entries(needs.held_drafts).filter(
|
||||
([, count]) => count > 0,
|
||||
);
|
||||
const idle = fleet.by_status.idle ?? 0;
|
||||
const active =
|
||||
fleet.by_status.active ?? Math.max(fleet.working.length, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<TgSection
|
||||
icon={CheckCircle2}
|
||||
title="Needs you"
|
||||
trailing={
|
||||
needs.total > 0 ? (
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold tabular-nums text-primary-foreground">
|
||||
{needs.total}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{needs.total === 0 ? (
|
||||
<p className="py-1.5 text-sm text-muted-foreground">
|
||||
All clear — nothing is waiting on you.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{heldEntries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-0.5">
|
||||
{heldEntries.map(([key, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => go("approvals")}
|
||||
className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium tabular-nums text-primary transition-colors active:bg-primary/20"
|
||||
>
|
||||
{DRAFT_LABELS[key] ?? key} · {count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="-mx-1.5 divide-y divide-border/60">
|
||||
{needs.awaiting_ceo.map((t) => (
|
||||
<TgRow
|
||||
key={t.id}
|
||||
title={t.title}
|
||||
meta={taskMeta(t)}
|
||||
onPress={() => go("board")}
|
||||
/>
|
||||
))}
|
||||
{needs.blocked.map((t) => (
|
||||
<TgRow
|
||||
key={t.id}
|
||||
title={t.title}
|
||||
meta={
|
||||
<>
|
||||
<span className="font-medium text-destructive">
|
||||
blocked
|
||||
</span>
|
||||
{" · "}
|
||||
{taskMeta(t)}
|
||||
</>
|
||||
}
|
||||
onPress={() => go("board")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TgSection>
|
||||
<div className="space-y-3">
|
||||
<SpendHero spend={spend} />
|
||||
|
||||
<div className="flex items-stretch gap-2 px-1">
|
||||
<TgCircleAction
|
||||
icon={CheckSquare}
|
||||
label="Approve"
|
||||
badge={needs.total}
|
||||
accent
|
||||
onPress={() => go("approvals")}
|
||||
/>
|
||||
<TgCircleAction icon={Kanban} label="Board" onPress={() => go("board")} />
|
||||
<TgCircleAction icon={Bell} label="Inbox" onPress={() => go("inbox")} />
|
||||
<TgCircleAction
|
||||
icon={MessageSquare}
|
||||
label="Chat"
|
||||
onPress={() => go("chat")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NeedsYouBanner
|
||||
needs={needs}
|
||||
onApprovals={() => go("approvals")}
|
||||
onBoard={() => go("board")}
|
||||
/>
|
||||
|
||||
<TgSection
|
||||
icon={Users}
|
||||
title="Fleet"
|
||||
trailing={
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{fleet.total} agents
|
||||
{Object.entries(fleet.by_status).map(
|
||||
([status, count]) => ` · ${count} ${status}`,
|
||||
)}
|
||||
{active} active · {idle} idle
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{fleet.working.length === 0 ? (
|
||||
<p className="py-1 text-sm text-muted-foreground">
|
||||
No one is mid-task.
|
||||
</p>
|
||||
<p className="py-1 text-sm text-muted-foreground">No one is mid-task.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{fleet.working.map((agent) => (
|
||||
<li
|
||||
key={agent.name}
|
||||
className="flex items-baseline gap-2 text-[13px] leading-snug"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-xs font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
{agent.task_title && (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{agent.task_title}
|
||||
<div className="space-y-2">
|
||||
<div className="flex -space-x-1.5 overflow-hidden">
|
||||
{fleet.working.map((a) => (
|
||||
<TgAvatar key={a.name} name={a.name} active />
|
||||
))}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{fleet.working.slice(0, 3).map((agent) => (
|
||||
<li
|
||||
key={agent.name}
|
||||
className="flex items-baseline gap-2 text-[13px] leading-snug"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-xs font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{agent.task_title && (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{agent.task_title}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</TgSection>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<TgSection icon={CircleDollarSign} title="Spend today">
|
||||
<TgStat
|
||||
value={`$${spend.cost_today_usd.toFixed(2)}`}
|
||||
caption={`${compactNumber.format(spend.tokens_today)} tokens`}
|
||||
<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>
|
||||
<TgSection icon={Rocket} title="Ship">
|
||||
<TgStat
|
||||
value={`v${ship.version}`}
|
||||
tone={ship.open_release_proposal ? "attention" : "default"}
|
||||
caption={
|
||||
ship.open_release_proposal
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ship.open_release_proposal && go("approvals")}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[22px] font-semibold leading-tight tracking-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"
|
||||
}
|
||||
/>
|
||||
: "No release pending"}
|
||||
</p>
|
||||
</button>
|
||||
</TgSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,90 @@ import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function TgCircleAction({
|
||||
icon: Icon,
|
||||
label,
|
||||
badge,
|
||||
accent = false,
|
||||
onPress,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
badge?: number;
|
||||
accent?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPress}
|
||||
className="flex flex-1 flex-col items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex h-12 w-12 items-center justify-center rounded-full transition-transform active:scale-95",
|
||||
accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const _AVATAR_HUES = [
|
||||
"bg-sky-500/20 text-sky-300",
|
||||
"bg-emerald-500/20 text-emerald-300",
|
||||
"bg-violet-500/20 text-violet-300",
|
||||
"bg-amber-500/20 text-amber-300",
|
||||
"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];
|
||||
return (
|
||||
<span className="relative inline-flex h-9 w-9 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-full text-[11px] font-semibold",
|
||||
hue,
|
||||
)}
|
||||
>
|
||||
{initials || "?"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function TgSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
@@ -93,10 +177,31 @@ export function TgRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Leading icon tile for rows — the grouped-list glyph square. */
|
||||
export function TgRowIcon({ icon: Icon }: { icon: LucideIcon }) {
|
||||
/** Leading icon tile for rows — the grouped-list glyph square. A `tone`
|
||||
* 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-amber-500/15 text-amber-400",
|
||||
sky: "bg-sky-500/15 text-sky-400",
|
||||
violet: "bg-violet-500/15 text-violet-400",
|
||||
emerald: "bg-emerald-500/15 text-emerald-400",
|
||||
muted: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
export function TgRowIcon({
|
||||
icon: Icon,
|
||||
tone = "muted",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
tone?: keyof typeof _TILE_TONES | string;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg",
|
||||
_TILE_TONES[tone] ?? _TILE_TONES.muted,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4.5 w-4.5" />
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -137,6 +137,12 @@ export const DEMO_TODAY: TodayBrief = {
|
||||
{ name: "ux-dev-2", role: "developer", team: "ux_ui", task_title: "v0.26.0 release motion" },
|
||||
],
|
||||
},
|
||||
spend: { tokens_today: 2_400_000, cost_today_usd: 18.72 },
|
||||
spend: {
|
||||
tokens_today: 2_400_000,
|
||||
cost_today_usd: 18.72,
|
||||
series: [12.4, 9.1, 15.8, 11.2, 21.6, 14.9, 18.72],
|
||||
delta_pct: 25.6,
|
||||
},
|
||||
velocity: { series: [3, 5, 2, 6, 4, 7, 5], week_total: 32 },
|
||||
ship: { version: "0.25.0", open_release_proposal: true, ci_fix_tasks: 0 },
|
||||
};
|
||||
|
||||
@@ -71,6 +71,13 @@ class TodayFleet(BaseModel):
|
||||
class TodaySpend(BaseModel):
|
||||
tokens_today: int
|
||||
cost_today_usd: float
|
||||
series: list[float] = []
|
||||
delta_pct: float | None = None
|
||||
|
||||
|
||||
class TodayVelocity(BaseModel):
|
||||
series: list[int] = []
|
||||
week_total: int = 0
|
||||
|
||||
|
||||
class TodayShip(BaseModel):
|
||||
@@ -85,4 +92,5 @@ class TelegramTodayResponse(BaseModel):
|
||||
needs_you: TodayNeedsYou
|
||||
fleet: TodayFleet
|
||||
spend: TodaySpend
|
||||
velocity: TodayVelocity
|
||||
ship: TodayShip
|
||||
|
||||
@@ -11,13 +11,17 @@ red.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import cast as sql_cast
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.types import Date
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import TaskTable
|
||||
from roboco.db.tables import AgentSpawnSessionTable, TaskTable
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.dashboard import get_dashboard_service
|
||||
from roboco.services.task import get_task_service
|
||||
@@ -33,6 +37,8 @@ if TYPE_CHECKING:
|
||||
# Phone-screen caps: the brief shows the top few and a count, never a feed.
|
||||
_TASK_ITEM_CAP = 5
|
||||
_WORKING_AGENT_CAP = 8
|
||||
# Trailing window for the hero spend sparkline and the velocity bars.
|
||||
_SERIES_DAYS = 7
|
||||
|
||||
|
||||
def _task_item(task: TaskTable) -> dict[str, Any]:
|
||||
@@ -57,6 +63,7 @@ class TgCockpitService(BaseService):
|
||||
"needs_you": needs_you,
|
||||
"fleet": await self.fleet(),
|
||||
"spend": await self._spend(),
|
||||
"velocity": await self._velocity(),
|
||||
"ship": {
|
||||
"version": settings.app_version,
|
||||
"open_release_proposal": needs_you["held_drafts"]["release_proposals"]
|
||||
@@ -65,6 +72,11 @@ class TgCockpitService(BaseService):
|
||||
},
|
||||
}
|
||||
|
||||
def _window_dates(self) -> list[date]:
|
||||
"""The last ``_SERIES_DAYS`` calendar dates (UTC), oldest → today."""
|
||||
today = datetime.now(UTC).date()
|
||||
return [today - timedelta(days=n) for n in reversed(range(_SERIES_DAYS))]
|
||||
|
||||
async def _needs_you(self, tasks: TaskService) -> dict[str, Any]:
|
||||
awaiting = await tasks.list_awaiting_ceo_approval()
|
||||
blocked = await tasks.list_blocked()
|
||||
@@ -130,13 +142,74 @@ class TgCockpitService(BaseService):
|
||||
# brief to zeros instead of failing the whole endpoint.
|
||||
try:
|
||||
summary = await get_usage_service(self.session).get_today_summary()
|
||||
series = await self._spend_series()
|
||||
today = series[-1] if series else 0.0
|
||||
prior = series[-2] if len(series) >= 2 else 0.0 # noqa: PLR2004
|
||||
return {
|
||||
"tokens_today": int(summary.get("tokens_today", 0)),
|
||||
"cost_today_usd": float(summary.get("cost_today_usd", 0.0)),
|
||||
"series": series,
|
||||
"delta_pct": _pct_change(today, prior),
|
||||
}
|
||||
except Exception: # pragma: no cover - defensive degradation
|
||||
self.log.warning("today-brief usage summary failed", exc_info=True)
|
||||
return {"tokens_today": 0, "cost_today_usd": 0.0}
|
||||
return {
|
||||
"tokens_today": 0,
|
||||
"cost_today_usd": 0.0,
|
||||
"series": [0.0] * _SERIES_DAYS,
|
||||
"delta_pct": None,
|
||||
}
|
||||
|
||||
async def _spend_series(self) -> list[float]:
|
||||
"""Per-day cost (USD) over the trailing window, zero-filled — the
|
||||
hero sparkline. Grouped by the spawn session's start date, matching
|
||||
what today's spend counts."""
|
||||
day = sql_cast(AgentSpawnSessionTable.started_at, Date).label("day")
|
||||
result = await self.session.execute(
|
||||
select(
|
||||
day,
|
||||
func.coalesce(
|
||||
func.sum(AgentSpawnSessionTable.estimated_cost_usd), 0.0
|
||||
).label("cost"),
|
||||
)
|
||||
.where(
|
||||
sql_cast(AgentSpawnSessionTable.started_at, Date)
|
||||
>= self._window_dates()[0]
|
||||
)
|
||||
.group_by(day)
|
||||
)
|
||||
by_day = {row.day: float(row.cost) for row in result}
|
||||
return [round(by_day.get(d, 0.0), 4) for d in self._window_dates()]
|
||||
|
||||
async def _velocity(self) -> dict[str, Any]:
|
||||
"""Per-day completed-task counts over the trailing window (the
|
||||
'shipped this week' bars) plus the window total."""
|
||||
try:
|
||||
day = sql_cast(TaskTable.completed_at, Date).label("day")
|
||||
result = await self.session.execute(
|
||||
select(day, func.count(TaskTable.id).label("n"))
|
||||
.where(
|
||||
TaskTable.status == TaskStatus.COMPLETED,
|
||||
TaskTable.completed_at.isnot(None),
|
||||
sql_cast(TaskTable.completed_at, Date) >= self._window_dates()[0],
|
||||
)
|
||||
.group_by(day)
|
||||
)
|
||||
by_day = {row.day: int(row.n) for row in result}
|
||||
series = [by_day.get(d, 0) for d in self._window_dates()]
|
||||
return {"series": series, "week_total": sum(series)}
|
||||
except Exception: # pragma: no cover - defensive degradation
|
||||
self.log.warning("today-brief velocity failed", exc_info=True)
|
||||
return {"series": [0] * _SERIES_DAYS, "week_total": 0}
|
||||
|
||||
|
||||
def _pct_change(current: float, prior: float) -> float | None:
|
||||
"""Signed percent change vs the prior day; None when there's no prior
|
||||
baseline to compare against (a first day of spend shouldn't read as an
|
||||
infinite spike)."""
|
||||
if prior <= 0:
|
||||
return None
|
||||
return round((current - prior) / prior * 100, 1)
|
||||
|
||||
|
||||
def get_tg_cockpit_service(session: AsyncSession) -> TgCockpitService:
|
||||
|
||||
@@ -120,7 +120,9 @@ async def test_today_brief_shape(db_session: AsyncSession) -> None:
|
||||
"""Structure + invariants that hold regardless of shared-DB residue."""
|
||||
brief = await get_tg_cockpit_service(db_session).today()
|
||||
|
||||
assert set(brief) == {"needs_you", "fleet", "spend", "ship"}
|
||||
assert set(brief) == {"needs_you", "fleet", "spend", "velocity", "ship"}
|
||||
assert len(brief["spend"]["series"]) == 7 # noqa: PLR2004
|
||||
assert len(brief["velocity"]["series"]) == 7 # noqa: PLR2004
|
||||
needs = brief["needs_you"]
|
||||
assert needs["total"] == (
|
||||
needs["awaiting_ceo_count"]
|
||||
|
||||
Reference in New Issue
Block a user