feat(tg): Mini App V6 — premium overhaul (#609)

* feat(tg): Mini App V6 — premium overhaul (design system, Chat parity, Metrics drilldown, CEO verbs)

Design system: native type with tabular-numeral heroes (mono demoted to
the wordmark), borderless elevated cards, floating dock, Telegram
window-chrome painting via the theme bridge; Inbox moves behind a header
bell with humanized notifications (UUIDs resolve to task names).

Chat: honest Mine/Fleet split — participant-scoped CEO threads with real
unread counts and mark-read, watched fleet threads with reply-as-CEO on
task-linked conversations (watch-only otherwise), markdown transcripts,
live pulse flashes, and a pinned Secretary live chat on the panel's SSE
session runtime.

Metrics: new tab with period-segmented spend hero, by-agent/team/model
breakdowns, delivery + efficiency health, and a per-agent drilldown over
usage time-series (agent_slug) + member scorecard.

Board: tg-native grouped pipeline replacing the MobileTaskBoard wrapper;
task sheet gains the CEO decide verbs (approve / request changes /
unblock).

Security: /api/dashboard router now require_panel_token-gated at router
level (mirrors /api/usage), closing unauthenticated metrics exposure.

* fix(tg): restore Share Tech Mono brand voice, Phosphor icon set, borderless avatars

The mono returns as the numeral/brand voice (.tg-display — heroes, stat
values, wordmark) while labels stay native sentence case. The hand-drawn
duotone glyphs and lucide feature icons are replaced by Phosphor (MIT):
duotone at rest via an IconContext at the shell, filled weight on the
dock's active tab; row glyph maps (board statuses, inbox kinds, approval
kinds, quick actions) all move over. Team avatar tiles drop their borders
— tint-only squircles.

* fix(tg): fleet avatar strip breathes — spaced tiles instead of overlap

* polish(tg): taste-skill audit pass — em-dash purge, one icon family, separator rationing

Applied the design-taste audit against the cockpit: every em-dash in
visible UI copy rewritten (periods/commas/colons), the remaining lucide
chrome (carets, arrows, send, close, spinners) moved to Phosphor so the
tg tree ships one icon family (send is the native paper-plane, carets
bold), the hand-rolled chevron SVG deleted, and metadata lines rationed
to a single middle-dot separator.

* polish(tg): pipeline chip strip scrolls without a visible scrollbar

* fix(tests): metrics observability fixture uses a relative timestamp

