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