mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): Mini App V4 — Today brief, native approvals, live data, bot tier, chat bridges (#576)
* feat(tg): P0 — dev mock bridge + Telegram-native foundations
Mini App V4 phase 0. The (tg) shell gains the groundwork every later
phase builds on:
- Dev mock bridge: outside Telegram, a development build falls back to a
no-op WebApp object and skips the webapp-auth POST (the regular panel
session cookie authorizes API calls), so the cockpit is workable in a
plain browser. Production keeps the "Open from Telegram" wall.
- Telegram theme adoption: themeParams map onto the shadcn CSS variables
scoped to #tg-shell (desktop dashboard untouched), colorScheme drives
the dark class, themeChanged re-applies live. Non-hex values are
dropped at the trust boundary.
- Viewport/swipe correctness: shell height rides Telegram's own
--tg-viewport-stable-height (100dvh fallback), vertical swipe-to-close
disabled so list scrolling can't dismiss the app.
- Native chrome bindings: TgWebAppProvider context plus useMainButton /
useBackButton declarative hooks and a null-safe haptics helper —
consumers never touch window.Telegram directly.
* feat(tg): P1 — Today home tab + one-round-trip /telegram/today brief
Mini App V4 phase 1: the cockpit now opens on a "Today" brief answering
"does anything need me?" in one glance.
Backend: GET /api/telegram/today (CEO-gated, rate-limited) returns the
whole brief in one round trip via the new TgCockpitService — needs-you
items (awaiting-CEO + blocked tasks capped for a phone screen, held-draft
counts across release/X/video/roadmap queues), a fleet snapshot with
per-agent current-task titles, today's spend from the day rollup
(degrading to zeros on a usage hiccup, mirroring the CEO overview), and
ship state. Deliberately DB-only: no live GitHub calls, no readiness
snapshot (that path clones), no orchestrator singleton — the CI
red/green proxy is the set of open ci_watch fix tasks.
Panel: TgTodayTab is the new default tab (Gauge icon) — needs-you rows
and draft chips deep-link into the tab that acts on them (with a haptic
tap), fleet/spend/ship render as dense cards, 45s refetch until the P3
WebSocket wiring lands.
* fix(tg): dev mock engages when the CDN bridge loads outside Telegram
Live browser smoke caught it: a bare tab still loads telegram-web-app.js,
so window.Telegram.WebApp EXISTS outside Telegram — just with empty
initData. The dev fallback keyed on a null bridge only, so a dev browser
went down the real-auth path and posted empty initData instead of
mounting the mock. The fallback now treats bridge-with-no-initData the
same as no bridge (a real Telegram launch always carries initData);
production behavior is unchanged.
* feat(tg): P2 — native approvals card stack
Mini App V4 phase 2: the Approvals tab stops stacking the four desktop
queue cards and becomes a phone-native flow — one normalized list across
release proposal / X drafts / video drafts / proposed roadmap items, and
a full-context detail per item:
- Release: version/bump/gate badges, changelog draft, gaps, migration
notes, in-flight + failed-execute banners; approve runs the fail-closed
executor, reject requires a substantive change request (10 chars).
- X: editable body with the live 280 counter, replied-to mention quoted;
approve sends the edited body only when actually edited.
- Video: cut-toggled player (blob-fetched through the authed client — a
bare <video src> would 401), per-platform caption edits with 280/2200
counters; approve sends only checked-in edits.
- Roadmap: the PO's full pitch (description, rationale, ACs); approve
materializes into the backlog per item.
The detail's primary action rides Telegram's native MainButton and back
navigation rides the BackButton, with visible fallbacks outside Telegram
(dev mock, old clients). Haptics fire on outcomes. An acted-on item
vanishes from the refetched queue, popping back to the list by
construction. A failed queue source is surfaced ("list may be
incomplete" / "couldn't load") instead of masquerading as an empty
queue — caught live in the browser smoke.
* feat(tg): dev demo mode — /tg?demo=1 renders canned cockpit data
Development-only: with the flag param present, the Today brief and the
four approval queues resolve typed fixtures (dynamically imported, so
production bundles never carry them) instead of hitting the backend —
the cockpit is fully browsable with zero stack running. Mutations still
go to the real API and fail loudly; it's a showroom, not a simulator.
* feat(tg): P3 — cockpit rides /ws/system live
Chat adopts the desktop A2A invalidate-on-frame idiom over the shared
ref-counted /ws/system socket: every a2a.message frame refreshes the
conversation list and the affected thread, missed-frame gaps are healed
by a reconnect refetch, and the 10s thread poll turns off entirely while
the socket is up (it remains the fallback). The Today brief refreshes on
each USAGE_SNAPSHOT push so the spend line tracks the sweeper live, with
the 45s poll as the socket-down fallback. No new sockets, no backend
changes — the WS gate already accepts the cloud-auth session cookie.
* feat(tg): P4 — deterministic bot command tier + self-syncing menu
Mini App V4 phase 4 (deterministic half): three new bot commands beside
/status /queue /task —
- /agents: who's mid-task right now, from the same TgCockpitService
fleet snapshot the Today brief renders (now public `fleet()`).
- /usage: today's spend from the day rollup.
- /blocked: awaiting-you + blocked tasks, deep-linked into the panel,
capped per section, titles HTML-escaped.
BOT_COMMANDS is the single registry driving /help AND a once-per-process
Bot API setMyCommands sync on the first poll cycle (new client method,
best-effort), so the Telegram command menu can never drift from what the
code implements. The interactive tier (/secretary, /newtask riding a
live Intake interview in-thread) is specced but not in this commit.
* feat(tg): direction-C styling pass — Telegram palette, RoboCo voice
The cockpit stops wearing default-shadcn and gets its own visual
language on top of the P0 themeParams bridge (colors stay CSS-variable
driven, so inside Telegram everything still adopts the user's theme):
- Shared primitives (components/tg/ui.tsx): TgSection grouped cards with
tracked micro-label headers, TgRow list rows (44px targets, press
feedback, 1/2-line clamp), TgRowIcon glyph tiles, TgStat tabular-nums
figures. Every tab composes the same three, so density and rhythm are
identical across the surface.
- Shell renders a centered 430px column (sm:border-x) — the phone UI no
longer stretches across a desktop dev browser.
- Tab bar: tighter type, active stroke-weight shift, backdrop blur.
- Today: needs-you count badge, divided task rows with inline blocked
marker, fleet as mono-named rows, spend/ship as stat tiles.
- Approvals rows as icon-tile cards; detail header gains the kind glyph.
- Inbox/Chat rows aligned to the same card language.
* feat(tg): P5 — /secretary and /newtask live-chat bridges
The bot's interactive tier: both commands bridge the CEO's Telegram chat
into the same in-process runtimes the panel drives — the persistent
Secretary container and the scoped Intake interview.
There is no synchronous send→reply seam (replies land on the session's
single-consumer relay queue), so each bridged session runs one long-lived
consumer task (roboco/services/telegram_bridge.py) that drains
PrompterLiveRegistry.stream and pushes one Telegram message per completed
turn. While a session is live, plain chat text IS the conversation;
/end closes it.
/newtask resolves the intake scope (single project auto-picked, multiple
offered as a tap-to-pick keyboard holding the initial text), and the
interview happens in-thread. A draft proposal renders as a card with
Send-to-Board / Discard buttons: confirm routes through the normal
board-review path (PrompterService.confirm_live_draft, route=board) and
PARKS the session — board feedback later streams straight back into the
same thread, closing the redraft loop from the phone. MegaTask batches
still confirm in the panel only.
The consumer's open stream arms the registry's 60s keepalive, so the
bridge runs its own idle TTL (same setting, parked sessions exempt).
State is per-process in-memory by design (the _PENDING_REPLIES posture);
intake/secretary containers are process-wide singletons, so a bridged
session preempts a live panel session of the same kind by construction.
* feat(tg): cockpit skin — RoboCo dark deck with a constant amber accent
The cockpit no longer inherits the dashboard's white default outside
Telegram: #tg-shell carries its own standing skin (deep slate surfaces,
amber primary) so the Mini App looks like RoboCo everywhere. Inside
Telegram the themeParams bridge now overrides SURFACE tokens only —
background/card/text/hint/border repaint to the user's Telegram theme
while --primary/--ring stay RoboCo amber: Telegram's surfaces, RoboCo's
voice. Demo fixtures also rewritten to neutral content (they previously
depicted unbuilt forge work and already-shipped roadmap items as live).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useMainButton } from "@/lib/telegram/hooks";
|
||||
import { useTgWebApp } from "@/lib/telegram/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* The focused card's one primary action. Inside Telegram it drives the
|
||||
* native MainButton (and renders nothing); outside — the dev mock, an old
|
||||
* client — it falls back to a visible full-width button. Mount at most one
|
||||
* per screen: the MainButton is global singleton chrome.
|
||||
*/
|
||||
export function PrimaryAction({
|
||||
text,
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
}: {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const webApp = useTgWebApp();
|
||||
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" />}
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
/**
|
||||
* Collapsible reject-with-reason form shared by every approval detail.
|
||||
* The minimum length mirrors each queue's server guard (release wants a
|
||||
* substantive change request, the rest a short reason).
|
||||
*/
|
||||
export function RejectForm({
|
||||
minChars,
|
||||
placeholder,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
minChars: number;
|
||||
placeholder: string;
|
||||
pending: boolean;
|
||||
onSubmit: (reason: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const tooShort = reason.trim().length < minChars;
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-destructive"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Reject…
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={pending || tooShort}
|
||||
onClick={() => onSubmit(reason.trim())}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{tooShort && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
At least {minChars} characters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
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 { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
|
||||
const MIN_REJECT_CHARS = 10;
|
||||
|
||||
/**
|
||||
* Focused release proposal: version/gate at a glance, the drafted
|
||||
* changelog, gaps and migration notes, then approve (runs the fail-closed
|
||||
* ~40min executor server-side) or reject-with-required-changes.
|
||||
*/
|
||||
export function ReleaseDetail({
|
||||
proposal,
|
||||
onDone,
|
||||
}: {
|
||||
proposal: ReleaseProposal;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const report = proposal.report;
|
||||
const inFlight = proposal.execute_in_flight === true;
|
||||
const executeFailed = !inFlight && !!proposal.execute_status;
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => releaseApi.approve(),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
|
||||
if (result.status === "published") {
|
||||
haptics.success();
|
||||
toast.success(`Published v${result.version}`);
|
||||
onDone();
|
||||
} else if (result.status === "accepted") {
|
||||
haptics.success();
|
||||
toast.info("Approved — executing in the background (~40 min).");
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(`Release halted (${result.status}): ${result.detail}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (changes: string) => releaseApi.reject(changes),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
|
||||
haptics.success();
|
||||
toast.success("Rejected — changes requested.");
|
||||
onDone();
|
||||
},
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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"}
|
||||
>
|
||||
gate: {report.gate_state}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{inFlight && (
|
||||
<p className="rounded-md bg-primary/10 p-2 text-xs">
|
||||
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">
|
||||
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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{report.drafted_changelog}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<PrimaryAction
|
||||
text={executeFailed ? "Retry approve & publish" : "Approve & publish"}
|
||||
disabled={inFlight}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="What must change before this ships?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
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 { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
|
||||
const MIN_REJECT_CHARS = 4;
|
||||
const PRIORITY_LABELS = ["critical", "high", "medium", "low"];
|
||||
|
||||
/** Focused roadmap item: the PO's pitch in full (description, rationale,
|
||||
* acceptance criteria), then approve into the backlog or reject. */
|
||||
export function RoadmapItemDetail({
|
||||
cycleId,
|
||||
item,
|
||||
onDone,
|
||||
}: {
|
||||
cycleId: string;
|
||||
item: RoadmapItem;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const finish = (ok: boolean, message: string) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["roadmap", "cycles"] });
|
||||
if (ok) {
|
||||
haptics.success();
|
||||
toast.success(message);
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(message);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => roadmapApi.approveItem(cycleId, item.id),
|
||||
onSuccess: (result) =>
|
||||
finish(
|
||||
result.status === "approved" || result.status === "already_approved",
|
||||
result.status === "approved"
|
||||
? "Approved — added to the backlog."
|
||||
: result.detail,
|
||||
),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) =>
|
||||
roadmapApi.rejectItem(cycleId, item.id, reason),
|
||||
onSuccess: () => finish(true, "Item rejected."),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary">{item.team}</Badge>
|
||||
<Badge variant="outline">{item.project_slug}</Badge>
|
||||
<Badge>
|
||||
P{item.priority} · {PRIORITY_LABELS[item.priority] ?? "?"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">{item.rationale}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Acceptance criteria
|
||||
</h3>
|
||||
<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>
|
||||
|
||||
<PrimaryAction
|
||||
text="Approve → backlog"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="Why not this one?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One normalized queue over the four held-draft sources (release proposal,
|
||||
* X posts, video posts, roadmap items) so the cockpit renders a single
|
||||
* card stack. Query keys and cadence match the desktop queue components —
|
||||
* the two surfaces share the cache when both are open.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { releaseApi, type ReleaseProposal } from "@/lib/api/release";
|
||||
import { xApi, type XPost } from "@/lib/api/x";
|
||||
import { videoApi, type VideoPost } from "@/lib/api/video";
|
||||
import { roadmapApi, type RoadmapItem } from "@/lib/api/roadmap";
|
||||
import { isTgDemoMode } from "@/lib/telegram/demo";
|
||||
|
||||
const demo = () => import("@/lib/telegram/demo-data");
|
||||
|
||||
export type ApprovalItem =
|
||||
| { kind: "release"; id: string; proposal: ReleaseProposal }
|
||||
| { kind: "x_post"; id: string; post: XPost }
|
||||
| { kind: "video_post"; id: string; post: VideoPost }
|
||||
| { kind: "roadmap"; id: string; cycleId: string; item: RoadmapItem };
|
||||
|
||||
const REFETCH_MS = 30_000;
|
||||
|
||||
export function useApprovalQueue() {
|
||||
const release = useQuery({
|
||||
queryKey: ["release", "proposal"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_RELEASE : releaseApi.getProposal(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
const xPosts = useQuery({
|
||||
queryKey: ["x", "posts"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_X_POSTS : xApi.listPosts(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
const videoPosts = useQuery({
|
||||
queryKey: ["video", "posts"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_VIDEO_POSTS : videoApi.listPosts(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
const roadmap = useQuery({
|
||||
queryKey: ["roadmap", "cycles"],
|
||||
queryFn: async () =>
|
||||
isTgDemoMode() ? (await demo()).DEMO_ROADMAP : roadmapApi.listCycles(),
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
|
||||
const items = useMemo<ApprovalItem[]>(() => {
|
||||
const out: ApprovalItem[] = [];
|
||||
if (release.data) {
|
||||
out.push({
|
||||
kind: "release",
|
||||
id: release.data.task_id,
|
||||
proposal: release.data,
|
||||
});
|
||||
}
|
||||
for (const post of xPosts.data ?? []) {
|
||||
out.push({ kind: "x_post", id: post.task_id, post });
|
||||
}
|
||||
for (const post of videoPosts.data ?? []) {
|
||||
out.push({ kind: "video_post", id: post.task_id, post });
|
||||
}
|
||||
for (const cycle of roadmap.data ?? []) {
|
||||
for (const item of cycle.items) {
|
||||
if (item.status === "proposed") {
|
||||
out.push({
|
||||
kind: "roadmap",
|
||||
// Item ids are only unique within their cycle.
|
||||
id: `${cycle.task_id}:${item.id}`,
|
||||
cycleId: cycle.task_id,
|
||||
item,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [release.data, xPosts.data, videoPosts.data, roadmap.data]);
|
||||
|
||||
const queries = [release, xPosts, videoPosts, roadmap];
|
||||
return {
|
||||
items,
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
// A failed source contributes no items, which would silently read as
|
||||
// "queue is clear" — surface it so an outage never masquerades as an
|
||||
// empty queue.
|
||||
anyFailed: queries.some((q) => q.isError),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { videoApi, type VideoCut, type VideoPost } from "@/lib/api/video";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_X_CAPTION_CHARS = 280;
|
||||
const MAX_TIKTOK_CAPTION_CHARS = 2200;
|
||||
const MIN_REJECT_CHARS = 4;
|
||||
|
||||
const CUT_LABELS: Record<VideoCut, string> = {
|
||||
vertical: "9:16",
|
||||
square: "1:1",
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendered-cut player. The media route is authenticated, so a bare
|
||||
* `<video src>` would 401 — fetch the MP4 as a blob through the authed
|
||||
* client and play the object URL (the desktop queue's exact pattern).
|
||||
*/
|
||||
function CutPlayer({ post }: { post: VideoPost }) {
|
||||
const paths = post.mp4_paths ?? {};
|
||||
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.
|
||||
const [loaded, setLoaded] = useState<{
|
||||
cut: VideoCut;
|
||||
url: string | null;
|
||||
} | null>(null);
|
||||
// A stable string dep — depending on the paths object would re-run the
|
||||
// effect (and refetch the MP4) on every render.
|
||||
const mediaPath = paths[cut];
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaPath) return;
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
void videoApi
|
||||
.getMediaBlob(post.task_id, cut)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setLoaded({ cut, url: objectUrl });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoaded({ cut, url: null });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [post.task_id, cut, mediaPath]);
|
||||
|
||||
const src = loaded?.cut === cut ? loaded.url : null;
|
||||
const failed = loaded?.cut === cut && loaded.url === null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-1.5">
|
||||
{(Object.keys(CUT_LABELS) as VideoCut[]).map((c) => (
|
||||
<Button
|
||||
key={c}
|
||||
size="sm"
|
||||
variant={cut === c ? "default" : "outline"}
|
||||
disabled={!paths[c]}
|
||||
onClick={() => setCut(c)}
|
||||
>
|
||||
{CUT_LABELS[c]}
|
||||
{!paths[c] && " (missing)"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{src ? (
|
||||
<video
|
||||
src={src}
|
||||
controls
|
||||
playsInline
|
||||
className={cn(
|
||||
"w-full rounded-md bg-black",
|
||||
cut === "vertical" ? "aspect-[9/16] max-h-[60dvh]" : "aspect-square",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-video items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground">
|
||||
{!mediaPath
|
||||
? "This cut wasn't rendered"
|
||||
: failed
|
||||
? "Preview failed to load"
|
||||
: "Loading preview…"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaptionEditor({
|
||||
platform,
|
||||
stored,
|
||||
maxChars,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
platform: string;
|
||||
stored: string;
|
||||
maxChars: number;
|
||||
value: string | null;
|
||||
onChange: (v: string | null) => void;
|
||||
}) {
|
||||
const editing = value !== null;
|
||||
const caption = value ?? stored;
|
||||
const overLimit = caption.length > maxChars;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-xs font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing}
|
||||
onChange={(e) => onChange(e.target.checked ? stored : null)}
|
||||
/>
|
||||
Edit {platform} caption
|
||||
</label>
|
||||
<Textarea
|
||||
value={caption}
|
||||
disabled={!editing}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
className={cn("text-sm", editing && overLimit && "border-destructive")}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-right text-[11px] tabular-nums",
|
||||
editing && overLimit ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{caption.length}/{maxChars}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Focused video draft: cut-toggled player, per-platform caption edits,
|
||||
* then post (X + TikTok as configured) or reject. */
|
||||
export function VideoPostDetail({
|
||||
post,
|
||||
onDone,
|
||||
}: {
|
||||
post: VideoPost;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [xCaption, setXCaption] = useState<string | null>(null);
|
||||
const [tiktokCaption, setTiktokCaption] = useState<string | null>(null);
|
||||
|
||||
const overLimit =
|
||||
(xCaption !== null && xCaption.length > MAX_X_CAPTION_CHARS) ||
|
||||
(tiktokCaption !== null && tiktokCaption.length > MAX_TIKTOK_CAPTION_CHARS);
|
||||
|
||||
const finish = (ok: boolean, message: string) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
|
||||
if (ok) {
|
||||
haptics.success();
|
||||
toast.success(message);
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(message);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
videoApi.approve(post.task_id, {
|
||||
...(xCaption !== null && { x_caption: xCaption }),
|
||||
...(tiktokCaption !== null && { tiktok_caption: tiktokCaption }),
|
||||
}),
|
||||
onSuccess: (result) =>
|
||||
finish(
|
||||
result.status === "posted" || result.status === "already_posted",
|
||||
result.status === "posted" ? "Video posted." : result.detail,
|
||||
),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) => videoApi.reject(post.task_id, reason),
|
||||
onSuccess: () => finish(true, "Draft rejected — feedback goes back to the author."),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary">{post.occasion}</Badge>
|
||||
{post.platforms.map((p) => (
|
||||
<Badge key={p} variant="outline">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
{post.render_status === "failed" && (
|
||||
<Badge variant="destructive">render failed</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CutPlayer post={post} />
|
||||
|
||||
{post.platforms.includes("x") && (
|
||||
<CaptionEditor
|
||||
platform="X"
|
||||
stored={post.x_caption ?? ""}
|
||||
maxChars={MAX_X_CAPTION_CHARS}
|
||||
value={xCaption}
|
||||
onChange={setXCaption}
|
||||
/>
|
||||
)}
|
||||
{post.platforms.includes("tiktok") && (
|
||||
<CaptionEditor
|
||||
platform="TikTok"
|
||||
stored={post.tiktok_caption ?? ""}
|
||||
maxChars={MAX_TIKTOK_CAPTION_CHARS}
|
||||
value={tiktokCaption}
|
||||
onChange={setTiktokCaption}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PrimaryAction
|
||||
text="Post video"
|
||||
disabled={overLimit}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="What's wrong with this video?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { xApi, type XPost } from "@/lib/api/x";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { haptics } from "@/lib/telegram/webapp";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PrimaryAction } from "./primary-action";
|
||||
import { RejectForm } from "./reject-form";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_TWEET_CHARS = 280;
|
||||
const MIN_REJECT_CHARS = 4;
|
||||
|
||||
const SOURCE_LABELS: Record<XPost["source"], string> = {
|
||||
x_post: "Release post",
|
||||
x_reply: "Reply",
|
||||
x_feature: "Feature spotlight",
|
||||
};
|
||||
|
||||
/** Focused X draft: editable body with the live 280 counter, the mention
|
||||
* being replied to when there is one, then post or reject. */
|
||||
export function XPostDetail({
|
||||
post,
|
||||
onDone,
|
||||
}: {
|
||||
post: XPost;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [edited, setEdited] = useState<string | null>(null);
|
||||
const body = edited ?? post.body;
|
||||
const overLimit = body.length > MAX_TWEET_CHARS;
|
||||
|
||||
const finish = (ok: boolean, message: string) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["x", "posts"] });
|
||||
if (ok) {
|
||||
haptics.success();
|
||||
toast.success(message);
|
||||
onDone();
|
||||
} else {
|
||||
haptics.error();
|
||||
toast.warning(message);
|
||||
}
|
||||
};
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
xApi.approve(post.task_id, edited !== null ? body : undefined),
|
||||
onSuccess: (result) =>
|
||||
finish(
|
||||
result.status === "posted" || result.status === "already_posted",
|
||||
result.status === "posted" ? "Posted to X." : result.detail,
|
||||
),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: (reason: string) => xApi.reject(post.task_id, reason),
|
||||
onSuccess: () => finish(true, "Draft rejected."),
|
||||
onError: (err) => {
|
||||
haptics.error();
|
||||
toast.error(getErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary">{SOURCE_LABELS[post.source]}</Badge>
|
||||
{post.release_version && <Badge>v{post.release_version}</Badge>}
|
||||
{post.project_name && (
|
||||
<Badge variant="outline">{post.project_name}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.mention && (
|
||||
<blockquote className="border-l-2 pl-2 text-xs text-muted-foreground">
|
||||
{post.mention.text}
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setEdited(e.target.value)}
|
||||
rows={5}
|
||||
className={cn("text-sm", overLimit && "border-destructive")}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-right text-[11px] tabular-nums",
|
||||
overLimit ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{body.length}/{MAX_TWEET_CHARS}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PrimaryAction
|
||||
text="Post to X"
|
||||
disabled={overLimit || body.trim().length === 0}
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
/>
|
||||
<RejectForm
|
||||
minChars={MIN_REJECT_CHARS}
|
||||
placeholder="Why is this draft wrong?"
|
||||
pending={reject.isPending}
|
||||
onSubmit={(reason) => reject.mutate(reason)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user