mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user