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:
Renzo F
2026-07-19 11:51:06 +02:00
committed by GitHub
co-authored by Renn F
parent 5f32d8760a
commit c7605b0d77
11 changed files with 606 additions and 172 deletions
@@ -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 () => {
+94
View File
@@ -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>
);
}
+6 -6
View File
@@ -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}
+16 -12
View File
@@ -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>
);
})
+34 -27
View File
@@ -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>
);
}
+245 -116
View File
@@ -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>
+108 -3
View File
@@ -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>
);
+7 -1
View File
@@ -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 },
};
+8
View File
@@ -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
+76 -3
View File
@@ -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:
+3 -1
View File
@@ -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"]