feat(tg): Mini App V5 — brand typography, icon depth, motion, detail sheets (#583)

* feat(tg): Mini App V5 — brand typography, icon depth, motion, detail sheets

Share Tech Mono (the vendored motion-brand face) becomes the cockpit's
display voice via next/font/local scoped to #tg-shell; icon tiles, circle
actions, and avatars get gradient/ring depth; a dependency-free motion
vocabulary lands (spend count-up, tab rise-in, staggered sections,
sparkline draw-in, sheet slide-up with native BackButton dismiss); the
Board tab gains a tap-through task sheet (ACs, open findings, PR link),
Today's fleet opens a full-roster sheet, and Board/Inbox join the
/tg?demo=1 fixtures.

* feat(tg): custom RoboCo icon set + operations ring

The cockpit stops using stock lucide on its hero surfaces: a hand-drawn
duotone icon set (speedometer, seal, bell, kanban, brand-cursor bubble,
rocket, double-check, broom, robot head) covers the tab bar and the Today
ring. The ring itself stops duplicating the tab bar and becomes real
operations: Ship deep-focuses the release proposal in Approvals, Ack all
bulk-acknowledges pending notifications, Sweep runs the stale-branch
cleanup across every git-configured project behind a confirm sheet, and
Fleet opens the roster.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-19 18:59:54 +02:00
committed by GitHub
co-authored by Renn F
parent fc41dfa40e
commit 700dbcd285
19 changed files with 1467 additions and 101 deletions
Binary file not shown.
+13 -1
View File
@@ -1,4 +1,16 @@
import Script from "next/script";
import localFont from "next/font/local";
// The brand display face — the same vendored Share Tech Mono the motion/
// video compositions use for their headline moments. Exposed as a CSS
// variable on the shell so `.tg-display` (globals.css) can reach it; body
// text stays the root layout's Inter.
const shareTechMono = localFont({
src: "./fonts/ShareTechMono-Regular.woff2",
weight: "400",
variable: "--font-share-tech",
display: "swap",
});
/**
* Slim shell for the Telegram Mini App surface (`/tg`) — no Sidebar/Header/
@@ -26,7 +38,7 @@ export default function TelegramLayout({
return (
<div
id="tg-shell"
className="mx-auto flex w-full max-w-[430px] flex-col overflow-hidden bg-background text-foreground sm:border-x"
className={`${shareTechMono.variable} mx-auto flex w-full max-w-[430px] flex-col overflow-hidden bg-background text-foreground sm:border-x`}
style={{ height: "var(--tg-viewport-stable-height, 100dvh)" }}
>
<Script
+18 -7
View File
@@ -48,6 +48,12 @@ function CenteredMessage({ children }: { children: React.ReactNode }) {
export default function TelegramMiniAppPage() {
const [state, setState] = useState<BootstrapState>({ kind: "validating" });
const [tab, setTab] = useState<TgTab>("today");
// Today's Ship action deep-focuses the release proposal in Approvals.
const [approvalsFocus, setApprovalsFocus] = useState<"release" | undefined>();
const navigate = (next: TgTab, intent?: "release") => {
setApprovalsFocus(next === "approvals" ? intent : undefined);
setTab(next);
};
useEffect(() => {
let cancelled = false;
@@ -137,13 +143,18 @@ export default function TelegramMiniAppPage() {
return (
<TgWebAppProvider webApp={state.webApp}>
<div className="p-3 pb-20">
{tab === "today" && <TgTodayTab onNavigate={setTab} />}
{tab === "approvals" && <TgApprovalsTab />}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
<TgTabBar active={tab} onChange={setTab} />
<div className="p-3 pb-24">
{/* Keyed by tab so every switch replays the rise-in entrance. */}
<div key={tab} className="tg-tab-in">
{tab === "today" && <TgTodayTab onNavigate={navigate} />}
{tab === "approvals" && (
<TgApprovalsTab initialFocus={approvalsFocus} />
)}
{tab === "inbox" && <TgInboxTab />}
{tab === "board" && <TgBoardTab />}
{tab === "chat" && <TgChatTab />}
</div>
<TgTabBar active={tab} onChange={navigate} />
</div>
</TgWebAppProvider>
);
+83
View File
@@ -154,6 +154,89 @@
--ring: oklch(0.8 0.13 78);
}
/* Cockpit brand typography + motion. `.tg-display` is the Share Tech Mono
display voice (labels, numerals, wordmark — never body text); the
animation classes are the Mini App's only motion vocabulary: rise-in for
tab/section entrances, slide-up for bottom sheets, a draw-in for the
spend sparkline. All animate transform/opacity only and collapse to
instant under prefers-reduced-motion via the global rule below. */
#tg-shell .tg-display {
font-family: var(--font-share-tech), ui-monospace, "SF Mono", monospace;
}
#tg-shell .tg-tab-in {
animation: tg-rise 0.24s cubic-bezier(0.21, 0.61, 0.35, 1) backwards;
}
#tg-shell .tg-stagger > * {
animation: tg-rise 0.3s cubic-bezier(0.21, 0.61, 0.35, 1) backwards;
}
#tg-shell .tg-stagger > *:nth-child(2) {
animation-delay: 40ms;
}
#tg-shell .tg-stagger > *:nth-child(3) {
animation-delay: 80ms;
}
#tg-shell .tg-stagger > *:nth-child(4) {
animation-delay: 120ms;
}
#tg-shell .tg-stagger > *:nth-child(5) {
animation-delay: 160ms;
}
#tg-shell .tg-stagger > *:nth-child(n + 6) {
animation-delay: 200ms;
}
#tg-shell .tg-backdrop {
animation: tg-fade 0.2s ease-out backwards;
}
#tg-shell .tg-sheet {
animation: tg-sheet-up 0.28s cubic-bezier(0.32, 0.72, 0.24, 1) backwards;
}
#tg-shell .tg-draw-line {
stroke-dasharray: 1;
animation: tg-draw 0.8s ease-out 0.1s backwards;
}
#tg-shell .tg-cursor {
animation: tg-blink 1.1s steps(1) infinite;
}
@keyframes tg-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tg-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes tg-sheet-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes tg-draw {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes tg-blink {
50% {
opacity: 0;
}
}
/* Collapse/expand and other transform/opacity transitions (animate-in,
animate-out, transition-transform, ...) become instant for users who
asked the OS for reduced motion — the content still opens/closes, it
@@ -43,9 +43,15 @@ const DEFAULT_OPEN = new Set<TaskStatus>([
TaskStatus.AWAITING_CEO_APPROVAL,
]);
function TaskRow({ task }: { task: Task }) {
return (
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 first:border-t-0">
function TaskRow({
task,
onPress,
}: {
task: Task;
onPress?: (task: Task) => void;
}) {
const content = (
<>
<div className="min-w-0">
<p className="truncate text-sm">{task.title}</p>
<p className="truncate text-xs text-muted-foreground">
@@ -53,6 +59,22 @@ function TaskRow({ task }: { task: Task }) {
</p>
</div>
<TaskStatusBadge status={task.status} />
</>
);
if (onPress) {
return (
<button
type="button"
onClick={() => onPress(task)}
className="flex w-full items-center justify-between gap-2 border-t px-3 py-2 text-left transition-colors first:border-t-0 active:bg-muted"
>
{content}
</button>
);
}
return (
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 first:border-t-0">
{content}
</div>
);
}
@@ -61,10 +83,12 @@ function StatusSection({
status,
tasks,
defaultOpen,
onTaskPress,
}: {
status: TaskStatus;
tasks: Task[];
defaultOpen: boolean;
onTaskPress?: (task: Task) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
@@ -87,7 +111,7 @@ function StatusSection({
</CollapsibleTrigger>
<CollapsibleContent>
{tasks.map((t) => (
<TaskRow key={t.id} task={t} />
<TaskRow key={t.id} task={t} onPress={onTaskPress} />
))}
</CollapsibleContent>
</Collapsible>
@@ -100,8 +124,18 @@ function StatusSection({
* drag-and-drop — that's the desktop kanban columns' job; this is a
* glance-and-tap surface for the /tg Mini App.
*/
export function MobileTaskBoard() {
const { data, isLoading } = useTasks({ limit: 200 });
export function MobileTaskBoard({
tasks: tasksOverride,
onTaskPress,
}: {
/** Bypass the live fetch (the /tg demo fixtures). The list query still
* mounts — dev-only demo noise, not worth a conditional-hook dance. */
tasks?: Task[];
onTaskPress?: (task: Task) => void;
} = {}) {
const { data: fetched, isLoading: fetchLoading } = useTasks({ limit: 200 });
const data = tasksOverride ?? fetched;
const isLoading = tasksOverride ? false : fetchLoading;
const grouped = useMemo(() => {
const byStatus = new Map<TaskStatus, Task[]>();
@@ -143,6 +177,7 @@ export function MobileTaskBoard() {
status={status}
tasks={tasks}
defaultOpen={DEFAULT_OPEN.has(status)}
onTaskPress={onTaskPress}
/>
))}
</div>
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { renderHook } from "@testing-library/react";
import { TgSheet, useCountUp } from "../motion";
describe("useCountUp", () => {
it("jumps straight to the target under reduced motion", async () => {
const original = window.matchMedia;
window.matchMedia = ((query: string) => ({
matches: true,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
try {
const { result } = renderHook(() => useCountUp(42));
await waitFor(() => expect(result.current).toBe(42));
} finally {
window.matchMedia = original;
}
});
it("settles on the exact target when animating", async () => {
const { result } = renderHook(() => useCountUp(12.34, 50));
// Generous timeout: rAF frames starve under parallel test workers.
await waitFor(() => expect(result.current).toBe(12.34), { timeout: 4000 });
});
});
describe("TgSheet", () => {
it("renders nothing when closed", () => {
render(
<TgSheet open={false} onClose={vi.fn()} title="Task">
<p>body</p>
</TgSheet>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows title and children when open, closes on backdrop tap", () => {
const onClose = vi.fn();
render(
<TgSheet open onClose={onClose} title="Fleet">
<p>sheet body</p>
</TgSheet>,
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Fleet")).toBeInTheDocument();
expect(screen.getByText("sheet body")).toBeInTheDocument();
const [backdrop] = screen.getAllByRole("button", { name: /close/i });
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TgTaskSheet } from "../tg-task-sheet";
import type { Task } from "@/types";
import type { TaskFindingsResponse } from "@/lib/api/tasks";
const { findings } = vi.hoisted(() => ({
findings: vi.fn<() => { data: TaskFindingsResponse | undefined }>(() => ({
data: undefined,
})),
}));
vi.mock("@/hooks/use-tasks", () => ({
useTaskFindings: findings,
}));
function task(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Harden the retry queue",
description: "Webhook retries with backoff.",
acceptance_criteria: ["Retries back off", "DLQ after 5 attempts"],
status: "awaiting_ceo_approval",
priority: 1,
sequence: 0,
team: "backend",
created_by: "main-pm",
assigned_to: "be-dev-1",
parent_task_id: null,
dependency_ids: [],
blocker_ids: [],
created_at: "2026-07-18T10:00:00Z",
updated_at: "2026-07-19T09:00:00Z",
claimed_at: null,
started_at: null,
completed_at: null,
target_date: null,
estimated_complexity: "medium",
nature: "technical",
task_type: "code",
project_id: "p1",
docs_complete: false,
pr_created: true,
pm_approvals: {},
plan: null,
checkpoints: [],
progress_updates: [],
commits: [],
dev_notes: null,
qa_notes: null,
auditor_notes: null,
quick_context: null,
self_verified: true,
qa_verified: true,
revision_count: 2,
branch_name: "feature/backend/T1",
pr_number: 612,
pr_url: "https://example.com/pull/612",
...overrides,
} as Task;
}
describe("TgTaskSheet", () => {
it("renders nothing without a task", () => {
render(<TgTaskSheet task={null} onClose={vi.fn()} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows title, ACs, bounce chip, and the PR link", () => {
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
expect(screen.getByText("Harden the retry queue")).toBeInTheDocument();
expect(screen.getByText("Retries back off")).toBeInTheDocument();
expect(screen.getByText("DLQ after 5 attempts")).toBeInTheDocument();
expect(screen.getByText(/bounced ×2/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /open pr #612/i })).toHaveAttribute(
"href",
"https://example.com/pull/612",
);
});
it("lists only the open findings", () => {
findings.mockReturnValue({
data: {
findings: [
{
id: "f1",
task_id: "t1",
origin: "qa",
round: 1,
author_slug: "be-qa",
file: "roboco/services/queue.py",
line: 42,
severity: "major",
criterion: null,
expected: "Backoff is exponential",
actual: "Fixed 1s delay",
fix: "Use exponential backoff with jitter",
evidence: null,
status: "open",
addressed_by_commit: null,
resolution_note: null,
created_at: "2026-07-19T08:00:00Z",
updated_at: null,
},
{
id: "f2",
task_id: "t1",
origin: "qa",
round: 1,
author_slug: "be-qa",
file: "roboco/services/dlq.py",
line: 7,
severity: "minor",
criterion: null,
expected: "x",
actual: "y",
fix: null,
evidence: null,
status: "verified",
addressed_by_commit: null,
resolution_note: null,
created_at: "2026-07-19T08:00:00Z",
updated_at: null,
},
],
summary: [],
total: 2,
truncated: false,
},
});
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
expect(screen.getByText(/open findings · 1/i)).toBeInTheDocument();
expect(screen.getByText("roboco/services/queue.py:42")).toBeInTheDocument();
expect(screen.queryByText(/dlq\.py/)).not.toBeInTheDocument();
});
});
@@ -4,8 +4,22 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TgTodayTab } from "../tg-today-tab";
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/lib/api/client", () => ({ default: { get } }));
const { get, ackMock, notifItems } = vi.hoisted(() => ({
get: vi.fn(),
ackMock: vi.fn(),
notifItems: { current: [] as Array<Record<string, unknown>> },
}));
vi.mock("@/lib/api/client", () => ({
default: { get },
getErrorMessage: () => "error",
}));
vi.mock("@/hooks/use-notifications", () => ({
notificationKeys: { all: ["notifications"] },
useNotifications: () => ({ data: { items: notifItems.current } }),
}));
vi.mock("@/lib/api/notifications", () => ({
notificationsApi: { acknowledge: ackMock },
}));
function renderTab(onNavigate = vi.fn()) {
const client = new QueryClient({
@@ -52,14 +66,28 @@ describe("TgTodayTab", () => {
// vitest call it as an after-test teardown hook.
beforeEach(() => {
get.mockReset();
ackMock.mockReset();
notifItems.current = [];
// Reduced motion → the spend count-up lands instantly; these tests
// assert content, not animation timing.
window.matchMedia = ((query: string) => ({
matches: query.includes("prefers-reduced-motion"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
});
it("shows skeletons while loading", () => {
get.mockReturnValue(new Promise(() => {}));
renderTab();
expect(document.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(
0,
);
expect(
document.querySelectorAll("[data-slot=skeleton]").length,
).toBeGreaterThan(0);
});
it("renders the all-clear state and the spend/ship numbers", async () => {
@@ -67,7 +95,8 @@ describe("TgTodayTab", () => {
renderTab();
expect(await screen.findByText(/all clear/i)).toBeInTheDocument();
expect(screen.getByText("$12.34")).toBeInTheDocument();
// The spend hero counts up to the target, so wait for the final frame.
expect(await screen.findByText("$12.34")).toBeInTheDocument();
expect(screen.getByText(/1\.2M tokens/)).toBeInTheDocument();
expect(screen.getByText("v0.25.0")).toBeInTheDocument();
expect(screen.getByText(/no release pending/i)).toBeInTheDocument();
@@ -110,6 +139,91 @@ describe("TgTodayTab", () => {
expect(onNavigate).toHaveBeenCalledWith("board");
});
it("renders the operations ring and deep-links Ship into the release", async () => {
get.mockResolvedValue({
data: brief({
ship: { version: "0.25.0", open_release_proposal: true, ci_fix_tasks: 0 },
}),
});
const onNavigate = vi.fn();
renderTab(onNavigate);
await userEvent.click(await screen.findByRole("button", { name: /ship/i }));
expect(onNavigate).toHaveBeenCalledWith("approvals", "release");
expect(screen.getByRole("button", { name: /sweep/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /fleet/i })).toBeInTheDocument();
});
it("ack-all acknowledges every pending notification", async () => {
get.mockResolvedValue({ data: brief() });
notifItems.current = [
{ id: "n1", requires_ack: true, is_acknowledged: false },
{ id: "n2", requires_ack: true, is_acknowledged: false },
{ id: "n3", requires_ack: false, is_acknowledged: false },
];
ackMock.mockResolvedValue({});
renderTab();
await userEvent.click(
await screen.findByRole("button", { name: /ack all/i }),
);
await waitFor(() => expect(ackMock).toHaveBeenCalledTimes(2));
expect(ackMock).toHaveBeenCalledWith("n1");
expect(ackMock).toHaveBeenCalledWith("n2");
});
it("opens the fleet sheet with the full working roster", async () => {
get.mockResolvedValue({
data: brief({
fleet: {
total: 26,
by_status: { active: 5, idle: 21 },
working: [
{
name: "be-dev-1",
role: "developer",
team: "backend",
task_title: "Task A",
},
{
name: "be-dev-2",
role: "developer",
team: "backend",
task_title: "Task B",
},
{
name: "fe-dev-1",
role: "developer",
team: "frontend",
task_title: "Task C",
},
{
name: "fe-qa",
role: "qa",
team: "frontend",
task_title: "Task D",
},
{
name: "ux-dev-1",
role: "developer",
team: "ux_ui",
task_title: "Task E",
},
],
},
}),
});
renderTab();
// The section previews 3 of 5; tapping it opens the full-roster sheet.
await userEvent.click(
await screen.findByText(/\+2 more · tap for the full roster/),
);
expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Task E")).toBeInTheDocument();
expect(screen.getByText(/idle · 21/)).toBeInTheDocument();
});
it("shows an error state when the brief fails to load", async () => {
get.mockRejectedValue(new Error("boom"));
renderTab();
+3 -1
View File
@@ -41,15 +41,17 @@ export function Sparkline({ values }: { values: number[] }) {
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${gradId})`} />
<path d={area} fill={`url(#${gradId})`} className="tg-backdrop" />
<polyline
points={line}
pathLength={1}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="tg-draw-line"
/>
<circle cx={lastX} cy={lastY} r="3.5" fill="currentColor" />
</svg>
+105
View File
@@ -0,0 +1,105 @@
"use client";
/**
* The cockpit's motion primitives a count-up hook for hero numerals and
* the bottom sheet every detail view rides. Kept dependency-free (rAF + CSS
* keyframes from globals.css); prefers-reduced-motion users get the final
* state instantly.
*/
import { useEffect, useRef, useState } from "react";
import { X } from "lucide-react";
import { haptics } from "@/lib/telegram/webapp";
import { useBackButton } from "@/lib/telegram/hooks";
function reducedMotion(): boolean {
// No matchMedia (SSR, bare jsdom) counts as reduced — jump to the target.
return (
typeof window === "undefined" ||
typeof window.matchMedia !== "function" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
/** Animate a numeric value toward `target` (ease-out cubic). First mount
* counts up from zero the wallet-style hero entrance. */
export function useCountUp(target: number, durationMs = 650): number {
const [value, setValue] = useState(0);
const fromRef = useRef(0);
useEffect(() => {
const from = fromRef.current;
fromRef.current = target;
let raf = 0;
if (from === target || reducedMotion()) {
raf = requestAnimationFrame(() => setValue(target));
return () => cancelAnimationFrame(raf);
}
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / durationMs, 1);
const eased = 1 - Math.pow(1 - t, 3);
setValue(from + (target - from) * eased);
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, durationMs]);
return value;
}
/**
* Bottom sheet slide-up detail surface over the active tab. Renders
* inside #tg-shell (never a portal) so the cockpit theme variables apply.
* Telegram's native BackButton dismisses it while it's open; outside
* Telegram the backdrop tap and the X do the same job.
*/
export function TgSheet({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
useBackButton(open ? onClose : null);
useEffect(() => {
if (open) haptics.tap();
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close"
onClick={onClose}
className="tg-backdrop absolute inset-0 bg-black/50"
/>
<div
role="dialog"
aria-modal="true"
className="tg-sheet absolute inset-x-0 bottom-0 mx-auto flex max-h-[85dvh] w-full max-w-[430px] flex-col rounded-t-2xl border-t bg-card text-card-foreground shadow-2xl"
>
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-muted-foreground/30" />
<header className="flex items-center justify-between gap-2 px-4 pb-2 pt-3">
<h2 className="tg-display text-[12px] uppercase tracking-[0.16em] text-muted-foreground">
{title}
</h2>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-muted-foreground transition-transform active:scale-90"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="overflow-y-auto px-4 pb-[calc(1.25rem+env(safe-area-inset-bottom))]">
{children}
</div>
</div>
</div>
);
}
+19 -3
View File
@@ -95,13 +95,29 @@ function Detail({ item, onDone }: { item: ApprovalItem; onDone: () => void }) {
* vanishes from the refetched queue, which pops the view back to the list
* by construction.
*/
export function TgApprovalsTab() {
export function TgApprovalsTab({
initialFocus,
}: {
/** Auto-focus the first item of this kind once — Today's Ship deep link. */
initialFocus?: "release";
} = {}) {
const { items, isLoading, anyFailed } = useApprovalQueue();
const [focusedId, setFocusedId] = useState<string | null>(null);
const [initialConsumed, setInitialConsumed] = useState(false);
const webApp = useTgWebApp();
const focused = items.find((i) => i.id === focusedId) ?? null;
const back = () => setFocusedId(null);
// Derived, not an effect: the deep link focuses the first matching item
// until the user backs out of it once.
const autoTarget =
initialFocus && !initialConsumed && focusedId === null
? items.find((i) => i.kind === initialFocus)
: undefined;
const focused =
items.find((i) => i.id === focusedId) ?? autoTarget ?? null;
const back = () => {
setInitialConsumed(true);
setFocusedId(null);
};
useBackButton(focused ? back : null);
if (isLoading) {
+23 -5
View File
@@ -1,11 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { MobileTaskBoard } from "@/components/tasks/mobile-task-board";
import { TgTaskSheet } from "@/components/tg/tg-task-sheet";
import { isTgDemoMode } from "@/lib/telegram/demo";
import type { Task } from "@/types";
/** Cockpit Board tab thin wrapper so every tab has its own file under
* components/tg/ (per the per-tab-file convention); the board itself lives
* in components/tasks since it's a general read-only task view, not
* Mini-App-specific. */
/** Cockpit Board tab the shared read-only board plus the tap-through
* task sheet (status, ACs, open findings, PR link). Demo mode swaps in the
* canned fixture list, lazily imported so it stays out of the prod bundle. */
export function TgBoardTab() {
return <MobileTaskBoard />;
const [selected, setSelected] = useState<Task | null>(null);
const [demoTasks, setDemoTasks] = useState<Task[] | undefined>(undefined);
useEffect(() => {
if (!isTgDemoMode()) return;
void import("@/lib/telegram/demo-data").then((m) =>
setDemoTasks(m.DEMO_TASKS),
);
}, []);
return (
<>
<MobileTaskBoard tasks={demoTasks} onTaskPress={setSelected} />
<TgTaskSheet task={selected} onClose={() => setSelected(null)} />
</>
);
}
+175
View File
@@ -0,0 +1,175 @@
/**
* RoboCo's own cockpit icon set hand-drawn duotone glyphs (a 45%-opacity
* body plus solid accents, everything currentColor) so the nav and action
* surfaces stop reading as stock-library linework. Utility chrome
* (chevrons, spinners, list-row glyphs) deliberately stays lucide; these
* cover the hero surfaces only.
*/
export type TgIconProps = { className?: string };
function Svg({
className,
children,
}: TgIconProps & { children: React.ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
>
{children}
</svg>
);
}
/** Speedometer — Today. */
export function IconToday({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
fillRule="evenodd"
opacity=".45"
d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 3.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Z"
/>
<rect
x="11.1"
y="5.2"
width="1.8"
height="7.2"
rx=".9"
transform="rotate(45 12 12)"
/>
<circle cx="12" cy="12" r="2" />
</Svg>
);
}
/** Octagon seal with a check — Approvals / approve actions. */
export function IconSeal({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M8.2 2.6h7.6a1 1 0 0 1 .7.3l4.6 4.6a1 1 0 0 1 .3.7v7.6a1 1 0 0 1-.3.7l-4.6 4.6a1 1 0 0 1-.7.3H8.2a1 1 0 0 1-.7-.3l-4.6-4.6a1 1 0 0 1-.3-.7V8.2a1 1 0 0 1 .3-.7l4.6-4.6a1 1 0 0 1 .7-.3Z"
/>
<path
d="m8.4 12.2 2.4 2.4 4.8-5"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
);
}
/** Bell with an unread dot — Inbox. */
export function IconInbox({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M12 3.2a6 6 0 0 0-6 6v3l-1.5 2.6c-.4.7.1 1.6.9 1.6h13.2c.8 0 1.3-.9.9-1.6L18 12.2v-3a6 6 0 0 0-6-6Z"
/>
<path d="M9.7 18.6a2.4 2.4 0 0 0 4.6 0H9.7Z" />
<circle cx="17.8" cy="5.4" r="2.6" />
</Svg>
);
}
/** Kanban columns — Board. */
export function IconBoard({ className }: TgIconProps) {
return (
<Svg className={className}>
<rect x="3.4" y="4" width="4.8" height="12.5" rx="1.7" opacity=".45" />
<rect x="9.6" y="4" width="4.8" height="16" rx="1.7" />
<rect x="15.8" y="4" width="4.8" height="9" rx="1.7" opacity=".45" />
</Svg>
);
}
/** Speech bubble carrying the brand cursor — Chat. */
export function IconChat({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M4 6.5A3.5 3.5 0 0 1 7.5 3h9A3.5 3.5 0 0 1 20 6.5v6a3.5 3.5 0 0 1-3.5 3.5H9.8l-4.1 3.4c-.7.5-1.7 0-1.7-.9V6.5Z"
/>
<rect x="8.2" y="10.6" width="6.2" height="2.2" rx="1.1" />
</Svg>
);
}
/** Rocket — Ship. */
export function IconShip({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="M12 2.3c3 1.8 4.8 5 4.8 8.6 0 1.9-.4 3.7-1.2 5.2H8.4a11.7 11.7 0 0 1-1.2-5.2c0-3.6 1.8-6.8 4.8-8.6Z"
/>
<circle cx="12" cy="9.6" r="2" />
<path d="M10.1 17.4h3.8c.3 1.7-.4 3.4-1.9 4.8-1.5-1.4-2.2-3.1-1.9-4.8Z" />
</Svg>
);
}
/** Double check — Ack all. */
export function IconAckAll({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
opacity=".45"
d="m3 12.9 4 4 7.6-8.8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="m10.4 13.8 3.1 3.1 7.5-8.7"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
);
}
/** Broom — the stale-branch sweep. */
export function IconSweep({ className }: TgIconProps) {
return (
<Svg className={className}>
<path
d="M19.4 3.6 13 10.8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
fill="none"
/>
<path
opacity=".45"
d="M12.1 10.4 15 13c-.9 3.1-3.3 5.7-7 7-1.5.5-3.2-.1-3.9-1.6l-1-2c3.7-.5 6.8-2.5 9-6Z"
/>
</Svg>
);
}
/** Robot head — the fleet. */
export function IconFleet({ className }: TgIconProps) {
return (
<Svg className={className}>
<rect x="11.1" y="2" width="1.8" height="3.4" rx=".9" />
<rect x="4" y="6.2" width="16" height="13" rx="4.2" opacity=".45" />
<circle cx="9" cy="12.7" r="1.8" />
<circle cx="15" cy="12.7" r="1.8" />
</Svg>
);
}
+17 -3
View File
@@ -1,9 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import {
useNotifications,
useAcknowledgeNotification,
} from "@/hooks/use-notifications";
import { isTgDemoMode } from "@/lib/telegram/demo";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import type { Notification } from "@/types";
@@ -55,8 +57,7 @@ function TgNotificationRow({ notification }: { notification: Notification }) {
{notification.body}
</p>
<p className="mt-1.5 text-[11px] text-muted-foreground">
{sender} ·{" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
{sender} · {formatDistanceToNow(new Date(notification.timestamp))} ago
</p>
</div>
</div>
@@ -67,9 +68,22 @@ function TgNotificationRow({ notification }: { notification: Notification }) {
* Notification inbox for the /tg cockpit every notification, newest
* first, with an Ack button on the ones that require it. Polling rides
* useNotifications' own 30s refetchInterval; no extra wiring needed here.
* Demo mode renders the canned fixtures instead (the live query still
* mounts dev-only noise, same trade as the Board tab).
*/
export function TgInboxTab() {
const { data, isLoading } = useNotifications();
const { data: fetched, isLoading: fetchLoading } = useNotifications();
const [demoItems, setDemoItems] = useState<Notification[] | undefined>(
undefined,
);
useEffect(() => {
if (!isTgDemoMode()) return;
void import("@/lib/telegram/demo-data").then((m) =>
setDemoItems(m.DEMO_NOTIFICATIONS),
);
}, []);
const data = demoItems ? { items: demoItems } : fetched;
const isLoading = demoItems ? false : fetchLoading;
if (isLoading) {
return (
+23 -12
View File
@@ -1,6 +1,13 @@
"use client";
import { Gauge, CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react";
import {
IconBoard,
IconChat,
IconInbox,
IconSeal,
IconToday,
type TgIconProps,
} from "@/components/tg/tg-icons";
import { cn } from "@/lib/utils";
export type TgTab = "today" | "approvals" | "inbox" | "board" | "chat";
@@ -8,13 +15,13 @@ export type TgTab = "today" | "approvals" | "inbox" | "board" | "chat";
const TABS: ReadonlyArray<{
id: TgTab;
label: string;
icon: typeof CheckSquare;
icon: React.ComponentType<TgIconProps>;
}> = [
{ id: "today", label: "Today", icon: Gauge },
{ id: "approvals", label: "Approvals", icon: CheckSquare },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "board", label: "Board", icon: Kanban },
{ id: "chat", label: "Chat", icon: MessageSquare },
{ id: "today", label: "Today", icon: IconToday },
{ id: "approvals", label: "Approvals", icon: IconSeal },
{ id: "inbox", label: "Inbox", icon: IconInbox },
{ id: "board", label: "Board", icon: IconBoard },
{ id: "chat", label: "Chat", icon: IconChat },
];
interface TgTabBarProps {
@@ -42,14 +49,18 @@ export function TgTabBar({ active, onChange }: TgTabBarProps) {
aria-current={isActive ? "page" : undefined}
onClick={() => onChange(tab.id)}
className={cn(
"flex flex-1 flex-col items-center gap-0.5 pb-2 pt-2.5 text-[10px] font-medium tracking-wide transition-colors",
"tg-display flex flex-1 flex-col items-center gap-0.5 pb-2 pt-1.5 text-[9px] uppercase tracking-[0.08em] transition-colors",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
>
<tab.icon
className="h-5 w-5"
strokeWidth={isActive ? 2.25 : 1.75}
/>
<span
className={cn(
"flex h-7 w-12 items-center justify-center rounded-full transition-all duration-200 ease-out",
isActive ? "bg-primary/15" : "bg-transparent",
)}
>
<tab.icon className="h-5 w-5" />
</span>
{tab.label}
</button>
);
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { TgSheet } from "@/components/tg/motion";
import { TaskStatusBadge } from "@/components/tasks/task-status-badge";
import { useTaskFindings } from "@/hooks/use-tasks";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { isTgDemoMode } from "@/lib/telegram/demo";
import type { Task } from "@/types";
import type { TaskFinding } from "@/lib/api/tasks";
import { CheckCircle2, ExternalLink } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
const FINDINGS_SHOWN = 5;
const SEVERITY_DOT: Record<TaskFinding["severity"], string> = {
blocker: "bg-rose-400",
major: "bg-amber-400",
minor: "bg-sky-400",
nit: "bg-muted-foreground/60",
};
function FindingRow({ finding }: { finding: TaskFinding }) {
return (
<li className="flex gap-2 py-1.5">
<span
className={cn(
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
SEVERITY_DOT[finding.severity],
)}
/>
<div className="min-w-0">
{finding.file && (
<p className="tg-display truncate text-xs">
{finding.file}
{finding.line !== null && `:${finding.line}`}
</p>
)}
<p className="line-clamp-2 text-xs text-muted-foreground">
{finding.fix ?? finding.expected}
</p>
</div>
</li>
);
}
/**
* Read-only task detail for the Board tab's tap-through: status, meta,
* description, acceptance criteria, the open revision findings, and the PR
* link. Mutations stay on the desktop panel the cockpit is a
* glance-and-decide surface, and the decide verbs already live in
* Approvals.
*/
export function TgTaskSheet({
task,
onClose,
}: {
task: Task | null;
onClose: () => void;
}) {
// Demo fixtures have no backend — disable the ledger fetch entirely there.
const findingsQuery = useTaskFindings(task && !isTgDemoMode() ? task.id : "");
const openFindings = (findingsQuery.data?.findings ?? []).filter(
(f) => f.status === "open",
);
return (
<TgSheet open={task !== null} onClose={onClose} title="Task">
{task && (
<div className="space-y-4 pb-1">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<TaskStatusBadge status={task.status} />
{(task.revision_count ?? 0) > 0 && (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] font-medium tabular-nums text-amber-300">
bounced ×{task.revision_count}
</span>
)}
</div>
<h3 className="text-base font-semibold leading-snug">
{task.title}
</h3>
<p className="text-[11px] text-muted-foreground">
{[
task.team,
getAgentDisplayName(task.assigned_to),
task.updated_at &&
`${formatDistanceToNow(new Date(task.updated_at))} ago`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{task.description && (
<p className="whitespace-pre-line text-sm leading-relaxed text-muted-foreground">
{task.description}
</p>
)}
{task.acceptance_criteria.length > 0 && (
<section>
<h4 className="tg-display mb-1.5 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Acceptance criteria
</h4>
<ul className="space-y-1.5">
{task.acceptance_criteria.map((criterion, i) => (
<li key={i} className="flex gap-2 text-sm leading-snug">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/50" />
<span>{criterion}</span>
</li>
))}
</ul>
</section>
)}
{openFindings.length > 0 && (
<section>
<h4 className="tg-display mb-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Open findings · {openFindings.length}
</h4>
<ul className="divide-y">
{openFindings.slice(0, FINDINGS_SHOWN).map((f) => (
<FindingRow key={f.id} finding={f} />
))}
</ul>
{openFindings.length > FINDINGS_SHOWN && (
<p className="mt-1 text-[11px] text-muted-foreground">
+{openFindings.length - FINDINGS_SHOWN} more on the desktop
panel
</p>
)}
</section>
)}
{task.pr_url && (
<a
href={task.pr_url}
target="_blank"
rel="noreferrer"
className="flex items-center justify-center gap-2 rounded-xl bg-muted py-2.5 text-sm font-medium transition-transform active:scale-[0.98]"
>
<ExternalLink className="h-4 w-4" />
Open PR{task.pr_number !== null && ` #${task.pr_number}`}
</a>
)}
</div>
)}
</TgSheet>
);
}
+225 -36
View File
@@ -1,31 +1,39 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api/client";
import api, { getErrorMessage } from "@/lib/api/client";
import { isTgDemoMode } from "@/lib/telegram/demo";
import { useWebSocket } from "@/hooks/use-websocket";
import {
notificationKeys,
useNotifications,
} from "@/hooks/use-notifications";
import { notificationsApi } from "@/lib/api/notifications";
import { projectsApi } from "@/lib/api/projects";
import { gitApi } from "@/lib/api/git";
import { haptics } from "@/lib/telegram/webapp";
import type { TgTab } from "@/components/tg/tg-tab-bar";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { TgAvatar, TgCircleAction, TgRow, TgSection } from "@/components/tg/ui";
import { TgSheet, useCountUp } from "@/components/tg/motion";
import {
TgAvatar,
TgCircleAction,
TgRow,
TgSection,
} from "@/components/tg/ui";
IconAckAll,
IconFleet,
IconShip,
IconSweep,
} from "@/components/tg/tg-icons";
import { DayBars, Sparkline } from "@/components/tg/charts";
import {
AlertTriangle,
ArrowDownRight,
ArrowUpRight,
Bell,
CheckSquare,
ChevronRight,
Kanban,
MessageSquare,
Rocket,
} from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
@@ -106,15 +114,16 @@ function weekdayLabels(count: number): string[] {
function SpendHero({ spend }: { spend: TodayBrief["spend"] }) {
const delta = spend.delta_pct;
const up = (delta ?? 0) >= 0;
const cost = useCountUp(spend.cost_today_usd);
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">
<p className="tg-display text-[11px] uppercase tracking-[0.14em] 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 className="tg-display text-[40px] leading-none tabular-nums">
${cost.toFixed(2)}
</span>
{delta !== null && (
<span
@@ -177,7 +186,7 @@ function NeedsYouBanner({
onClick={onApprovals}
className="flex w-full items-center justify-between"
>
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">
<span className="tg-display text-[11px] uppercase tracking-[0.14em] text-primary">
Needs you
</span>
<span className="flex items-center gap-1 text-primary">
@@ -204,7 +213,12 @@ function NeedsYouBanner({
{(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} />
<TgRow
key={t.id}
title={t.title}
meta={taskMeta(t)}
onPress={onBoard}
/>
))}
{needs.blocked.slice(0, 2).map((t) => (
<TgRow
@@ -226,6 +240,52 @@ function NeedsYouBanner({
);
}
/** Full working-roster sheet every mid-task agent with its live task,
* opened from the Fleet section's (truncated) preview. */
function FleetSheet({
fleet,
open,
onClose,
}: {
fleet: TodayBrief["fleet"];
open: boolean;
onClose: () => void;
}) {
return (
<TgSheet open={open} onClose={onClose} title="Fleet">
<div className="mb-3 flex flex-wrap gap-1.5">
{Object.entries(fleet.by_status).map(([status, count]) => (
<span
key={status}
className="rounded-full bg-muted px-2.5 py-1 text-xs tabular-nums text-muted-foreground"
>
{status} · {count}
</span>
))}
</div>
<ul className="divide-y">
{fleet.working.map((agent) => (
<li key={agent.name} className="flex items-center gap-3 py-2.5">
<TgAvatar name={agent.name} active />
<div className="min-w-0 flex-1">
<p className="tg-display text-[13px]">{agent.name}</p>
<p className="truncate text-xs text-muted-foreground">
{agent.task_title ??
`${agent.role}${agent.team ? ` · ${agent.team}` : ""}`}
</p>
</div>
</li>
))}
{fleet.working.length === 0 && (
<li className="py-4 text-center text-sm text-muted-foreground">
No one is mid-task.
</li>
)}
</ul>
</TgSheet>
);
}
/**
* 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
@@ -234,9 +294,72 @@ function NeedsYouBanner({
export function TgTodayTab({
onNavigate,
}: {
onNavigate: (tab: TgTab) => void;
/** `intent: "release"` deep-focuses the release proposal in Approvals. */
onNavigate: (tab: TgTab, intent?: "release") => void;
}) {
const queryClient = useQueryClient();
const [fleetOpen, setFleetOpen] = useState(false);
const [sweepOpen, setSweepOpen] = useState(false);
const [ackBusy, setAckBusy] = useState(false);
const [sweepBusy, setSweepBusy] = useState(false);
// Shares the Inbox tab's query cache; powers the Ack-all badge + action.
const { data: notifData } = useNotifications();
const pendingAcks = (notifData?.items ?? []).filter(
(n) => n.requires_ack && !n.is_acknowledged,
);
const runAckAll = async () => {
haptics.tap();
if (pendingAcks.length === 0) {
toast.info("Nothing is waiting for an ack");
return;
}
setAckBusy(true);
const results = await Promise.allSettled(
pendingAcks.map((n) => notificationsApi.acknowledge(n.id)),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
await queryClient.invalidateQueries({ queryKey: notificationKeys.all });
setAckBusy(false);
if (ok === results.length) {
haptics.success();
toast.success(`Acknowledged ${ok} notification${ok === 1 ? "" : "s"}`);
} else {
haptics.error();
toast.warning(`Acknowledged ${ok} of ${results.length}`);
}
};
const runSweep = async () => {
setSweepBusy(true);
try {
const projects = (await projectsApi.list()).filter(
(p) => p.has_git_token,
);
let deleted = 0;
let errors = 0;
for (const p of projects) {
try {
const res = await gitApi.cleanupBranches({ project_slug: p.slug });
deleted += res.remote_deleted;
errors += res.errors;
} catch {
errors += 1;
}
}
haptics.success();
toast.success(
`Swept ${projects.length} project${projects.length === 1 ? "" : "s"}: ${deleted} stale branch${deleted === 1 ? "" : "es"} deleted${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`,
);
} catch (err) {
haptics.error();
toast.error(getErrorMessage(err));
} finally {
setSweepBusy(false);
setSweepOpen(false);
}
};
const { data, isLoading, isError } = useQuery<TodayBrief>({
queryKey: ["tg-today"],
queryFn: async () => {
@@ -274,32 +397,55 @@ export function TgTodayTab({
}
const { needs_you: needs, fleet, spend, velocity, ship } = data;
const go = (tab: TgTab) => {
const go = (tab: TgTab, intent?: "release") => {
haptics.tap();
onNavigate(tab);
if (intent) onNavigate(tab, intent);
else onNavigate(tab);
};
const idle = fleet.by_status.idle ?? 0;
const active =
fleet.by_status.active ?? Math.max(fleet.working.length, 0);
const active = fleet.by_status.active ?? Math.max(fleet.working.length, 0);
return (
<div className="space-y-3">
<div className="tg-stagger space-y-3">
<header className="flex items-center justify-between px-1 pt-0.5">
<p className="tg-display text-[13px] tracking-[0.24em] text-muted-foreground">
ROBOCO<span className="tg-cursor text-primary">_</span>
</p>
</header>
<SpendHero spend={spend} />
{/* Operations, not navigation — the tab bar already navigates. */}
<div className="flex items-stretch gap-2 px-1">
<TgCircleAction
icon={CheckSquare}
label="Approve"
badge={needs.total}
accent
onPress={() => go("approvals")}
icon={IconShip}
label="Ship"
badge={ship.open_release_proposal ? 1 : undefined}
accent={ship.open_release_proposal}
onPress={() => go("approvals", "release")}
/>
<TgCircleAction icon={Kanban} label="Board" onPress={() => go("board")} />
<TgCircleAction icon={Bell} label="Inbox" onPress={() => go("inbox")} />
<TgCircleAction
icon={MessageSquare}
label="Chat"
onPress={() => go("chat")}
icon={IconAckAll}
label="Ack all"
badge={pendingAcks.length}
busy={ackBusy}
onPress={() => void runAckAll()}
/>
<TgCircleAction
icon={IconSweep}
label="Sweep"
busy={sweepBusy}
onPress={() => {
haptics.tap();
setSweepOpen(true);
}}
/>
<TgCircleAction
icon={IconFleet}
label="Fleet"
onPress={() => {
haptics.tap();
setFleetOpen(true);
}}
/>
</div>
@@ -318,9 +464,18 @@ export function TgTodayTab({
}
>
{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>
) : (
<div className="space-y-2">
<button
type="button"
onClick={() => {
haptics.tap();
setFleetOpen(true);
}}
className="w-full space-y-2 text-left"
>
<div className="flex -space-x-1.5 overflow-hidden">
{fleet.working.map((a) => (
<TgAvatar key={a.name} name={a.name} active />
@@ -332,7 +487,7 @@ export function TgTodayTab({
key={agent.name}
className="flex items-baseline gap-2 text-[13px] leading-snug"
>
<span className="shrink-0 font-mono text-xs font-medium">
<span className="tg-display shrink-0 text-xs">
{agent.name}
</span>
{agent.task_title && (
@@ -343,7 +498,12 @@ export function TgTodayTab({
</li>
))}
</ul>
</div>
{fleet.working.length > 3 && (
<p className="text-[11px] text-muted-foreground">
+{fleet.working.length - 3} more · tap for the full roster
</p>
)}
</button>
)}
</TgSection>
@@ -369,7 +529,7 @@ export function TgTodayTab({
>
<p
className={cn(
"text-[22px] font-semibold leading-tight tracking-tight tabular-nums",
"tg-display text-[22px] leading-tight tabular-nums",
ship.open_release_proposal && "text-primary",
)}
>
@@ -385,6 +545,35 @@ export function TgTodayTab({
</button>
</TgSection>
</div>
<FleetSheet
fleet={fleet}
open={fleetOpen}
onClose={() => setFleetOpen(false)}
/>
<TgSheet
open={sweepOpen}
onClose={() => {
if (!sweepBusy) setSweepOpen(false);
}}
title="Sweep branches"
>
<div className="space-y-3 pb-1">
<p className="text-sm leading-relaxed text-muted-foreground">
Deletes the remote and local branches of every completed or
cancelled task, across every project with git configured. Live
branches and environment rungs are spared.
</p>
<Button
className="w-full"
disabled={sweepBusy}
onClick={() => void runSweep()}
>
{sweepBusy ? "Sweeping…" : "Sweep stale branches"}
</Button>
</div>
</TgSheet>
</div>
);
}
+24 -16
View File
@@ -26,36 +26,40 @@ export function TgCircleAction({
label,
badge,
accent = false,
busy = false,
onPress,
}: {
icon: LucideIcon;
icon: React.ComponentType<{ className?: string }>;
label: string;
badge?: number;
accent?: boolean;
/** Disables the button and spins the icon while an operation runs. */
busy?: boolean;
onPress: () => void;
}) {
return (
<button
type="button"
onClick={onPress}
className="flex flex-1 flex-col items-center gap-1.5"
disabled={busy}
className="flex flex-1 flex-col items-center gap-1.5 disabled:opacity-60"
>
<span
className={cn(
"relative flex h-12 w-12 items-center justify-center rounded-full transition-transform active:scale-95",
"relative flex h-12 w-12 items-center justify-center rounded-full transition-all duration-200 ease-out active:scale-90",
accent
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
? "bg-gradient-to-b from-primary to-primary/80 text-primary-foreground shadow-[0_8px_20px_-8px] shadow-primary/60"
: "bg-gradient-to-b from-muted to-muted/60 text-foreground ring-1 ring-inset ring-white/5",
)}
>
<Icon className="h-5 w-5" />
<Icon className={cn("h-5 w-5", busy && "animate-pulse")} />
{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">
<span className="tg-display text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
{label}
</span>
</button>
@@ -86,7 +90,7 @@ export function TgAvatar({ name, active }: { name: string; active?: boolean }) {
<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",
"flex h-9 w-9 items-center justify-center rounded-full text-[11px] font-semibold ring-1 ring-inset ring-white/10",
hue,
)}
>
@@ -120,7 +124,7 @@ export function TgSection({
)}
>
<header className="flex items-center justify-between gap-2 px-3 pb-1 pt-2.5">
<h2 className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
<h2 className="tg-display flex items-center gap-1.5 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
{Icon && <Icon className="h-3.5 w-3.5" />}
{title}
</h2>
@@ -181,11 +185,15 @@ export function TgRow({
* 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",
amber:
"bg-gradient-to-br from-amber-400/25 to-amber-500/5 text-amber-300 ring-1 ring-inset ring-amber-400/20",
sky: "bg-gradient-to-br from-sky-400/25 to-sky-500/5 text-sky-300 ring-1 ring-inset ring-sky-400/20",
violet:
"bg-gradient-to-br from-violet-400/25 to-violet-500/5 text-violet-300 ring-1 ring-inset ring-violet-400/20",
emerald:
"bg-gradient-to-br from-emerald-400/25 to-emerald-500/5 text-emerald-300 ring-1 ring-inset ring-emerald-400/20",
rose: "bg-gradient-to-br from-rose-400/25 to-rose-500/5 text-rose-300 ring-1 ring-inset ring-rose-400/20",
muted: "bg-muted text-muted-foreground ring-1 ring-inset ring-white/5",
};
export function TgRowIcon({
@@ -198,7 +206,7 @@ export function TgRowIcon({
return (
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg",
"flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px]",
_TILE_TONES[tone] ?? _TILE_TONES.muted,
)}
>
@@ -220,7 +228,7 @@ export function TgStat({
<div>
<p
className={cn(
"text-[22px] font-semibold leading-tight tracking-tight tabular-nums",
"tg-display text-[22px] leading-tight tabular-nums",
tone === "attention" && "text-primary",
)}
>
+233 -5
View File
@@ -9,6 +9,17 @@ import type { XPost } from "@/lib/api/x";
import type { VideoPost } from "@/lib/api/video";
import type { RoadmapCycle } from "@/lib/api/roadmap";
import type { TodayBrief } from "@/components/tg/tg-today-tab";
import {
Complexity,
NotificationPriority,
NotificationType,
TaskNature,
TaskStatus,
TaskType,
Team,
type Notification,
type Task,
} from "@/types";
export const DEMO_RELEASE: ReleaseProposal = {
task_id: "demo-release",
@@ -23,8 +34,17 @@ export const DEMO_RELEASE: ReleaseProposal = {
],
drafted_changelog:
"## 0.26.0\n\n### Added\n- Telegram Mini App V4: Today brief, native approvals card stack.\n\n### Fixed\n- CEO approve-and-merge no longer fails on a diverged workspace clone.",
version_bump_plan: ["pyproject.toml", "roboco/__init__.py", "panel/package.json"],
gaps: [{ category: "docs", detail: "docs.roboco.tech Mini App page not yet updated" }],
version_bump_plan: [
"pyproject.toml",
"roboco/__init__.py",
"panel/package.json",
],
gaps: [
{
category: "docs",
detail: "docs.roboco.tech Mini App page not yet updated",
},
],
migration_notes: [],
gate_state: "green",
},
@@ -132,9 +152,24 @@ export const DEMO_TODAY: TodayBrief = {
total: 26,
by_status: { active: 4, idle: 22 },
working: [
{ name: "be-dev-1", role: "developer", team: "backend", task_title: "Webhook rate limiting" },
{ name: "fe-qa", role: "qa", team: "frontend", task_title: "Metrics time-series review" },
{ name: "ux-dev-2", role: "developer", team: "ux_ui", task_title: "v0.26.0 release motion" },
{
name: "be-dev-1",
role: "developer",
team: "backend",
task_title: "Webhook rate limiting",
},
{
name: "fe-qa",
role: "qa",
team: "frontend",
task_title: "Metrics time-series review",
},
{
name: "ux-dev-2",
role: "developer",
team: "ux_ui",
task_title: "v0.26.0 release motion",
},
],
},
spend: {
@@ -146,3 +181,196 @@ export const DEMO_TODAY: TodayBrief = {
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 },
};
const _now = Date.now();
const _iso = (minsAgo: number) =>
new Date(_now - minsAgo * 60_000).toISOString();
function demoTask(
overrides: Partial<Task> & Pick<Task, "id" | "title" | "status" | "team">,
): Task {
return {
description: "",
constraints: null,
acceptance_criteria: [],
priority: 2,
sequence: 0,
created_by: "main-pm",
assigned_to: null,
parent_task_id: null,
dependency_ids: [],
blocker_ids: [],
created_at: _iso(600),
updated_at: _iso(30),
claimed_at: null,
started_at: null,
completed_at: null,
target_date: null,
estimated_complexity: Complexity.MEDIUM,
nature: TaskNature.TECHNICAL,
task_type: TaskType.CODE,
project_id: "demo-project",
docs_complete: false,
pr_created: false,
pm_approvals: {},
plan: null,
checkpoints: [],
progress_updates: [],
commits: [],
dev_notes: null,
qa_notes: null,
auditor_notes: null,
quick_context: null,
self_verified: false,
qa_verified: null,
branch_name: null,
pr_number: null,
pr_url: null,
...overrides,
};
}
export const DEMO_TASKS: Task[] = [
demoTask({
id: "demo-t1",
title: "Payments retry queue hardening",
status: TaskStatus.AWAITING_CEO_APPROVAL,
team: Team.BACKEND,
assigned_to: "be-dev-2",
description:
"Webhook deliveries that fail mid-flight are retried with exponential backoff and a dead-letter queue after five attempts.",
acceptance_criteria: [
"Failed webhook deliveries retry with exponential backoff",
"A delivery lands in the dead-letter queue after 5 failed attempts",
"The DLQ is drainable from the admin panel",
],
pr_number: 612,
pr_url: "https://github.com/example/demo/pull/612",
branch_name: "feature/backend/DEMO612",
}),
demoTask({
id: "demo-t2",
title: "Docs search index rebuild",
status: TaskStatus.BLOCKED,
team: Team.FRONTEND,
assigned_to: "fe-dev-1",
description: "Blocked on the docs-site deploy token rotation.",
acceptance_criteria: ["Search results reflect pages published this week"],
}),
demoTask({
id: "demo-t3",
title: "Webhook rate limiting",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
assigned_to: "be-dev-1",
acceptance_criteria: [
"Per-tenant rate limits enforced at the gateway",
"429 responses carry a Retry-After header",
],
}),
demoTask({
id: "demo-t4",
title: "Metrics time-series review",
status: TaskStatus.AWAITING_QA,
team: Team.FRONTEND,
assigned_to: "fe-qa",
acceptance_criteria: ["Charts render loading, empty, and error states"],
pr_number: 604,
pr_url: "https://github.com/example/demo/pull/604",
}),
demoTask({
id: "demo-t5",
title: "Onboarding empty-state illustrations",
status: TaskStatus.NEEDS_REVISION,
team: Team.UX_UI,
assigned_to: "ux-dev-1",
revision_count: 2,
acceptance_criteria: [
"Every dashboard empty state has a branded illustration",
],
}),
demoTask({
id: "demo-t6",
title: "v0.26.0 release motion",
status: TaskStatus.IN_PROGRESS,
team: Team.UX_UI,
assigned_to: "ux-dev-2",
acceptance_criteria: ["Both 9:16 and 1:1 cuts render under 20s"],
}),
demoTask({
id: "demo-t7",
title: "Self-serve workspace invites",
status: TaskStatus.PENDING,
team: Team.BACKEND,
acceptance_criteria: ["Invited members land in the right workspace role"],
}),
demoTask({
id: "demo-t8",
title: "Mini App today brief",
status: TaskStatus.COMPLETED,
team: Team.FRONTEND,
assigned_to: "fe-dev-2",
completed_at: _iso(1400),
acceptance_criteria: ["One round trip renders the whole brief"],
}),
];
export const DEMO_NOTIFICATIONS: Notification[] = [
{
id: "demo-n1",
type: NotificationType.BLOCKER_ESCALATION,
priority: NotificationPriority.URGENT,
from_agent: "main-pm",
to_agents: ["ceo-renzo"],
subject: "Docs search index rebuild is blocked",
body: "The docs-site deploy token expired; fe-dev-1 cannot push the rebuilt index. Needs a token rotation from the project settings.",
requires_ack: true,
is_acknowledged: false,
is_fully_acknowledged: false,
is_read: false,
related_task_id: "demo-t2",
related_message_ids: [],
timestamp: _iso(35),
expires_at: null,
acked_by: [],
acked_at: {},
},
{
id: "demo-n2",
type: NotificationType.APPROVAL,
priority: NotificationPriority.HIGH,
from_agent: "main-pm",
to_agents: ["ceo-renzo"],
subject: "Payments retry queue hardening awaits your approval",
body: "QA passed, docs written, PR #612 green. The root PR is assembled and gated — your call.",
requires_ack: false,
is_acknowledged: false,
is_fully_acknowledged: false,
is_read: false,
related_task_id: "demo-t1",
related_message_ids: [],
timestamp: _iso(95),
expires_at: null,
acked_by: [],
acked_at: {},
},
{
id: "demo-n3",
type: NotificationType.KNOWLEDGE_SHARE,
priority: NotificationPriority.NORMAL,
from_agent: "be-dev-2",
to_agents: ["ceo-renzo"],
subject: "Learning: idempotency keys beat retry dedupe",
body: "Webhook consumers with idempotency keys made the DLQ drain safe to re-run — worth adopting on every external delivery path.",
requires_ack: false,
is_acknowledged: true,
is_fully_acknowledged: true,
is_read: true,
related_task_id: null,
related_message_ids: [],
timestamp: _iso(400),
expires_at: null,
acked_by: ["ceo-renzo"],
acked_at: {},
},
];