The hardcoded _T0 (2026-06-20) aged out of the service's 30-day window
exactly 30 days later, detonating the suite on every branch. Two days
back from now() stays inside every window (30d metrics, 7d scorecards)
permanently.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-20 17:29:22 +02:00
committed by GitHub
co-authored by Renn F
parent cd73ad6a74
commit 3c5ee46347
41 changed files with 4227 additions and 812 deletions
@@ -3,7 +3,7 @@
import { useMainButton } from "@/lib/telegram/hooks";
import { useTgWebApp } from "@/lib/telegram/hooks";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { CircleNotch } from "@phosphor-icons/react";
/**
* The focused card's one primary action. Inside Telegram it drives the
@@ -26,12 +26,10 @@ export function PrimaryAction({
useMainButton({ text, visible: true, disabled, loading, onClick });
if (webApp?.MainButton) return null;
return (
<Button
className="w-full"
disabled={disabled || loading}
onClick={onClick}
>
{loading && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
<Button className="w-full" disabled={disabled || loading} onClick={onClick}>
{loading && (
<CircleNotch weight="bold" className="mr-1.5 h-4 w-4 animate-spin" />
)}
{text}
</Button>
);
@@ -6,8 +6,10 @@ import { releaseApi, type ReleaseProposal } from "@/lib/api/release";
import { getErrorMessage } from "@/lib/api/client";
import { haptics } from "@/lib/telegram/webapp";
import { Badge } from "@/components/ui/badge";
import { TgSection } from "@/components/tg/ui";
import { PrimaryAction } from "./primary-action";
import { RejectForm } from "./reject-form";
import { cn } from "@/lib/utils";
const MIN_REJECT_CHARS = 10;
@@ -70,59 +72,59 @@ export function ReleaseDetail({
<div className="flex flex-wrap items-center gap-1.5">
<Badge>v{report.proposed_version}</Badge>
<Badge variant="secondary">{report.bump_kind}</Badge>
<Badge
variant={report.gate_state === "green" ? "secondary" : "destructive"}
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
report.gate_state === "green"
? "bg-emerald-500/15 text-emerald-300"
: "bg-rose-500/15 text-rose-300",
)}
>
gate: {report.gate_state}
</Badge>
</span>
</div>
{inFlight && (
<p className="rounded-md bg-primary/10 p-2 text-xs">
<p className="rounded-xl bg-primary/10 p-2 text-xs text-primary">
Execute is running in the background (~40 min). This card updates
itself.
</p>
)}
{executeFailed && (
<p className="rounded-md bg-destructive/10 p-2 text-xs text-destructive">
<p className="rounded-xl bg-rose-500/10 p-2 text-xs text-rose-300">
Last execute failed ({proposal.execute_status})
{proposal.execute_detail ? `: ${proposal.execute_detail}` : ""}
</p>
)}
{report.gaps.length > 0 && (
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Gaps
</h3>
{report.gaps.map((gap, i) => (
<p key={i} className="text-xs text-muted-foreground">
[{gap.category}] {gap.detail}
</p>
))}
</div>
<TgSection title="Gaps">
<div className="space-y-1">
{report.gaps.map((gap, i) => (
<p key={i} className="text-xs text-muted-foreground">
[{gap.category}] {gap.detail}
</p>
))}
</div>
</TgSection>
)}
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Changelog draft
</h3>
<pre className="whitespace-pre-wrap rounded-md bg-muted p-2 text-xs leading-relaxed">
<TgSection title="Changelog draft">
<pre className="whitespace-pre-wrap rounded-xl bg-muted p-2 text-xs leading-relaxed">
{report.drafted_changelog}
</pre>
</div>
</TgSection>
{report.migration_notes.length > 0 && (
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Migrations
</h3>
{report.migration_notes.map((note, i) => (
<p key={i} className="text-xs">
{note}
</p>
))}
</div>
<TgSection title="Migrations">
<div className="space-y-1">
{report.migration_notes.map((note, i) => (
<p key={i} className="text-xs">
{note}
</p>
))}
</div>
</TgSection>
)}
<PrimaryAction
@@ -6,6 +6,7 @@ import { roadmapApi, type RoadmapItem } from "@/lib/api/roadmap";
import { getErrorMessage } from "@/lib/api/client";
import { haptics } from "@/lib/telegram/webapp";
import { Badge } from "@/components/ui/badge";
import { TgSection } from "@/components/tg/ui";
import { PrimaryAction } from "./primary-action";
import { RejectForm } from "./reject-form";
@@ -74,23 +75,17 @@ export function RoadmapItemDetail({
<p className="text-sm leading-relaxed">{item.description}</p>
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Why
</h3>
<TgSection title="Why">
<p className="text-xs text-muted-foreground">{item.rationale}</p>
</div>
</TgSection>
<div className="space-y-1">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Acceptance criteria
</h3>
<TgSection title="Acceptance criteria">
<ul className="list-disc space-y-0.5 pl-4 text-xs">
{item.acceptance_criteria.map((ac, i) => (
<li key={i}>{ac}</li>
))}
</ul>
</div>
</TgSection>
<PrimaryAction
text="Approve → backlog"
@@ -29,7 +29,9 @@ const CUT_LABELS: Record<VideoCut, string> = {
*/
function CutPlayer({ post }: { post: VideoPost }) {
const paths = post.mp4_paths ?? {};
const [cut, setCut] = useState<VideoCut>(paths.vertical ? "vertical" : "square");
const [cut, setCut] = useState<VideoCut>(
paths.vertical ? "vertical" : "square",
);
// url === null means the fetch for that cut failed; a stale entry for a
// different cut is simply ignored in render, so no synchronous state
// reset is needed when the cut changes.
@@ -87,7 +89,9 @@ function CutPlayer({ post }: { post: VideoPost }) {
playsInline
className={cn(
"w-full rounded-md bg-black",
cut === "vertical" ? "aspect-[9/16] max-h-[60dvh]" : "aspect-square",
cut === "vertical"
? "aspect-[9/16] max-h-[60dvh]"
: "aspect-square",
)}
/>
) : (
@@ -134,15 +138,15 @@ function CaptionEditor({
disabled={!editing}
onChange={(e) => onChange(e.target.value)}
rows={3}
className={cn("text-sm", editing && overLimit && "border-destructive")}
className={cn("text-sm", editing && overLimit && "border-rose-400/60")}
/>
<p
className={cn(
"text-right text-[11px] tabular-nums",
editing && overLimit ? "text-destructive" : "text-muted-foreground",
editing && overLimit ? "text-rose-400" : "text-muted-foreground",
)}
>
{caption.length}/{maxChars}
{caption.length} / {maxChars}
</p>
</div>
);
@@ -196,7 +200,8 @@ export function VideoPostDetail({
const reject = useMutation({
mutationFn: (reason: string) => videoApi.reject(post.task_id, reason),
onSuccess: () => finish(true, "Draft rejected — feedback goes back to the author."),
onSuccess: () =>
finish(true, "Draft rejected. Feedback goes back to the author."),
onError: (err) => {
haptics.error();
toast.error(getErrorMessage(err));
@@ -213,7 +218,9 @@ export function VideoPostDetail({
</Badge>
))}
{post.render_status === "failed" && (
<Badge variant="destructive">render failed</Badge>
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-xs font-medium text-rose-300">
render failed
</span>
)}
</div>
@@ -91,15 +91,15 @@ export function XPostDetail({
value={body}
onChange={(e) => setEdited(e.target.value)}
rows={5}
className={cn("text-sm", overLimit && "border-destructive")}
className={cn("text-sm", overLimit && "border-rose-400/60")}
/>
<p
className={cn(
"text-right text-[11px] tabular-nums",
overLimit ? "text-destructive" : "text-muted-foreground",
overLimit ? "text-rose-400" : "text-muted-foreground",
)}
>
{body.length}/{MAX_TWEET_CHARS}
{body.length} / {MAX_TWEET_CHARS}
</p>
</div>